offline sales upload

This commit is contained in:
2026-07-30 17:23:21 +05:30
parent eb6e750b6a
commit 9990355e19
7 changed files with 1316 additions and 3 deletions

View File

@@ -1287,6 +1287,118 @@ export async function getSalesSummary(opts: {
return res.details;
}
// ════════════════════════════════════════════════════════════════════════════
// OFFLINE (IN-STORE) SALES
// ════════════════════════════════════════════════════════════════════════════
export interface SaleTemplateRow {
productid: number;
productname: string;
productunit: string;
unitvalue: string;
categoryname: string;
currentstock: number;
price: number;
taxpercent: number;
}
export interface SaleTemplate {
tenantid: number;
locationid: number;
locationname: string;
products: SaleTemplateRow[];
}
/**
* GET /products/getsaletemplate — every product stocked at one outlet, with its
* live ledger balance and price.
*
* 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.
*/
export async function getSaleTemplate(opts: {
tenantid: number;
locationid: number;
}): Promise<SaleTemplate> {
const res = await fiestaGet<{ details: SaleTemplate | null }>('products/getsaletemplate', {
tenantid: opts.tenantid,
locationid: opts.locationid,
});
if (!res?.details) throw new Error('No products are stocked at this outlet yet.');
return res.details;
}
export interface OfflineSaleItemInput {
productid: number;
productname?: string;
qtysold: number;
unitprice?: number;
discountamount?: number;
taxpercent?: number;
}
export interface OfflineSaleBillInput {
billno?: string;
saledate?: string;
paymentmode?: string;
customername?: string;
customermobile?: string;
remarks?: string;
items: OfflineSaleItemInput[];
}
export interface OfflineSaleResult {
billno: string;
status: 'imported' | 'duplicate' | 'failed';
orderid: string;
orderheaderid: number;
itemcount: number;
amount: number;
message: string;
}
export interface OfflineSalesUploadResponse {
imported: number;
duplicate: number;
failed: number;
totalamount: number;
results: OfflineSaleResult[];
}
/**
* POST /orders/uploadofflinesales — import counter sales as real orders.
*
* Each bill is committed independently, so the response reports a per-bill
* outcome and a partially-good spreadsheet still imports its good bills. A
* thrown error therefore means nothing at all was attempted (bad outlet, empty
* batch); individual rejections come back inside `results`.
*
* 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.
*/
export async function uploadOfflineSales(input: {
tenantid: number;
locationid: number;
userid?: number;
bills: OfflineSaleBillInput[];
}): Promise<OfflineSalesUploadResponse> {
const res = await fiestaSend<{ details: OfflineSalesUploadResponse }>(
'orders/uploadofflinesales',
'POST',
{
tenantid: input.tenantid,
locationid: input.locationid,
userid: input.userid ?? 0,
bills: input.bills,
},
);
return res.details;
}
// ════════════════════════════════════════════════════════════════════════════
// GLOBAL CATALOGUE
// ════════════════════════════════════════════════════════════════════════════

View 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 };
}