Carry the branch on every offline-sales row instead of picking a store
The upload modal made the admin choose a branch, then generated and validated the workbook against that choice. A merchant with several outlets had to repeat the whole cycle per branch, and the picker defaulted to the first outlet, so an admin who never opened it credited the wrong store — the file and the selection agreed, so nothing flagged it. The sheet now carries tenantid, locationid and the store name as locked columns on every row, and the row's own locationid routes its sale. One file covers the whole business: fill in qtysold wherever something sold, across as many branches as needed, and upload once. The picker is gone from the admin surface entirely, and the store user's page keeps passing its locationId, which pins the upload to that branch and rejects rows for any other before they are even sent. Bills are keyed on branch first and bill number second. Counter books at different outlets restart numbering from 1, so a shared number is two sales rather than a duplicate, and keying on the number alone would have dropped the second one. Because a single upload can now move stock at six outlets, one total is no longer enough to check before committing: the preview gains a store count, a per-store table of lines, units, amount and problems, and a Store column on every row, and results name the branch on each bill. Rows are ordered store then product with an Excel autofilter, so a branch can isolate its own rows in a file spanning the business. Verified end to end against a two-branch tenant sharing a product id across both outlets: one upload deducted each branch independently, a pinned upload refused the other branch's rows, and re-uploading deducted nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<SaleTemplate> {
|
||||
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<OfflineSalesUploadResponse> {
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<unknown[]>(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<string, ParsedSaleRow[]>();
|
||||
|
||||
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<string>();
|
||||
const stores = new Map<number, SaleSummary['byStore'][number]>();
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user