Files
daily_merchant_web/src/services/offlineSalesSheet.ts
abhishek cc08f2f6c5 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>
2026-07-31 12:18:09 +05:30

562 lines
21 KiB
TypeScript

/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
/**
* 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.
*
* 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';
import type { OfflineSaleBillInput, SaleTemplate } from './fiestaApi';
/** Sheet names. Parsing looks for SALES_SHEET by name, then falls back to the
* first sheet, so a file re-saved by Excel under a translated name still works. */
export const SALES_SHEET = 'Sales';
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 = 2;
/**
* 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',
'qtysold',
'unitprice',
'discountamount',
'taxpercent',
'billno',
'saledate',
'paymentmode',
'customername',
'customermobile',
'remarks',
] as const;
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)',
qtysold: 'qtysold *',
unitprice: 'unitprice',
discountamount: 'discountamount',
taxpercent: 'taxpercent',
billno: 'billno',
saledate: 'saledate',
paymentmode: 'paymentmode',
customername: 'customername',
customermobile: 'customermobile',
remarks: 'remarks',
};
const INSTRUCTIONS: string[][] = [
['How to record offline (counter) sales'],
[''],
['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 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.'],
['', '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'],
['', 'collected stays exactly unitprice x qtysold minus any discount.'],
['8.', 'customername / customermobile are optional. Give a mobile number and the sale is'],
['', '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 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. 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 the operator's columns, left empty: pre-filling
// qtysold with 0 invites a file of accidental zero-quantity rows.
'',
p.price > 0 ? p.price : '',
'',
p.taxpercent > 0 ? p.taxpercent : '',
'',
'',
'',
'',
'',
'',
]);
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 } }) };
const info = XLSX.utils.aoa_to_sheet([
['Field', 'Value'],
['tenantid', template.tenantid],
['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.'],
['Each sale is routed by the locationid on its own row in the Sales sheet.'],
]);
info['!cols'] = [{ wch: 34 }, { wch: 42 }];
const help = XLSX.utils.aoa_to_sheet(INSTRUCTIONS);
help['!cols'] = [{ wch: 4 }, { wch: 92 }];
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, sales, SALES_SHEET);
XLSX.utils.book_append_sheet(wb, info, INFO_SHEET);
XLSX.utils.book_append_sheet(wb, help, HELP_SHEET);
return wb;
}
/** 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 =
(single || 'all-stores')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '') || 'all-stores';
return `offline-sales-${slug}-${new Date().toISOString().slice(0, 10)}.xlsx`;
}
export function downloadSaleTemplate(template: SaleTemplate): void {
XLSX.writeFile(buildSaleTemplateWorkbook(template), saleTemplateFilename(template));
}
// ── Parsing ───────────────────────────────────────────────────────────────────
/** One spreadsheet row after parsing, carrying its own problems. */
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;
qtysold: number;
unitprice: number | null;
discountamount: number;
taxpercent: number | null;
billno: string;
saledate: string;
paymentmode: string;
customername: string;
customermobile: string;
remarks: string;
errors: string[];
warnings: string[];
}
export interface ParsedSheet {
/** Tenant read from the Store Info sheet, when the file still has it. */
tenantid: number | null;
/** Rows with a quantity — blank ones are dropped, not reported. */
rows: ParsedSaleRow[];
/** 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[];
}
/** Excel hands back numbers, strings, or a Date depending on the cell format. */
function toNum(v: unknown): number | null {
if (v === null || v === undefined || v === '') return null;
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
const n = Number(String(v).trim().replace(/,/g, ''));
return Number.isFinite(n) ? n : null;
}
function toStr(v: unknown): string {
if (v === null || v === undefined) return '';
return String(v).trim();
}
/**
* A date cell can arrive as a real Date (Excel date-formatted), a serial number
* (raw numeric cell), or text. Normalise all three to YYYY-MM-DD so the backend
* sees one format regardless of how the operator's Excel was configured.
*/
function toDateString(v: unknown): string {
if (v === null || v === undefined || v === '') return '';
if (v instanceof Date) return v.toISOString().slice(0, 10);
if (typeof v === 'number') {
const parsed = XLSX.SSF?.parse_date_code?.(v);
if (parsed && parsed.y) {
const mm = String(parsed.m).padStart(2, '0');
const dd = String(parsed.d).padStart(2, '0');
return `${parsed.y}-${mm}-${dd}`;
}
return '';
}
return String(v).trim();
}
/** Match a header cell back to a known column, tolerating the "(do not edit)"
* and "*" suffixes and any case/spacing the operator's Excel introduced. */
function normaliseHeader(raw: unknown): string {
return toStr(raw)
.toLowerCase()
.replace(/\(.*?\)/g, '')
.replace(/[^a-z]/g, '');
}
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, scope: ParseScope): ParsedSheet {
const out: ParsedSheet = { tenantid: null, rows: [], skipped: 0, fatal: [] };
let wb: XLSX.WorkBook;
try {
wb = XLSX.read(data, { cellDates: true });
} catch {
out.fatal.push('That file could not be read as a spreadsheet. Upload the .xlsx template.');
return out;
}
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) {
if (toStr(r?.[0]).toLowerCase() === 'tenantid') out.tenantid = toNum(r?.[1]);
}
}
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]];
if (!sheet) {
out.fatal.push('The workbook has no sheets.');
return out;
}
const grid = XLSX.utils.sheet_to_json<unknown[]>(sheet, { header: 1, blankrows: false, defval: '' });
if (grid.length < 2) {
out.fatal.push('The Sales sheet has no rows to import.');
return out;
}
const index: Record<string, number> = {};
grid[0].forEach((cell, i) => {
const key = normaliseHeader(cell);
if (key && index[key] === undefined) index[key] = i;
});
if (index.productid === undefined || index.qtysold === undefined) {
out.fatal.push(
'The Sales sheet is missing the "productid" or "qtysold" column. Upload the downloaded template without renaming its columns.',
);
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];
return i === undefined ? '' : row[i];
};
for (let i = 1; i < grid.length; i++) {
const raw = grid[i];
const excelRow = i + 1;
const qty = toNum(cell(raw, 'qtysold'));
// Nothing sold on this line. Not an error — a template lists the whole
// 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,
qtysold: qty,
unitprice,
discountamount: discount,
taxpercent,
billno: toStr(cell(raw, 'billno')),
saledate: toDateString(cell(raw, 'saledate')),
paymentmode,
customername: toStr(cell(raw, 'customername')),
customermobile: toStr(cell(raw, 'customermobile')),
remarks: toStr(cell(raw, 'remarks')),
errors: [],
warnings: [],
};
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');
}
// Checked here as well as server-side so the operator sees it against the
// row instead of getting a rejected bill back.
if (stock !== null && qty > stock) {
row.errors.push(`only ${stock} in stock, ${qty} sold`);
}
if (discount < 0) {
row.errors.push('discountamount cannot be negative');
}
if (unitprice !== null && unitprice > 0 && discount > unitprice * qty) {
row.errors.push('discount is larger than the line total');
}
if (paymentmode && !PAYMENT_MODES.has(paymentmode.toLowerCase())) {
row.errors.push(`paymentmode "${paymentmode}" is not Cash, Card or UPI`);
}
// Warnings do not block the upload. A zero price is legitimate (a free
// sample) but is nearly always a forgotten cell, so it is worth saying.
if (unitprice === null || unitprice <= 0) {
row.warnings.push('no price — this sale will record no revenue');
}
if (!Number.isInteger(qty)) {
row.warnings.push('fractional quantity');
}
out.rows.push(row);
}
if (out.rows.length === 0 && out.fatal.length === 0) {
out.fatal.push('No sold quantities found. Fill in the "qtysold" column for at least one product.');
}
return out;
}
/**
* Group parsed rows into bills for the API.
*
* 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.locationid}::${r.billno.trim().toUpperCase() || '__nobill__'}`;
const bucket = groups.get(key);
if (bucket) bucket.push(r);
else groups.set(key, [r]);
}
return Array.from(groups.values()).map((group) => {
// Bill-level fields belong to the bill, not the line. Taking the first
// non-empty value means the operator only has to fill them in on the first
// row of a bill, which is how people actually fill these in.
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),
customername: first((r) => r.customername),
customermobile: first((r) => r.customermobile),
remarks: first((r) => r.remarks),
items: group.map((r) => ({
productid: r.productid,
productname: r.productname || undefined,
qtysold: r.qtysold,
unitprice: r.unitprice ?? undefined,
discountamount: r.discountamount || undefined,
taxpercent: r.taxpercent ?? undefined,
})),
};
});
}
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 += lineAmount;
if (r.errors.length) errors++;
if (r.warnings.length) warnings++;
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,
stores: stores.size,
byStore: Array.from(stores.values()).sort((a, b) => b.amount - a.amount),
};
}