offline sales upload
This commit is contained in:
470
src/services/offlineSalesSheet.ts
Normal file
470
src/services/offlineSalesSheet.ts
Normal file
@@ -0,0 +1,470 @@
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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 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.
|
||||
*/
|
||||
|
||||
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 = 1;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
const COLUMNS = [
|
||||
'productid',
|
||||
'productname',
|
||||
'currentstock',
|
||||
'qtysold',
|
||||
'unitprice',
|
||||
'discountamount',
|
||||
'taxpercent',
|
||||
'billno',
|
||||
'saledate',
|
||||
'paymentmode',
|
||||
'customername',
|
||||
'customermobile',
|
||||
'remarks',
|
||||
] as const;
|
||||
|
||||
const COLUMN_WIDTHS = [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> = {
|
||||
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'],
|
||||
[''],
|
||||
['1.', 'Fill in the "qtysold" column on the Sales sheet for whatever you 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.'],
|
||||
['', '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.'],
|
||||
['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, 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.
|
||||
*/
|
||||
export function buildSaleTemplateWorkbook(template: SaleTemplate): XLSX.WorkBook {
|
||||
const header = COLUMNS.map((c) => HEADER_LABELS[c] ?? c);
|
||||
|
||||
const body = template.products.map((p) => [
|
||||
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.
|
||||
'',
|
||||
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' };
|
||||
|
||||
// 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],
|
||||
[''],
|
||||
['Do not edit this sheet.'],
|
||||
['These values tell the system which store the sales belong to.'],
|
||||
]);
|
||||
info['!cols'] = [{ wch: 18 }, { 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;
|
||||
}
|
||||
|
||||
/** `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. */
|
||||
export function saleTemplateFilename(template: SaleTemplate): string {
|
||||
const slug =
|
||||
template.locationname
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '') || `location-${template.locationid}`;
|
||||
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;
|
||||
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 {
|
||||
/** Outlet 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. */
|
||||
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']);
|
||||
|
||||
/**
|
||||
* 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: [] };
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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 (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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 and most rows are expected to be empty.
|
||||
if (qty === null || qty === 0) {
|
||||
out.skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const productid = toNum(cell(raw, 'productid'));
|
||||
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'));
|
||||
|
||||
const row: ParsedSaleRow = {
|
||||
excelRow,
|
||||
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 (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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
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 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 {
|
||||
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,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 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[]): {
|
||||
lines: number;
|
||||
units: number;
|
||||
amount: number;
|
||||
errors: number;
|
||||
warnings: number;
|
||||
bills: number;
|
||||
} {
|
||||
let units = 0;
|
||||
let amount = 0;
|
||||
let errors = 0;
|
||||
let warnings = 0;
|
||||
const bills = new Set<string>();
|
||||
|
||||
for (const r of rows) {
|
||||
units += r.qtysold;
|
||||
amount += Math.max(0, (r.unitprice ?? 0) * r.qtysold - r.discountamount);
|
||||
if (r.errors.length) errors++;
|
||||
if (r.warnings.length) warnings++;
|
||||
bills.add(r.billno.trim().toUpperCase() || '__nobill__');
|
||||
}
|
||||
|
||||
return { lines: rows.length, units, amount, errors, warnings, bills: bills.size };
|
||||
}
|
||||
Reference in New Issue
Block a user