From 9990355e1900df82ba4c4e2ac04d406fd3e2de4e Mon Sep 17 00:00:00 2001 From: abhishek Date: Thu, 30 Jul 2026 17:23:21 +0530 Subject: [PATCH] offline sales upload --- package-lock.json | 106 ++++- package.json | 3 +- src/components/InventoryView.tsx | 21 + src/components/OfflineSalesUpload.tsx | 576 ++++++++++++++++++++++++++ src/components/StoreCatalogView.tsx | 31 +- src/services/fiestaApi.ts | 112 +++++ src/services/offlineSalesSheet.ts | 470 +++++++++++++++++++++ 7 files changed, 1316 insertions(+), 3 deletions(-) create mode 100644 src/components/OfflineSalesUpload.tsx create mode 100644 src/services/offlineSalesSheet.ts diff --git a/package-lock.json b/package-lock.json index 8d83182..82ed0bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,8 @@ "react-leaflet": "^5.0.0", "react-router-dom": "^7.2.0", "recharts": "^3.8.1", - "vite": "^6.2.3" + "vite": "^6.2.3", + "xlsx": "^0.18.5" }, "devDependencies": { "@types/express": "^4.17.21", @@ -1810,6 +1811,15 @@ "node": ">= 0.6" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -2040,6 +2050,19 @@ ], "license": "CC-BY-4.0" }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/claude": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/claude/-/claude-0.1.1.tgz", @@ -2055,6 +2078,15 @@ "node": ">=6" } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -2097,6 +2129,18 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/d3-array": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", @@ -2619,6 +2663,15 @@ "node": ">= 0.6" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -4074,6 +4127,18 @@ "node": ">=0.10.0" } }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -4821,6 +4886,24 @@ "node": ">= 8" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/ws": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", @@ -4842,6 +4925,27 @@ } } }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index 0b459d3..2852268 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "react-leaflet": "^5.0.0", "react-router-dom": "^7.2.0", "recharts": "^3.8.1", - "vite": "^6.2.3" + "vite": "^6.2.3", + "xlsx": "^0.18.5" }, "devDependencies": { "@types/express": "^4.17.21", diff --git a/src/components/InventoryView.tsx b/src/components/InventoryView.tsx index 60ad50e..ca8865a 100644 --- a/src/components/InventoryView.tsx +++ b/src/components/InventoryView.tsx @@ -55,6 +55,7 @@ import AwaitingApi from './AwaitingApi'; import { SlideDrawer, Skeleton, TH_STYLE, SURFACE_ALT, TEXT, TEXT_2, TEXT_3, BORDER, BRAND, tint, edge, StatusChip } from './consoleUi'; import FMCGHoverOverlay from './FMCGHoverOverlay'; import CatalogueBrowser from './CatalogueBrowser'; +import OfflineSalesUpload from './OfflineSalesUpload'; import { useCompare } from '../contexts/CompareContext'; @@ -79,6 +80,7 @@ export default function InventoryView({ const { setHideCompareBar } = useCompare(); const [searchTerm, setSearchTerm] = useState(''); const [showCatalogueModal, setShowCatalogueModal] = useState(false); + const [showOfflineSales, setShowOfflineSales] = useState(false); const navigate = useNavigate(); // ── Live stock across every outlet (Fiesta) ─────────────────────────────── @@ -528,6 +530,13 @@ export default function InventoryView({ {storeRequests.length} )} + + + +
+ {result ? ( + + ) : ( + <> + {/* Which branch. Only rendered for a user who has more than one — + a store user has no choice to make and showing them a picker + would imply they do. Changing it clears any loaded file, since + a file's productids belong to the outlet it was generated for. */} + {picker && ( +
+ + +

+ Stock is deducted from this store, and the template below is built from its catalogue. +

+
+ )} + + {/* Step 1 — the template. Presented first and prominently because + uploading anything else will not work. */} +
+
+
+

+ + 1 + + Download the template for this store +

+

+ {templateQuery.isLoading + ? 'Loading this outlet’s catalogue…' + : templateQuery.isError + ? 'Could not load this outlet’s catalogue.' + : `${templateQuery.data?.products.length ?? 0} products stocked here. Fill in the qtysold column and upload the file back.`} +

+
+ +
+ {templateQuery.isError && ( +

+ {(templateQuery.error as Error).message} +

+ )} +
+ + {/* Step 2 — the file. */} +
+

+ + 2 + + Upload the filled-in file +

+ +
{ + e.preventDefault(); + setDragging(true); + }} + onDragLeave={() => setDragging(false)} + onDrop={(e) => { + e.preventDefault(); + setDragging(false); + const file = e.dataTransfer.files?.[0]; + if (file) void loadFile(file); + }} + onClick={() => fileInputRef.current?.click()} + className={`cursor-pointer rounded-lg border-2 border-dashed p-8 text-center transition-colors ${ + dragging + ? 'border-[#662582] bg-purple-50' + : 'border-slate-300 bg-white hover:border-slate-400 hover:bg-slate-50' + }`} + > + +

+ {fileName || 'Drop the .xlsx file here, or click to choose'} +

+

Only the template downloaded above will import correctly.

+ { + const file = e.target.files?.[0]; + if (file) void loadFile(file); + }} + /> +
+
+ + {/* Step 3 — the preview. Every problem is shown at once, against + the operator's own row numbers, so the file can be fixed in one + pass rather than one rejection at a time. */} + {parsed && ( +
+

+ + 3 + + Check and confirm +

+ + {parsed.fatal.length > 0 && ( +
+ {parsed.fatal.map((f, i) => ( +

+ + {f} +

+ ))} +
+ )} + + {summary && parsed.rows.length > 0 && ( + <> +
+ + + + + 0 ? 'bad' : 'good'} + /> +
+ + {summary.errors > 0 && ( +

+ + {summary.errors} row{summary.errors === 1 ? '' : 's'} must be fixed in the file before this can be + uploaded. +

+ )} + {summary.errors === 0 && summary.warnings > 0 && ( +

+ + {summary.warnings} row{summary.warnings === 1 ? '' : 's'} have warnings. You can still upload. +

+ )} + {parsed.skipped > 0 && ( +

+ {parsed.skipped} row{parsed.skipped === 1 ? '' : 's'} had no quantity and were ignored. +

+ )} + +
+ + + + + + + + + + + + + + {parsed.rows.map((r) => { + const bad = r.errors.length > 0; + const warn = !bad && r.warnings.length > 0; + return ( + + + + + + + + + + ); + })} + +
RowProductQtyPriceAmountBillStatus
{r.excelRow} + {r.productname || '—'} + #{r.productid} + {r.qtysold} + {r.unitprice ? money(r.unitprice) : } + + {money(Math.max(0, (r.unitprice ?? 0) * r.qtysold - r.discountamount))} + {r.billno || '—'} + {bad ? ( + {r.errors.join('; ')} + ) : warn ? ( + {r.warnings.join('; ')} + ) : ( + + ready + + )} +
+
+ + )} + + {uploadError && ( +

+ {uploadError} +

+ )} +
+ )} + + )} +
+ + {!result && ( +
+

+ Imported sales reduce stock and appear in Orders marked OFFLINE. + Re-uploading the same file will not deduct twice. +

+
+ {parsed && ( + + )} + +
+
+ )} + + + ); +} + +function Stat({ label, value, tone }: { label: string; value: string; tone?: 'good' | 'bad' }) { + const valueTone = tone === 'bad' ? 'text-red-700' : tone === 'good' ? 'text-emerald-700' : 'text-slate-900'; + return ( +
+

{label}

+

{value}

+
+ ); +} + +/** + * Per-bill outcome. Duplicates are reported as their own neutral category + * rather than as failures: a re-upload being refused is the safeguard working, + * and calling it an error would push people towards "fixing" it. + */ +function ResultPanel({ + result, + onAnother, + onClose, +}: { + result: OfflineSalesUploadResponse; + onAnother: () => void; + onClose: () => void; +}) { + return ( +
+
+ {result.imported > 0 ? ( + + ) : ( + + )} +
+

+ {result.imported > 0 + ? `${result.imported} bill${result.imported === 1 ? '' : 's'} imported` + : 'Nothing was imported'} +

+

+ {result.imported > 0 && <>Stock has been reduced and {money(result.totalamount)} recorded as revenue. } + {result.duplicate > 0 && ( + <> + {result.duplicate} bill{result.duplicate === 1 ? ' was' : 's were'} already imported and{' '} + {result.duplicate === 1 ? 'was' : 'were'} skipped.{' '} + + )} + {result.failed > 0 && ( + <> + {result.failed} bill{result.failed === 1 ? '' : 's'} could not be imported — see below. + + )} +

+
+
+ +
+ + + + + + + + + + + + + {result.results.map((r, i) => ( + + + + + + + + + ))} + +
BillResultOrderItemsAmountDetail
{r.billno || '—'} + {r.status === 'imported' && ( + imported + )} + {r.status === 'duplicate' && ( + already done + )} + {r.status === 'failed' && ( + failed + )} + {r.orderid || '—'}{r.itemcount || '—'}{r.amount ? money(r.amount) : '—'}{r.message}
+
+ +
+ + +
+
+ ); +} diff --git a/src/components/StoreCatalogView.tsx b/src/components/StoreCatalogView.tsx index d583d88..0f3e741 100644 --- a/src/components/StoreCatalogView.tsx +++ b/src/components/StoreCatalogView.tsx @@ -23,13 +23,14 @@ */ import React, { useEffect, useMemo, useState } from 'react'; -import { Search, Boxes, Layers, Plus, Minus, Check, CheckCircle2, X, Store, PackageSearch, Activity, Info, Inbox } from 'lucide-react'; +import { Search, Boxes, Layers, Plus, Minus, Check, CheckCircle2, X, Store, PackageSearch, Activity, Info, Inbox, UploadCloud } from 'lucide-react'; import { useFiestaStockStatement, useFiestaCreateStockRequest, useFiestaGetStockRequests, useFiestaUpdateStockRequest, useFiestaCreateProductLocation, FIESTA_TENANT_ID } from '../services/fiestaQueries'; import { num as fnum, str as fstr, type Row } from '../services/fiestaApi'; import { useStoreCatalogue } from '../services/storeCatalogue'; import AwaitingApi from './AwaitingApi'; import { SlideDrawer, StatusChip, TH_STYLE, SURFACE_ALT, TEXT, TEXT_2, TEXT_3, BORDER, BRAND } from './consoleUi'; import FMCGHoverOverlay from './FMCGHoverOverlay'; +import OfflineSalesUpload from './OfflineSalesUpload'; const PLACEHOLDER = 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&q=80&w=200'; @@ -88,6 +89,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store', const [activeQtyProduct, setActiveQtyProduct] = useState(null); const [tempQty, setTempQty] = useState(1); const [notice, setNotice] = useState(false); + const [showOfflineSales, setShowOfflineSales] = useState(false); // Every read and write on this page is scoped to the tenant + outlet the // logged-in user is linked to. There is no safe default here: falling back to @@ -358,6 +360,23 @@ export default function StoreCatalogView({ locationid, storeName = 'your store', + {/* Counter sales. Deliberately outside the tab group and always visible: + it is an action, not another way of looking at the catalogue, and the + store needs it from wherever they are on this page. No outlet picker + is passed — a store user can only ever import against their own. */} + + {view === 'requests' && (
Date: @@ -1006,6 +1025,16 @@ export default function StoreCatalogView({ locationid, storeName = 'your store', })()} + {/* Counter-sales import, locked to this user's own outlet. */} + {showOfflineSales && storeLocationId > 0 && ( + setShowOfflineSales(false)} + /> + )} +
); } 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 }; +}