);
}
diff --git a/src/services/fiestaApi.ts b/src/services/fiestaApi.ts
index 59b2565..723093f 100644
--- a/src/services/fiestaApi.ts
+++ b/src/services/fiestaApi.ts
@@ -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 {
+ 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 {
+ 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
// ════════════════════════════════════════════════════════════════════════════
diff --git a/src/services/offlineSalesSheet.ts b/src/services/offlineSalesSheet.ts
new file mode 100644
index 0000000..2705f85
--- /dev/null
+++ b/src/services/offlineSalesSheet.ts
@@ -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 = {
+ 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(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(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 = {};
+ 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();
+
+ 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();
+
+ 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 };
+}