diff --git a/src/components/InventoryView.tsx b/src/components/InventoryView.tsx index ca8865a..d2b2952 100644 --- a/src/components/InventoryView.tsx +++ b/src/components/InventoryView.tsx @@ -1107,14 +1107,14 @@ export default function InventoryView({ })()} - {/* Offline (counter) sales import. `locations` is passed so an admin picks - the branch the bills belong to — this view otherwise operates on the - tenant's first outlet, which would silently credit the wrong store. */} + {/* Offline (counter) sales import. No locationId is passed: the admin gets + one workbook covering every branch, and each row's own locationid + routes its sale to the right store. This view operates on the tenant's + first outlet elsewhere, which would have been the wrong store to + credit for most of these sales. */} {showOfflineSales && ( ({ locationid, locationname }))} onClose={() => setShowOfflineSales(false)} /> )} diff --git a/src/components/OfflineSalesUpload.tsx b/src/components/OfflineSalesUpload.tsx index 40e06e1..75ba689 100644 --- a/src/components/OfflineSalesUpload.tsx +++ b/src/components/OfflineSalesUpload.tsx @@ -7,18 +7,24 @@ * Offline (counter) sales import. * * A sale rung up at the till never passes through the app, so nothing deducts - * its stock. This is the way that stock gets deducted: download a spreadsheet - * pre-filled with the outlet's catalogue, type sold quantities into it, upload - * it back. Imported sales become real orders, so they reduce stock through the - * same path an app order uses and show up in revenue reporting. + * its stock. This is how that stock gets deducted: download a spreadsheet + * pre-filled with the catalogue, type sold quantities into it, upload it back. + * Imported sales become real orders, so they reduce stock through the same path + * an app order uses and show up in revenue reporting. + * + * ONE file covers EVERY branch. There is no store picker: each row of the sheet + * carries its own tenantid and locationid, and that row's locationid decides + * which branch the sale comes out of. A merchant with six outlets fills in rows + * for all six and uploads once. Nothing here has to know or choose a store. * * The template must be downloaded rather than hand-written because `productid` * is the only usable key for a product — SKUs are not unique in this catalogue * (6,245 products share 93 sku values) and names are not unique either. The - * download fills productid in so nobody has to know it. + * download fills productid and locationid in so nobody has to know them. * - * Used by both surfaces. The admin console passes the outlet it has selected; - * the store user's page passes their own, which is the only one they can reach. + * Used by both surfaces. The admin console passes no locationId, so the file + * routes itself. The store user's page passes theirs, which pins the upload to + * their branch and rejects rows for any other — enforced again server-side. */ import { useCallback, useMemo, useRef, useState } from 'react'; @@ -30,6 +36,7 @@ import { FileSpreadsheet, Loader2, RotateCcw, + Store, Upload, X, XCircle, @@ -49,19 +56,15 @@ import { interface OfflineSalesUploadProps { tenantId: number; - /** The outlet to credit. For a store user this is their own and is the only - * one reachable; for an admin it is the initial selection in `locations`. */ - locationId: number; - /** Shown in the header so it is unambiguous which store is being credited. */ + /** + * Pins the upload to a single branch. Passed by the store user's page so they + * can only ever import for their own store. Omitted by the admin console, so + * the workbook spans every branch and each row routes itself. + */ + locationId?: number; + /** Shown in the header when the upload is pinned to one store. */ storeName?: string; userId?: number; - /** - * Outlets the user may choose between. Passed by the admin console, whose - * users manage several branches and must say which one a bill belongs to. - * Omitted for a store user, which locks the import to their own outlet — the - * backend enforces the same thing regardless of what the file says. - */ - locations?: { locationid: number; locationname: string }[]; onClose: () => void; } @@ -73,28 +76,29 @@ export default function OfflineSalesUpload({ locationId, storeName, userId, - locations, onClose, }: OfflineSalesUploadProps) { const queryClient = useQueryClient(); const fileInputRef = useRef(null); - const [activeLocationId, setActiveLocationId] = useState(locationId); const [parsed, setParsed] = useState(null); const [fileName, setFileName] = useState(''); const [dragging, setDragging] = useState(false); const [result, setResult] = useState(null); const [uploadError, setUploadError] = useState(''); + const pinned = (locationId ?? 0) > 0; + const templateQuery = useQuery({ - queryKey: ['saleTemplate', tenantId, activeLocationId], - queryFn: () => getSaleTemplate({ tenantid: tenantId, locationid: activeLocationId }), - enabled: tenantId > 0 && activeLocationId > 0, + queryKey: ['saleTemplate', tenantId, locationId ?? 0], + queryFn: () => getSaleTemplate({ tenantid: tenantId, locationid: locationId ?? 0 }), + enabled: tenantId > 0, // Always refetched on open: a template is only useful if its stock figures - // and product list match the outlet right now. + // and product list match the outlets right now. staleTime: 0, }); + const template = templateQuery.data; const summary = useMemo(() => (parsed ? summarise(parsed.rows) : null), [parsed]); const blocked = Boolean(parsed && (parsed.fatal.length > 0 || (summary?.errors ?? 0) > 0)); @@ -103,7 +107,9 @@ export default function OfflineSalesUpload({ if (!parsed) throw new Error('No file loaded.'); return uploadOfflineSales({ tenantid: tenantId, - locationid: activeLocationId, + // Sent only when pinned. Left at 0, the backend routes each bill by the + // locationid the sheet gave it. + locationid: locationId ?? 0, userid: userId, bills: toBills(parsed.rows), }); @@ -111,9 +117,10 @@ export default function OfflineSalesUpload({ onSuccess: (res) => { setResult(res); setUploadError(''); - // Stock has moved, so every view reading it is now stale. Invalidating - // broadly is deliberate — a partially-refreshed inventory screen after an - // import is worse than a few extra refetches. + // Stock has moved at potentially several branches, so every view reading + // it is now stale. Invalidating broadly is deliberate — a partially + // refreshed inventory screen after an import is worse than a few extra + // refetches. queryClient.invalidateQueries(); }, onError: (err: Error) => setUploadError(err.message), @@ -126,19 +133,23 @@ export default function OfflineSalesUpload({ setFileName(file.name); try { const buffer = await file.arrayBuffer(); - setParsed(parseSalesWorkbook(buffer, { tenantid: tenantId, locationid: activeLocationId })); + setParsed( + parseSalesWorkbook(buffer, { + tenantid: tenantId, + locationid: locationId, + allowedLocationIds: template?.locations.map((l) => l.locationid), + }), + ); } catch { setParsed({ tenantid: null, - locationid: null, - locationname: '', rows: [], skipped: 0, fatal: ['That file could not be read. Upload the .xlsx template you downloaded.'], }); } }, - [tenantId, activeLocationId], + [tenantId, locationId, template], ); const reset = () => { @@ -149,12 +160,12 @@ export default function OfflineSalesUpload({ if (fileInputRef.current) fileInputRef.current.value = ''; }; - const picker = locations && locations.length > 1 ? locations : null; - const outletLabel = - picker?.find((l) => l.locationid === activeLocationId)?.locationname || - storeName || - templateQuery.data?.locationname || - `Outlet ${activeLocationId}`; + const storeCount = template?.locations.length ?? 0; + const scopeLabel = pinned + ? storeName || template?.locations[0]?.locationname || `Outlet ${locationId}` + : storeCount === 1 + ? template?.locations[0]?.locationname || 'your store' + : `all ${storeCount} stores`; return (
@@ -167,7 +178,7 @@ export default function OfflineSalesUpload({

Offline Sales Upload

- Counter sales for {outletLabel} + Counter sales for {scopeLabel}

@@ -185,36 +196,6 @@ export default function OfflineSalesUpload({ ) : ( <> - {/* Which branch. Only rendered for a user who has more than one — - a store user has no choice to make and showing them a picker - would imply they do. Changing it clears any loaded file, since - a file's productids belong to the outlet it was generated for. */} - {picker && ( -
- - -

- Stock is deducted from this store, and the template below is built from its catalogue. -

-
- )} - {/* Step 1 — the template. Presented first and prominently because uploading anything else will not work. */}
@@ -224,19 +205,21 @@ export default function OfflineSalesUpload({ 1 - Download the template for this store + Download the template

{templateQuery.isLoading - ? 'Loading this outlet’s catalogue…' + ? 'Loading your catalogue…' : templateQuery.isError - ? 'Could not load this outlet’s catalogue.' - : `${templateQuery.data?.products.length ?? 0} products stocked here. Fill in the qtysold column and upload the file back.`} + ? 'Could not load your catalogue.' + : pinned || storeCount <= 1 + ? `${template?.products.length ?? 0} products stocked. Fill in the qtysold column and upload the file back.` + : `${template?.products.length ?? 0} rows covering all ${storeCount} stores. Every row already says which store it belongs to — fill in qtysold wherever you sold something and upload the one file.`}

+ + {/* What the one file covers. Shown so it is obvious up front + that no store has to be chosen anywhere. */} + {!pinned && storeCount > 1 && ( +
+ {template?.locations.map((l) => ( + + + {l.locationname} + #{l.locationid} + · {l.productcount} + + ))} +
+ )} + {templateQuery.isError && (

{(templateQuery.error as Error).message} @@ -325,7 +327,8 @@ export default function OfflineSalesUpload({ {summary && parsed.rows.length > 0 && ( <> -

+
+ @@ -337,6 +340,45 @@ export default function OfflineSalesUpload({ />
+ {/* Per-store totals. With one file covering the whole + business, the single figure above is not enough to + sanity-check what is about to be deducted where. */} + {summary.stores > 1 && ( +
+ + + + + + + + + + + + {summary.byStore.map((s) => ( + 0 ? 'bg-red-50' : 'bg-white'}> + + + + + + + ))} + +
StoreLinesUnitsAmountProblems
+ {s.locationname || `Outlet ${s.locationid}`} + #{s.locationid} + {s.lines}{s.units}{money(s.amount)} 0 ? 'text-red-700' : 'text-emerald-700' + }`} + > + {s.errors} +
+
+ )} + {summary.errors > 0 && (

@@ -357,10 +399,11 @@ export default function OfflineSalesUpload({ )}

- +
+ @@ -379,6 +422,9 @@ export default function OfflineSalesUpload({ className={bad ? 'bg-red-50' : warn ? 'bg-amber-50' : 'bg-white'} > +
RowStore Product Qty Price{r.excelRow} + {r.locationname || `#${r.locationid}`} + {r.productname || '—'} #{r.productid} @@ -425,8 +471,8 @@ export default function OfflineSalesUpload({ {!result && (

- Imported sales reduce stock and appear in Orders marked OFFLINE. - Re-uploading the same file will not deduct twice. + Each sale is deducted from the store named on its own row, and appears in Orders marked{' '} + OFFLINE. Re-uploading the same file will not deduct twice.

{parsed && ( @@ -448,7 +494,8 @@ export default function OfflineSalesUpload({ ) : ( <> - Import {summary?.bills ? `${summary.bills} Bill${summary.bills === 1 ? '' : 's'}` : 'Sales'} + Import{' '} + {summary?.bills ? `${summary.bills} Bill${summary.bills === 1 ? '' : 's'}` : 'Sales'} )} @@ -519,6 +566,7 @@ function ResultPanel({ + @@ -530,11 +578,12 @@ function ResultPanel({ {result.results.map((r, i) => ( +
Store Bill Result Order
{r.locationname || (r.locationid ? `#${r.locationid}` : '—')} {r.billno || '—'} {r.status === 'imported' && ( diff --git a/src/services/fiestaApi.ts b/src/services/fiestaApi.ts index 723093f..0963b60 100644 --- a/src/services/fiestaApi.ts +++ b/src/services/fiestaApi.ts @@ -1292,6 +1292,9 @@ export async function getSalesSummary(opts: { // ════════════════════════════════════════════════════════════════════════════ export interface SaleTemplateRow { + tenantid: number; + locationid: number; + locationname: string; productid: number; productname: string; productunit: string; @@ -1302,33 +1305,45 @@ export interface SaleTemplateRow { taxpercent: number; } -export interface SaleTemplate { - tenantid: number; +export interface SaleTemplateLocation { locationid: number; locationname: string; + productcount: number; +} + +export interface SaleTemplate { + tenantid: number; + /** 0 when the template spans every branch of the tenant. */ + locationid: number; + locations: SaleTemplateLocation[]; products: SaleTemplateRow[]; } /** - * GET /products/getsaletemplate — every product stocked at one outlet, with its - * live ledger balance and price. + * GET /products/getsaletemplate — products stocked across the tenant's + * branches, each with its live ledger balance and price. + * + * `locationid` is optional and defaults to every branch, which is the normal + * case: one workbook covers the whole business and each row carries the branch + * its stock belongs to. Pass a locationid to narrow it to a single store. * * This is what the offline-sales spreadsheet is built from, and the reason it * has to be generated rather than hand-written: `productid` is the only usable * key for a product. Across the live catalogue 6,245 products share just 93 * distinct `productsku` values (one tenant has 463 products all carrying sku * "1"), so a store cannot identify a product by SKU, and product names are not - * unique enough either. Pre-filling productid removes the problem entirely. + * unique enough either. Pre-filling productid and locationid removes both + * problems at once. */ export async function getSaleTemplate(opts: { tenantid: number; - locationid: number; + locationid?: number; }): Promise { const res = await fiestaGet<{ details: SaleTemplate | null }>('products/getsaletemplate', { tenantid: opts.tenantid, - locationid: opts.locationid, + locationid: opts.locationid ?? 0, }); - if (!res?.details) throw new Error('No products are stocked at this outlet yet.'); + if (!res?.details) throw new Error('No products are stocked at any of your outlets yet.'); return res.details; } @@ -1342,6 +1357,8 @@ export interface OfflineSaleItemInput { } export interface OfflineSaleBillInput { + /** Branch this bill was rung up at, taken from the spreadsheet row. */ + locationid: number; billno?: string; saledate?: string; paymentmode?: string; @@ -1352,6 +1369,8 @@ export interface OfflineSaleBillInput { } export interface OfflineSaleResult { + locationid: number; + locationname: string; billno: string; status: 'imported' | 'duplicate' | 'failed'; orderid: string; @@ -1379,10 +1398,16 @@ export interface OfflineSalesUploadResponse { * * Re-uploading the same file is safe: the backend records each bill number and * refuses one it has already imported rather than deducting the stock twice. + * + * `locationid` is a scope constraint, not the destination. Omit it and each + * bill goes to the branch named on its own rows — the multi-branch case. Set it + * and the upload is pinned to that branch, with any bill naming another one + * refused; that is how a store user is held to their own store regardless of + * what the spreadsheet was edited to say. */ export async function uploadOfflineSales(input: { tenantid: number; - locationid: number; + locationid?: number; userid?: number; bills: OfflineSaleBillInput[]; }): Promise { @@ -1391,7 +1416,7 @@ export async function uploadOfflineSales(input: { 'POST', { tenantid: input.tenantid, - locationid: input.locationid, + locationid: input.locationid ?? 0, userid: input.userid ?? 0, bills: input.bills, }, diff --git a/src/services/offlineSalesSheet.ts b/src/services/offlineSalesSheet.ts index 2705f85..1e188e0 100644 --- a/src/services/offlineSalesSheet.ts +++ b/src/services/offlineSalesSheet.ts @@ -4,15 +4,21 @@ */ /** - * The spreadsheet half of offline-sales import: turning an outlet's catalogue - * into a workbook the store fills in, and turning that workbook back into bills + * The spreadsheet half of offline-sales import: turning a merchant's catalogue + * into a workbook the stores fill in, and turning that workbook back into bills * the API can take. * - * Parsing happens here in the browser rather than on the server so the operator - * sees every problem — a bad quantity, a product that isn't theirs, a price - * they forgot — laid out against their own rows and can fix the file before - * anything is written. The backend validates all of it again regardless; this - * is for the person, not for safety. + * ONE workbook covers EVERY branch. Each row carries its own `tenantid` and + * `locationid`, and that row's `locationid` is what decides which branch the + * sale is deducted from. A merchant running six outlets downloads one file, and + * rows for all six can be filled in and uploaded together — nobody picks a + * store in the UI, because the sheet already says which store each line is for. + * + * Parsing happens in the browser rather than on the server so the operator sees + * every problem laid out against their own rows and can fix the file before + * anything is written. The backend validates all of it again, and re-checks + * that each locationid belongs to the tenant; this is for the person, not for + * safety. */ import * as XLSX from 'xlsx'; @@ -25,13 +31,17 @@ export const INFO_SHEET = 'Store Info'; const HELP_SHEET = 'Instructions'; /** Bumped only when the column set changes in a way an old file would break on. */ -export const TEMPLATE_VERSION = 1; +export const TEMPLATE_VERSION = 2; /** - * Column headers, in the order they appear. The first three are locked - * reference data; `qtysold` is the one the user is expected to type in. + * Column headers, in the order they appear. The first six are locked reference + * data — `locationid` among them, since it routes the sale — and `qtysold` is + * the one the user is expected to type in. */ const COLUMNS = [ + 'tenantid', + 'locationid', + 'locationname', 'productid', 'productname', 'currentstock', @@ -47,10 +57,13 @@ const COLUMNS = [ 'remarks', ] as const; -const COLUMN_WIDTHS = [11, 38, 13, 10, 11, 15, 11, 14, 13, 13, 18, 15, 24]; +const COLUMN_WIDTHS = [10, 11, 22, 11, 38, 13, 10, 11, 15, 11, 14, 13, 13, 18, 15, 24]; /** Rendered above the table so the sheet explains itself without the help tab. */ const HEADER_LABELS: Record = { + tenantid: 'tenantid (do not edit)', + locationid: 'locationid (do not edit)', + locationname: 'store (do not edit)', productid: 'productid (do not edit)', productname: 'productname (do not edit)', currentstock: 'currentstock (info)', @@ -69,16 +82,24 @@ const HEADER_LABELS: Record = { const INSTRUCTIONS: string[][] = [ ['How to record offline (counter) sales'], [''], - ['1.', 'Fill in the "qtysold" column on the Sales sheet for whatever you sold at the counter.'], + ['This ONE file covers every one of your stores.'], + ['Each row already says which store it belongs to, in the locationid and store columns.'], + ['Fill in rows for as many stores as you like and upload the file once —'], + ['each sale is deducted from the store named on its own row.'], + [''], + ['1.', 'Fill in the "qtysold" column for whatever was sold at the counter.'], ['', 'Leave the row blank or 0 if the product did not sell — blank rows are ignored.'], - ['2.', 'Do NOT edit productid or productname. They identify the product and must match.'], - ['', 'If a product is missing from the sheet, add it to the store catalogue first,'], - ['', 'then download a fresh template.'], - ['3.', 'unitprice defaults to the store price shown. Change it if you sold at a different price.'], + ['2.', 'Do NOT edit tenantid, locationid, store, productid or productname.'], + ['', 'They identify the store and the product, and must match.'], + ['', 'If a product is missing, add it to that store catalogue first, then download a'], + ['', 'fresh template.'], + ['3.', 'unitprice defaults to that store price. Change it if you sold at a different price.'], ['', 'If the price shows 0, the product has no price set — type the real one or the sale'], ['', 'will be recorded with no revenue.'], ['4.', 'billno groups rows into one bill. Rows sharing a billno become a single order.'], - ['', 'Leave billno empty and the whole sheet is imported as one bill.'], + ['', 'Bill numbers only need to be unique WITHIN a store — the same number at two'], + ['', 'different stores is treated as two separate sales.'], + ['', 'Leave billno empty and each store gets one bill for all its rows.'], ['5.', 'saledate accepts YYYY-MM-DD or DD-MM-YYYY. Blank means today.'], ['6.', 'paymentmode accepts Cash, Card or UPI. Blank means Cash.'], ['7.', 'taxpercent is treated as already included in unitprice (MRP), so the amount'], @@ -87,27 +108,33 @@ const INSTRUCTIONS: string[][] = [ ['', 'attached to that shopper; leave it blank and it goes to a walk-in customer.'], [''], ['Uploading the same file twice is safe.'], - ['Each bill is remembered, so a repeated bill is reported as already imported'], - ['and its stock is NOT deducted a second time.'], + ['Each bill is remembered per store, so a repeated bill is reported as already'], + ['imported and its stock is NOT deducted a second time.'], [''], ['Imported sales reduce stock exactly like an app order, and appear in Orders'], ['and in revenue reports marked as OFFLINE.'], ]; /** - * Build the workbook for one outlet. Every stocked product gets a row even when - * its stock is zero — the sheet is a worksheet to fill in, and hiding rows would - * just mean the operator cannot record a sale they actually made. + * Build the workbook. Every stocked product at every branch gets a row, even + * when its stock is zero — the sheet is a worksheet to fill in, and hiding rows + * would mean an operator could not record a sale they actually made. + * + * Rows arrive already ordered by store then product, so a store's rows sit + * together and can be filled in as one block. */ export function buildSaleTemplateWorkbook(template: SaleTemplate): XLSX.WorkBook { const header = COLUMNS.map((c) => HEADER_LABELS[c] ?? c); const body = template.products.map((p) => [ + p.tenantid, + p.locationid, + p.locationname, p.productid, p.productname, p.currentstock, - // qtysold onwards are left empty: these are the operator's columns, and - // pre-filling qtysold with 0 invites a file of accidental zero-quantity rows. + // qtysold onwards are the operator's columns, left empty: pre-filling + // qtysold with 0 invites a file of accidental zero-quantity rows. '', p.price > 0 ? p.price : '', '', @@ -123,22 +150,24 @@ export function buildSaleTemplateWorkbook(template: SaleTemplate): XLSX.WorkBook const sales = XLSX.utils.aoa_to_sheet([header, ...body]); sales['!cols'] = COLUMN_WIDTHS.map((w) => ({ wch: w })); sales['!freeze'] = { xSplit: '0', ySplit: '1' }; + // Excel's filter dropdowns, so a store can isolate its own rows in a file + // that spans the whole business. + sales['!autofilter'] = { ref: XLSX.utils.encode_range({ s: { r: 0, c: 0 }, e: { r: body.length, c: COLUMNS.length - 1 } }) }; - // The outlet identity travels in the file so the upload can be checked against - // the outlet the template was generated for, instead of trusting a number a - // person could retype. The backend re-authorises it either way. const info = XLSX.utils.aoa_to_sheet([ ['Field', 'Value'], ['tenantid', template.tenantid], - ['locationid', template.locationid], - ['locationname', template.locationname], ['generatedon', new Date().toISOString()], ['templateversion', TEMPLATE_VERSION], + ['stores in this file', template.locations.length], + [''], + ['Stores covered', 'Products'], + ...template.locations.map((l) => [`${l.locationid} — ${l.locationname}`, l.productcount]), [''], ['Do not edit this sheet.'], - ['These values tell the system which store the sales belong to.'], + ['Each sale is routed by the locationid on its own row in the Sales sheet.'], ]); - info['!cols'] = [{ wch: 18 }, { wch: 42 }]; + info['!cols'] = [{ wch: 34 }, { wch: 42 }]; const help = XLSX.utils.aoa_to_sheet(INSTRUCTIONS); help['!cols'] = [{ wch: 4 }, { wch: 92 }]; @@ -150,14 +179,15 @@ export function buildSaleTemplateWorkbook(template: SaleTemplate): XLSX.WorkBook return wb; } -/** `offline-sales-r-mart-2026-07-30.xlsx` — outlet and date, so a folder of - * these stays sortable and it is obvious which store a file belongs to. */ +/** Named for what it spans: one store by name, or "all-stores" for the full + * business, plus the date so a folder of these stays sortable. */ export function saleTemplateFilename(template: SaleTemplate): string { + const single = template.locations.length === 1 ? template.locations[0].locationname : ''; const slug = - template.locationname + (single || 'all-stores') .toLowerCase() .replace(/[^a-z0-9]+/g, '-') - .replace(/^-|-$/g, '') || `location-${template.locationid}`; + .replace(/^-|-$/g, '') || 'all-stores'; return `offline-sales-${slug}-${new Date().toISOString().slice(0, 10)}.xlsx`; } @@ -171,6 +201,9 @@ export function downloadSaleTemplate(template: SaleTemplate): void { export interface ParsedSaleRow { /** 1-based row number as shown in Excel, so an error can be pointed at. */ excelRow: number; + tenantid: number | null; + locationid: number; + locationname: string; productid: number; productname: string; currentstock: number | null; @@ -189,14 +222,12 @@ export interface ParsedSaleRow { } export interface ParsedSheet { - /** Outlet read from the Store Info sheet, when the file still has it. */ + /** Tenant read from the Store Info sheet, when the file still has it. */ tenantid: number | null; - locationid: number | null; - locationname: string; /** Rows with a quantity — blank ones are dropped, not reported. */ rows: ParsedSaleRow[]; - /** Rows skipped for having no quantity. Counted so the operator can tell an - * empty column apart from a file that genuinely had two sales in it. */ + /** Rows skipped for having no quantity. Counted so an empty column can be + * told apart from a file that genuinely had two sales in it. */ skipped: number; /** Problems with the file as a whole, not with a row. */ fatal: string[]; @@ -246,14 +277,25 @@ function normaliseHeader(raw: unknown): string { const PAYMENT_MODES = new Set(['cash', 'card', 'upi']); +export interface ParseScope { + tenantid: number; + /** When set, the upload is pinned to this branch and rows for any other are + * rejected — the store-user case. Omit for a multi-branch upload. */ + locationid?: number; + /** Branches the uploader may write to, for naming an unknown locationid in a + * useful way. Absence of this list disables the check, since the backend + * authorises every branch anyway. */ + allowedLocationIds?: number[]; +} + /** * Parse an uploaded workbook. Never throws for row-level problems — those are * attached to the row so the whole sheet can be shown at once, which is the * point of parsing client-side. Only a file that cannot be read at all, or has * no recognisable columns, produces a fatal. */ -export function parseSalesWorkbook(data: ArrayBuffer, expected?: { tenantid: number; locationid: number }): ParsedSheet { - const out: ParsedSheet = { tenantid: null, locationid: null, locationname: '', rows: [], skipped: 0, fatal: [] }; +export function parseSalesWorkbook(data: ArrayBuffer, scope: ParseScope): ParsedSheet { + const out: ParsedSheet = { tenantid: null, rows: [], skipped: 0, fatal: [] }; let wb: XLSX.WorkBook; try { @@ -263,31 +305,18 @@ export function parseSalesWorkbook(data: ArrayBuffer, expected?: { tenantid: num return out; } - // Store Info is read first: knowing the outlet lets a mismatched file be - // caught before any row is interpreted against the wrong catalogue. const infoSheet = wb.Sheets[INFO_SHEET]; if (infoSheet) { const infoRows = XLSX.utils.sheet_to_json(infoSheet, { header: 1, blankrows: false }); for (const r of infoRows) { - const key = toStr(r?.[0]).toLowerCase(); - const val = r?.[1]; - if (key === 'tenantid') out.tenantid = toNum(val); - else if (key === 'locationid') out.locationid = toNum(val); - else if (key === 'locationname') out.locationname = toStr(val); + if (toStr(r?.[0]).toLowerCase() === 'tenantid') out.tenantid = toNum(r?.[1]); } } - if (expected) { - if (out.tenantid !== null && out.tenantid !== expected.tenantid) { - out.fatal.push( - `This file was generated for a different account (tenant ${out.tenantid}). Download a fresh template.`, - ); - } - if (out.locationid !== null && out.locationid !== expected.locationid) { - out.fatal.push( - `This file was generated for ${out.locationname || `outlet ${out.locationid}`}, not the outlet you are uploading to. Download a fresh template for this store.`, - ); - } + if (out.tenantid !== null && out.tenantid !== scope.tenantid) { + out.fatal.push( + `This file was generated for a different account (tenant ${out.tenantid}). Download a fresh template.`, + ); } const sheet = wb.Sheets[SALES_SHEET] ?? wb.Sheets[wb.SheetNames[0]]; @@ -314,6 +343,16 @@ export function parseSalesWorkbook(data: ArrayBuffer, expected?: { tenantid: num ); return out; } + if (index.locationid === undefined && !scope.locationid) { + // Without a locationid column there is nothing to route a sale by, and + // guessing a branch would silently move the wrong store's stock. + out.fatal.push( + 'The Sales sheet is missing the "locationid" column, so there is no way to tell which store each sale belongs to. Download a fresh template.', + ); + return out; + } + + const allowed = scope.allowedLocationIds?.length ? new Set(scope.allowedLocationIds) : null; const cell = (row: unknown[], key: string): unknown => { const i = index[key]; @@ -326,21 +365,29 @@ export function parseSalesWorkbook(data: ArrayBuffer, expected?: { tenantid: num const qty = toNum(cell(raw, 'qtysold')); // Nothing sold on this line. Not an error — a template lists the whole - // catalogue and most rows are expected to be empty. + // catalogue of every store, and most rows are expected to be empty. if (qty === null || qty === 0) { out.skipped++; continue; } const productid = toNum(cell(raw, 'productid')); + const rowLocation = toNum(cell(raw, 'locationid')); const unitprice = toNum(cell(raw, 'unitprice')); const taxpercent = toNum(cell(raw, 'taxpercent')); const discount = toNum(cell(raw, 'discountamount')) ?? 0; const stock = toNum(cell(raw, 'currentstock')); const paymentmode = toStr(cell(raw, 'paymentmode')); + // A pinned upload (store user) supplies the branch, so a sheet without the + // column still works for them. + const locationid = rowLocation ?? scope.locationid ?? 0; + const row: ParsedSaleRow = { excelRow, + tenantid: toNum(cell(raw, 'tenantid')), + locationid, + locationname: toStr(cell(raw, 'locationname')), productid: productid ?? 0, productname: toStr(cell(raw, 'productname')), currentstock: stock, @@ -361,6 +408,17 @@ export function parseSalesWorkbook(data: ArrayBuffer, expected?: { tenantid: num if (!productid || productid <= 0) { row.errors.push('productid is missing — do not delete that column'); } + if (!locationid || locationid <= 0) { + row.errors.push('locationid is missing — this row does not say which store it belongs to'); + } else if (row.tenantid !== null && row.tenantid !== scope.tenantid) { + row.errors.push(`tenantid ${row.tenantid} is not your account`); + } else if (scope.locationid && locationid !== scope.locationid) { + // The store-user guard. The backend enforces this too; saying it here + // means they see it before uploading rather than as a rejected bill. + row.errors.push('this row is for another store, which you cannot upload for'); + } else if (allowed && !allowed.has(locationid)) { + row.errors.push(`locationid ${locationid} is not one of your stores`); + } if (qty < 0) { row.errors.push('qtysold cannot be negative'); } @@ -401,17 +459,21 @@ export function parseSalesWorkbook(data: ArrayBuffer, expected?: { tenantid: num /** * Group parsed rows into bills for the API. * - * Rows sharing a billno become one order. Rows with no billno collapse into a - * single unnumbered bill rather than one bill per row: a sheet where the - * operator ignored the billno column is one shopping trip far more often than it - * is fifty separate ones, and one bill per row would also mean one order per - * row cluttering the order list. + * Bills are keyed on BRANCH first and bill number second, so the same bill + * number at two stores stays two separate sales rather than colliding — which + * matters now that one file spans the whole business, and counter books at + * different outlets routinely restart numbering from 1. + * + * Rows with no billno collapse into a single unnumbered bill per branch rather + * than one bill per row: a sheet where the operator ignored the billno column is + * one shopping trip far more often than it is fifty separate ones, and one bill + * per row would also mean one order per row cluttering the order list. */ export function toBills(rows: ParsedSaleRow[]): OfflineSaleBillInput[] { const groups = new Map(); for (const r of rows) { - const key = r.billno.trim().toUpperCase() || '__nobill__'; + const key = `${r.locationid}::${r.billno.trim().toUpperCase() || '__nobill__'}`; const bucket = groups.get(key); if (bucket) bucket.push(r); else groups.set(key, [r]); @@ -424,6 +486,7 @@ export function toBills(rows: ParsedSaleRow[]): OfflineSaleBillInput[] { const first = (pick: (r: ParsedSaleRow) => string): string => group.map(pick).find((v) => v !== '') ?? ''; return { + locationid: group[0].locationid, billno: group[0].billno.trim(), saledate: first((r) => r.saledate), paymentmode: first((r) => r.paymentmode), @@ -442,29 +505,57 @@ export function toBills(rows: ParsedSaleRow[]): OfflineSaleBillInput[] { }); } -/** Totals for the preview bar. Amounts mirror the backend's arithmetic (tax - * inclusive), so the figure shown before upload is the one that gets recorded. */ -export function summarise(rows: ParsedSaleRow[]): { +export interface SaleSummary { lines: number; units: number; amount: number; errors: number; warnings: number; bills: number; -} { + stores: number; + /** Per-branch breakdown, so a multi-store upload can be checked store by + * store before it is committed. */ + byStore: { locationid: number; locationname: string; lines: number; units: number; amount: number; errors: number }[]; +} + +/** Totals for the preview bar. Amounts mirror the backend's arithmetic (tax + * inclusive), so the figure shown before upload is the one that gets recorded. */ +export function summarise(rows: ParsedSaleRow[]): SaleSummary { let units = 0; let amount = 0; let errors = 0; let warnings = 0; const bills = new Set(); + const stores = new Map(); for (const r of rows) { + const lineAmount = Math.max(0, (r.unitprice ?? 0) * r.qtysold - r.discountamount); units += r.qtysold; - amount += Math.max(0, (r.unitprice ?? 0) * r.qtysold - r.discountamount); + amount += lineAmount; if (r.errors.length) errors++; if (r.warnings.length) warnings++; - bills.add(r.billno.trim().toUpperCase() || '__nobill__'); + bills.add(`${r.locationid}::${r.billno.trim().toUpperCase() || '__nobill__'}`); + + let store = stores.get(r.locationid); + if (!store) { + store = { locationid: r.locationid, locationname: r.locationname, lines: 0, units: 0, amount: 0, errors: 0 }; + stores.set(r.locationid, store); + } + store.lines++; + store.units += r.qtysold; + store.amount += lineAmount; + if (r.errors.length) store.errors++; + if (!store.locationname && r.locationname) store.locationname = r.locationname; } - return { lines: rows.length, units, amount, errors, warnings, bills: bills.size }; + return { + lines: rows.length, + units, + amount, + errors, + warnings, + bills: bills.size, + stores: stores.size, + byStore: Array.from(stores.values()).sort((a, b) => b.amount - a.amount), + }; }