Carry the branch on every offline-sales row instead of picking a store

The upload modal made the admin choose a branch, then generated and
validated the workbook against that choice. A merchant with several
outlets had to repeat the whole cycle per branch, and the picker
defaulted to the first outlet, so an admin who never opened it credited
the wrong store — the file and the selection agreed, so nothing flagged
it.

The sheet now carries tenantid, locationid and the store name as locked
columns on every row, and the row's own locationid routes its sale. One
file covers the whole business: fill in qtysold wherever something sold,
across as many branches as needed, and upload once. The picker is gone
from the admin surface entirely, and the store user's page keeps passing
its locationId, which pins the upload to that branch and rejects rows for
any other before they are even sent.

Bills are keyed on branch first and bill number second. Counter books at
different outlets restart numbering from 1, so a shared number is two
sales rather than a duplicate, and keying on the number alone would have
dropped the second one.

Because a single upload can now move stock at six outlets, one total is
no longer enough to check before committing: the preview gains a store
count, a per-store table of lines, units, amount and problems, and a
Store column on every row, and results name the branch on each bill.

Rows are ordered store then product with an Excel autofilter, so a
branch can isolate its own rows in a file spanning the business.

Verified end to end against a two-branch tenant sharing a product id
across both outlets: one upload deducted each branch independently, a
pinned upload refused the other branch's rows, and re-uploading deducted
nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 12:18:09 +05:30
parent 9990355e19
commit cc08f2f6c5
4 changed files with 335 additions and 170 deletions

View File

@@ -1107,14 +1107,14 @@ export default function InventoryView({
})()} })()}
</SlideDrawer> </SlideDrawer>
{/* Offline (counter) sales import. `locations` is passed so an admin picks {/* Offline (counter) sales import. No locationId is passed: the admin gets
the branch the bills belong to — this view otherwise operates on the one workbook covering every branch, and each row's own locationid
tenant's first outlet, which would silently credit the wrong store. */} routes its sale to the right store. This view operates on the tenant's
first outlet elsewhere, which would have been the wrong store to
credit for most of these sales. */}
{showOfflineSales && ( {showOfflineSales && (
<OfflineSalesUpload <OfflineSalesUpload
tenantId={tenantId} tenantId={tenantId}
locationId={primaryLocationId}
locations={locations.map(({ locationid, locationname }) => ({ locationid, locationname }))}
onClose={() => setShowOfflineSales(false)} onClose={() => setShowOfflineSales(false)}
/> />
)} )}

View File

@@ -7,18 +7,24 @@
* Offline (counter) sales import. * Offline (counter) sales import.
* *
* A sale rung up at the till never passes through the app, so nothing deducts * A sale rung up at the till never passes through the app, so nothing deducts
* its stock. This is the way that stock gets deducted: download a spreadsheet * its stock. This is how that stock gets deducted: download a spreadsheet
* pre-filled with the outlet's catalogue, type sold quantities into it, upload * pre-filled with the catalogue, type sold quantities into it, upload it back.
* it back. Imported sales become real orders, so they reduce stock through the * Imported sales become real orders, so they reduce stock through the same path
* same path an app order uses and show up in revenue reporting. * an app order uses and show up in revenue reporting.
*
* ONE file covers EVERY branch. There is no store picker: each row of the sheet
* carries its own tenantid and locationid, and that row's locationid decides
* which branch the sale comes out of. A merchant with six outlets fills in rows
* for all six and uploads once. Nothing here has to know or choose a store.
* *
* The template must be downloaded rather than hand-written because `productid` * The template must be downloaded rather than hand-written because `productid`
* is the only usable key for a product — SKUs are not unique in this catalogue * is the only usable key for a product — SKUs are not unique in this catalogue
* (6,245 products share 93 sku values) and names are not unique either. The * (6,245 products share 93 sku values) and names are not unique either. The
* download fills productid in so nobody has to know it. * download fills productid and locationid in so nobody has to know them.
* *
* Used by both surfaces. The admin console passes the outlet it has selected; * Used by both surfaces. The admin console passes no locationId, so the file
* the store user's page passes their own, which is the only one they can reach. * routes itself. The store user's page passes theirs, which pins the upload to
* their branch and rejects rows for any other — enforced again server-side.
*/ */
import { useCallback, useMemo, useRef, useState } from 'react'; import { useCallback, useMemo, useRef, useState } from 'react';
@@ -30,6 +36,7 @@ import {
FileSpreadsheet, FileSpreadsheet,
Loader2, Loader2,
RotateCcw, RotateCcw,
Store,
Upload, Upload,
X, X,
XCircle, XCircle,
@@ -49,19 +56,15 @@ import {
interface OfflineSalesUploadProps { interface OfflineSalesUploadProps {
tenantId: number; tenantId: number;
/** The outlet to credit. For a store user this is their own and is the only /**
* one reachable; for an admin it is the initial selection in `locations`. */ * Pins the upload to a single branch. Passed by the store user's page so they
locationId: number; * can only ever import for their own store. Omitted by the admin console, so
/** Shown in the header so it is unambiguous which store is being credited. */ * the workbook spans every branch and each row routes itself.
*/
locationId?: number;
/** Shown in the header when the upload is pinned to one store. */
storeName?: string; storeName?: string;
userId?: number; userId?: number;
/**
* Outlets the user may choose between. Passed by the admin console, whose
* users manage several branches and must say which one a bill belongs to.
* Omitted for a store user, which locks the import to their own outlet — the
* backend enforces the same thing regardless of what the file says.
*/
locations?: { locationid: number; locationname: string }[];
onClose: () => void; onClose: () => void;
} }
@@ -73,28 +76,29 @@ export default function OfflineSalesUpload({
locationId, locationId,
storeName, storeName,
userId, userId,
locations,
onClose, onClose,
}: OfflineSalesUploadProps) { }: OfflineSalesUploadProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [activeLocationId, setActiveLocationId] = useState(locationId);
const [parsed, setParsed] = useState<ParsedSheet | null>(null); const [parsed, setParsed] = useState<ParsedSheet | null>(null);
const [fileName, setFileName] = useState(''); const [fileName, setFileName] = useState('');
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
const [result, setResult] = useState<OfflineSalesUploadResponse | null>(null); const [result, setResult] = useState<OfflineSalesUploadResponse | null>(null);
const [uploadError, setUploadError] = useState(''); const [uploadError, setUploadError] = useState('');
const pinned = (locationId ?? 0) > 0;
const templateQuery = useQuery({ const templateQuery = useQuery({
queryKey: ['saleTemplate', tenantId, activeLocationId], queryKey: ['saleTemplate', tenantId, locationId ?? 0],
queryFn: () => getSaleTemplate({ tenantid: tenantId, locationid: activeLocationId }), queryFn: () => getSaleTemplate({ tenantid: tenantId, locationid: locationId ?? 0 }),
enabled: tenantId > 0 && activeLocationId > 0, enabled: tenantId > 0,
// Always refetched on open: a template is only useful if its stock figures // Always refetched on open: a template is only useful if its stock figures
// and product list match the outlet right now. // and product list match the outlets right now.
staleTime: 0, staleTime: 0,
}); });
const template = templateQuery.data;
const summary = useMemo(() => (parsed ? summarise(parsed.rows) : null), [parsed]); const summary = useMemo(() => (parsed ? summarise(parsed.rows) : null), [parsed]);
const blocked = Boolean(parsed && (parsed.fatal.length > 0 || (summary?.errors ?? 0) > 0)); const blocked = Boolean(parsed && (parsed.fatal.length > 0 || (summary?.errors ?? 0) > 0));
@@ -103,7 +107,9 @@ export default function OfflineSalesUpload({
if (!parsed) throw new Error('No file loaded.'); if (!parsed) throw new Error('No file loaded.');
return uploadOfflineSales({ return uploadOfflineSales({
tenantid: tenantId, tenantid: tenantId,
locationid: activeLocationId, // Sent only when pinned. Left at 0, the backend routes each bill by the
// locationid the sheet gave it.
locationid: locationId ?? 0,
userid: userId, userid: userId,
bills: toBills(parsed.rows), bills: toBills(parsed.rows),
}); });
@@ -111,9 +117,10 @@ export default function OfflineSalesUpload({
onSuccess: (res) => { onSuccess: (res) => {
setResult(res); setResult(res);
setUploadError(''); setUploadError('');
// Stock has moved, so every view reading it is now stale. Invalidating // Stock has moved at potentially several branches, so every view reading
// broadly is deliberate — a partially-refreshed inventory screen after an // it is now stale. Invalidating broadly is deliberate — a partially
// import is worse than a few extra refetches. // refreshed inventory screen after an import is worse than a few extra
// refetches.
queryClient.invalidateQueries(); queryClient.invalidateQueries();
}, },
onError: (err: Error) => setUploadError(err.message), onError: (err: Error) => setUploadError(err.message),
@@ -126,19 +133,23 @@ export default function OfflineSalesUpload({
setFileName(file.name); setFileName(file.name);
try { try {
const buffer = await file.arrayBuffer(); const buffer = await file.arrayBuffer();
setParsed(parseSalesWorkbook(buffer, { tenantid: tenantId, locationid: activeLocationId })); setParsed(
parseSalesWorkbook(buffer, {
tenantid: tenantId,
locationid: locationId,
allowedLocationIds: template?.locations.map((l) => l.locationid),
}),
);
} catch { } catch {
setParsed({ setParsed({
tenantid: null, tenantid: null,
locationid: null,
locationname: '',
rows: [], rows: [],
skipped: 0, skipped: 0,
fatal: ['That file could not be read. Upload the .xlsx template you downloaded.'], fatal: ['That file could not be read. Upload the .xlsx template you downloaded.'],
}); });
} }
}, },
[tenantId, activeLocationId], [tenantId, locationId, template],
); );
const reset = () => { const reset = () => {
@@ -149,12 +160,12 @@ export default function OfflineSalesUpload({
if (fileInputRef.current) fileInputRef.current.value = ''; if (fileInputRef.current) fileInputRef.current.value = '';
}; };
const picker = locations && locations.length > 1 ? locations : null; const storeCount = template?.locations.length ?? 0;
const outletLabel = const scopeLabel = pinned
picker?.find((l) => l.locationid === activeLocationId)?.locationname || ? storeName || template?.locations[0]?.locationname || `Outlet ${locationId}`
storeName || : storeCount === 1
templateQuery.data?.locationname || ? template?.locations[0]?.locationname || 'your store'
`Outlet ${activeLocationId}`; : `all ${storeCount} stores`;
return ( return (
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4"> <div className="fixed inset-0 z-[200] flex items-center justify-center p-4">
@@ -167,7 +178,7 @@ export default function OfflineSalesUpload({
<div> <div>
<h2 className="text-lg font-bold tracking-tight text-white">Offline Sales Upload</h2> <h2 className="text-lg font-bold tracking-tight text-white">Offline Sales Upload</h2>
<p className="text-xs text-white/70"> <p className="text-xs text-white/70">
Counter sales for <span className="font-semibold text-white/90">{outletLabel}</span> Counter sales for <span className="font-semibold text-white/90">{scopeLabel}</span>
</p> </p>
</div> </div>
</div> </div>
@@ -185,36 +196,6 @@ export default function OfflineSalesUpload({
<ResultPanel result={result} onAnother={reset} onClose={onClose} /> <ResultPanel result={result} onAnother={reset} onClose={onClose} />
) : ( ) : (
<> <>
{/* 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 && (
<section className="mb-6 rounded-lg border border-amber-200 bg-amber-50 p-4">
<label className="block text-xs font-bold text-slate-800" htmlFor="offline-outlet">
Which store are these sales from?
</label>
<select
id="offline-outlet"
value={activeLocationId}
onChange={(e) => {
setActiveLocationId(Number(e.target.value));
reset();
}}
className="mt-2 w-full max-w-md rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-800 focus:border-[#662582] focus:outline-none"
>
{picker.map((l) => (
<option key={l.locationid} value={l.locationid}>
{l.locationname}
</option>
))}
</select>
<p className="mt-2 text-[11px] text-slate-600">
Stock is deducted from this store, and the template below is built from its catalogue.
</p>
</section>
)}
{/* Step 1 — the template. Presented first and prominently because {/* Step 1 — the template. Presented first and prominently because
uploading anything else will not work. */} uploading anything else will not work. */}
<section className="mb-6 rounded-lg border border-slate-200 bg-slate-50 p-5"> <section className="mb-6 rounded-lg border border-slate-200 bg-slate-50 p-5">
@@ -224,19 +205,21 @@ export default function OfflineSalesUpload({
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-[#662582] text-[11px] font-bold text-white"> <span className="flex h-5 w-5 items-center justify-center rounded-full bg-[#662582] text-[11px] font-bold text-white">
1 1
</span> </span>
Download the template for this store Download the template
</h3> </h3>
<p className="mt-1.5 text-xs leading-relaxed text-slate-600"> <p className="mt-1.5 text-xs leading-relaxed text-slate-600">
{templateQuery.isLoading {templateQuery.isLoading
? 'Loading this outlets catalogue…' ? 'Loading your catalogue…'
: templateQuery.isError : templateQuery.isError
? 'Could not load this outlets catalogue.' ? 'Could not load your catalogue.'
: `${templateQuery.data?.products.length ?? 0} products stocked here. Fill in the qtysold column and upload the file back.`} : pinned || storeCount <= 1
? `${template?.products.length ?? 0} products stocked. Fill in the qtysold column and upload the file back.`
: `${template?.products.length ?? 0} rows covering all ${storeCount} stores. Every row already says which store it belongs to — fill in qtysold wherever you sold something and upload the one file.`}
</p> </p>
</div> </div>
<button <button
onClick={() => templateQuery.data && downloadSaleTemplate(templateQuery.data)} onClick={() => template && downloadSaleTemplate(template)}
disabled={!templateQuery.data || templateQuery.isLoading} disabled={!template || templateQuery.isLoading}
className="flex items-center gap-2 rounded-lg bg-[#662582] px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-[#551f6d] disabled:cursor-not-allowed disabled:opacity-50" className="flex items-center gap-2 rounded-lg bg-[#662582] px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-[#551f6d] disabled:cursor-not-allowed disabled:opacity-50"
> >
{templateQuery.isLoading ? ( {templateQuery.isLoading ? (
@@ -247,6 +230,25 @@ export default function OfflineSalesUpload({
Download Template Download Template
</button> </button>
</div> </div>
{/* What the one file covers. Shown so it is obvious up front
that no store has to be chosen anywhere. */}
{!pinned && storeCount > 1 && (
<div className="mt-4 flex flex-wrap gap-2 border-t border-slate-200 pt-3">
{template?.locations.map((l) => (
<span
key={l.locationid}
className="flex items-center gap-1.5 rounded-full border border-slate-200 bg-white px-2.5 py-1 text-[11px] font-medium text-slate-700"
>
<Store size={11} className="text-[#662582]" />
{l.locationname}
<span className="text-slate-400">#{l.locationid}</span>
<span className="text-slate-400">· {l.productcount}</span>
</span>
))}
</div>
)}
{templateQuery.isError && ( {templateQuery.isError && (
<p className="mt-3 rounded border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700"> <p className="mt-3 rounded border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
{(templateQuery.error as Error).message} {(templateQuery.error as Error).message}
@@ -325,7 +327,8 @@ export default function OfflineSalesUpload({
{summary && parsed.rows.length > 0 && ( {summary && parsed.rows.length > 0 && (
<> <>
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-5"> <div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-6">
<Stat label="Stores" value={String(summary.stores)} />
<Stat label="Bills" value={String(summary.bills)} /> <Stat label="Bills" value={String(summary.bills)} />
<Stat label="Lines" value={String(summary.lines)} /> <Stat label="Lines" value={String(summary.lines)} />
<Stat label="Units" value={String(summary.units)} /> <Stat label="Units" value={String(summary.units)} />
@@ -337,6 +340,45 @@ export default function OfflineSalesUpload({
/> />
</div> </div>
{/* Per-store totals. With one file covering the whole
business, the single figure above is not enough to
sanity-check what is about to be deducted where. */}
{summary.stores > 1 && (
<div className="mb-4 overflow-hidden rounded-lg border border-slate-200">
<table className="w-full text-left text-xs">
<thead className="bg-slate-100 text-[11px] uppercase tracking-wide text-slate-600">
<tr>
<th className="px-3 py-2 font-semibold">Store</th>
<th className="px-3 py-2 text-right font-semibold">Lines</th>
<th className="px-3 py-2 text-right font-semibold">Units</th>
<th className="px-3 py-2 text-right font-semibold">Amount</th>
<th className="px-3 py-2 text-right font-semibold">Problems</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{summary.byStore.map((s) => (
<tr key={s.locationid} className={s.errors > 0 ? 'bg-red-50' : 'bg-white'}>
<td className="px-3 py-2 font-medium text-slate-800">
{s.locationname || `Outlet ${s.locationid}`}
<span className="ml-1.5 font-mono text-[10px] text-slate-400">#{s.locationid}</span>
</td>
<td className="px-3 py-2 text-right text-slate-600">{s.lines}</td>
<td className="px-3 py-2 text-right text-slate-600">{s.units}</td>
<td className="px-3 py-2 text-right font-semibold text-slate-800">{money(s.amount)}</td>
<td
className={`px-3 py-2 text-right font-semibold ${
s.errors > 0 ? 'text-red-700' : 'text-emerald-700'
}`}
>
{s.errors}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{summary.errors > 0 && ( {summary.errors > 0 && (
<p className="mb-3 flex items-center gap-2 rounded border border-red-200 bg-red-50 px-3 py-2 text-xs font-medium text-red-800"> <p className="mb-3 flex items-center gap-2 rounded border border-red-200 bg-red-50 px-3 py-2 text-xs font-medium text-red-800">
<XCircle size={14} /> <XCircle size={14} />
@@ -357,10 +399,11 @@ export default function OfflineSalesUpload({
)} )}
<div className="max-h-72 overflow-auto rounded-lg border border-slate-200"> <div className="max-h-72 overflow-auto rounded-lg border border-slate-200">
<table className="w-full min-w-[820px] text-left text-xs"> <table className="w-full min-w-[920px] text-left text-xs">
<thead className="sticky top-0 bg-slate-100 text-[11px] uppercase tracking-wide text-slate-600"> <thead className="sticky top-0 bg-slate-100 text-[11px] uppercase tracking-wide text-slate-600">
<tr> <tr>
<th className="px-3 py-2 font-semibold">Row</th> <th className="px-3 py-2 font-semibold">Row</th>
<th className="px-3 py-2 font-semibold">Store</th>
<th className="px-3 py-2 font-semibold">Product</th> <th className="px-3 py-2 font-semibold">Product</th>
<th className="px-3 py-2 text-right font-semibold">Qty</th> <th className="px-3 py-2 text-right font-semibold">Qty</th>
<th className="px-3 py-2 text-right font-semibold">Price</th> <th className="px-3 py-2 text-right font-semibold">Price</th>
@@ -379,6 +422,9 @@ export default function OfflineSalesUpload({
className={bad ? 'bg-red-50' : warn ? 'bg-amber-50' : 'bg-white'} className={bad ? 'bg-red-50' : warn ? 'bg-amber-50' : 'bg-white'}
> >
<td className="px-3 py-2 font-mono text-slate-500">{r.excelRow}</td> <td className="px-3 py-2 font-mono text-slate-500">{r.excelRow}</td>
<td className="px-3 py-2 text-slate-700">
{r.locationname || `#${r.locationid}`}
</td>
<td className="px-3 py-2"> <td className="px-3 py-2">
<span className="font-medium text-slate-800">{r.productname || '—'}</span> <span className="font-medium text-slate-800">{r.productname || '—'}</span>
<span className="ml-1.5 font-mono text-[10px] text-slate-400">#{r.productid}</span> <span className="ml-1.5 font-mono text-[10px] text-slate-400">#{r.productid}</span>
@@ -425,8 +471,8 @@ export default function OfflineSalesUpload({
{!result && ( {!result && (
<div className="flex shrink-0 items-center justify-between gap-3 border-t border-slate-200 bg-slate-50 px-6 py-4"> <div className="flex shrink-0 items-center justify-between gap-3 border-t border-slate-200 bg-slate-50 px-6 py-4">
<p className="text-xs text-slate-500"> <p className="text-xs text-slate-500">
Imported sales reduce stock and appear in Orders marked <span className="font-semibold">OFFLINE</span>. Each sale is deducted from the store named on its own row, and appears in Orders marked{' '}
Re-uploading the same file will not deduct twice. <span className="font-semibold">OFFLINE</span>. Re-uploading the same file will not deduct twice.
</p> </p>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{parsed && ( {parsed && (
@@ -448,7 +494,8 @@ export default function OfflineSalesUpload({
</> </>
) : ( ) : (
<> <>
<Upload size={16} /> Import {summary?.bills ? `${summary.bills} Bill${summary.bills === 1 ? '' : 's'}` : 'Sales'} <Upload size={16} /> Import{' '}
{summary?.bills ? `${summary.bills} Bill${summary.bills === 1 ? '' : 's'}` : 'Sales'}
</> </>
)} )}
</button> </button>
@@ -519,6 +566,7 @@ function ResultPanel({
<table className="w-full text-left text-xs"> <table className="w-full text-left text-xs">
<thead className="bg-slate-100 text-[11px] uppercase tracking-wide text-slate-600"> <thead className="bg-slate-100 text-[11px] uppercase tracking-wide text-slate-600">
<tr> <tr>
<th className="px-3 py-2 font-semibold">Store</th>
<th className="px-3 py-2 font-semibold">Bill</th> <th className="px-3 py-2 font-semibold">Bill</th>
<th className="px-3 py-2 font-semibold">Result</th> <th className="px-3 py-2 font-semibold">Result</th>
<th className="px-3 py-2 font-semibold">Order</th> <th className="px-3 py-2 font-semibold">Order</th>
@@ -530,11 +578,12 @@ function ResultPanel({
<tbody className="divide-y divide-slate-100"> <tbody className="divide-y divide-slate-100">
{result.results.map((r, i) => ( {result.results.map((r, i) => (
<tr <tr
key={`${r.billno}-${i}`} key={`${r.locationid}-${r.billno}-${i}`}
className={ className={
r.status === 'imported' ? 'bg-white' : r.status === 'duplicate' ? 'bg-slate-50' : 'bg-red-50' r.status === 'imported' ? 'bg-white' : r.status === 'duplicate' ? 'bg-slate-50' : 'bg-red-50'
} }
> >
<td className="px-3 py-2 text-slate-700">{r.locationname || (r.locationid ? `#${r.locationid}` : '—')}</td>
<td className="px-3 py-2 font-mono text-slate-700">{r.billno || '—'}</td> <td className="px-3 py-2 font-mono text-slate-700">{r.billno || '—'}</td>
<td className="px-3 py-2"> <td className="px-3 py-2">
{r.status === 'imported' && ( {r.status === 'imported' && (

View File

@@ -1292,6 +1292,9 @@ export async function getSalesSummary(opts: {
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
export interface SaleTemplateRow { export interface SaleTemplateRow {
tenantid: number;
locationid: number;
locationname: string;
productid: number; productid: number;
productname: string; productname: string;
productunit: string; productunit: string;
@@ -1302,33 +1305,45 @@ export interface SaleTemplateRow {
taxpercent: number; taxpercent: number;
} }
export interface SaleTemplate { export interface SaleTemplateLocation {
tenantid: number;
locationid: number; locationid: number;
locationname: string; locationname: string;
productcount: number;
}
export interface SaleTemplate {
tenantid: number;
/** 0 when the template spans every branch of the tenant. */
locationid: number;
locations: SaleTemplateLocation[];
products: SaleTemplateRow[]; products: SaleTemplateRow[];
} }
/** /**
* GET /products/getsaletemplate — every product stocked at one outlet, with its * GET /products/getsaletemplate — products stocked across the tenant's
* live ledger balance and price. * branches, each with its live ledger balance and price.
*
* `locationid` is optional and defaults to every branch, which is the normal
* case: one workbook covers the whole business and each row carries the branch
* its stock belongs to. Pass a locationid to narrow it to a single store.
* *
* This is what the offline-sales spreadsheet is built from, and the reason it * 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 * 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 * 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 * 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 * "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. * unique enough either. Pre-filling productid and locationid removes both
* problems at once.
*/ */
export async function getSaleTemplate(opts: { export async function getSaleTemplate(opts: {
tenantid: number; tenantid: number;
locationid: number; locationid?: number;
}): Promise<SaleTemplate> { }): Promise<SaleTemplate> {
const res = await fiestaGet<{ details: SaleTemplate | null }>('products/getsaletemplate', { const res = await fiestaGet<{ details: SaleTemplate | null }>('products/getsaletemplate', {
tenantid: opts.tenantid, tenantid: opts.tenantid,
locationid: opts.locationid, locationid: opts.locationid ?? 0,
}); });
if (!res?.details) throw new Error('No products are stocked at this outlet yet.'); if (!res?.details) throw new Error('No products are stocked at any of your outlets yet.');
return res.details; return res.details;
} }
@@ -1342,6 +1357,8 @@ export interface OfflineSaleItemInput {
} }
export interface OfflineSaleBillInput { export interface OfflineSaleBillInput {
/** Branch this bill was rung up at, taken from the spreadsheet row. */
locationid: number;
billno?: string; billno?: string;
saledate?: string; saledate?: string;
paymentmode?: string; paymentmode?: string;
@@ -1352,6 +1369,8 @@ export interface OfflineSaleBillInput {
} }
export interface OfflineSaleResult { export interface OfflineSaleResult {
locationid: number;
locationname: string;
billno: string; billno: string;
status: 'imported' | 'duplicate' | 'failed'; status: 'imported' | 'duplicate' | 'failed';
orderid: string; orderid: string;
@@ -1379,10 +1398,16 @@ export interface OfflineSalesUploadResponse {
* *
* Re-uploading the same file is safe: the backend records each bill number and * 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. * refuses one it has already imported rather than deducting the stock twice.
*
* `locationid` is a scope constraint, not the destination. Omit it and each
* bill goes to the branch named on its own rows — the multi-branch case. Set it
* and the upload is pinned to that branch, with any bill naming another one
* refused; that is how a store user is held to their own store regardless of
* what the spreadsheet was edited to say.
*/ */
export async function uploadOfflineSales(input: { export async function uploadOfflineSales(input: {
tenantid: number; tenantid: number;
locationid: number; locationid?: number;
userid?: number; userid?: number;
bills: OfflineSaleBillInput[]; bills: OfflineSaleBillInput[];
}): Promise<OfflineSalesUploadResponse> { }): Promise<OfflineSalesUploadResponse> {
@@ -1391,7 +1416,7 @@ export async function uploadOfflineSales(input: {
'POST', 'POST',
{ {
tenantid: input.tenantid, tenantid: input.tenantid,
locationid: input.locationid, locationid: input.locationid ?? 0,
userid: input.userid ?? 0, userid: input.userid ?? 0,
bills: input.bills, bills: input.bills,
}, },

View File

@@ -4,15 +4,21 @@
*/ */
/** /**
* The spreadsheet half of offline-sales import: turning an outlet's catalogue * The spreadsheet half of offline-sales import: turning a merchant's catalogue
* into a workbook the store fills in, and turning that workbook back into bills * into a workbook the stores fill in, and turning that workbook back into bills
* the API can take. * the API can take.
* *
* Parsing happens here in the browser rather than on the server so the operator * ONE workbook covers EVERY branch. Each row carries its own `tenantid` and
* sees every problem — a bad quantity, a product that isn't theirs, a price * `locationid`, and that row's `locationid` is what decides which branch the
* they forgot — laid out against their own rows and can fix the file before * sale is deducted from. A merchant running six outlets downloads one file, and
* anything is written. The backend validates all of it again regardless; this * rows for all six can be filled in and uploaded together — nobody picks a
* is for the person, not for safety. * store in the UI, because the sheet already says which store each line is for.
*
* Parsing happens in the browser rather than on the server so the operator sees
* every problem laid out against their own rows and can fix the file before
* anything is written. The backend validates all of it again, and re-checks
* that each locationid belongs to the tenant; this is for the person, not for
* safety.
*/ */
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
@@ -25,13 +31,17 @@ export const INFO_SHEET = 'Store Info';
const HELP_SHEET = 'Instructions'; const HELP_SHEET = 'Instructions';
/** Bumped only when the column set changes in a way an old file would break on. */ /** Bumped only when the column set changes in a way an old file would break on. */
export const TEMPLATE_VERSION = 1; export const TEMPLATE_VERSION = 2;
/** /**
* Column headers, in the order they appear. The first three are locked * Column headers, in the order they appear. The first six are locked reference
* reference data; `qtysold` is the one the user is expected to type in. * data — `locationid` among them, since it routes the sale — and `qtysold` is
* the one the user is expected to type in.
*/ */
const COLUMNS = [ const COLUMNS = [
'tenantid',
'locationid',
'locationname',
'productid', 'productid',
'productname', 'productname',
'currentstock', 'currentstock',
@@ -47,10 +57,13 @@ const COLUMNS = [
'remarks', 'remarks',
] as const; ] as const;
const COLUMN_WIDTHS = [11, 38, 13, 10, 11, 15, 11, 14, 13, 13, 18, 15, 24]; const COLUMN_WIDTHS = [10, 11, 22, 11, 38, 13, 10, 11, 15, 11, 14, 13, 13, 18, 15, 24];
/** Rendered above the table so the sheet explains itself without the help tab. */ /** Rendered above the table so the sheet explains itself without the help tab. */
const HEADER_LABELS: Record<string, string> = { const HEADER_LABELS: Record<string, string> = {
tenantid: 'tenantid (do not edit)',
locationid: 'locationid (do not edit)',
locationname: 'store (do not edit)',
productid: 'productid (do not edit)', productid: 'productid (do not edit)',
productname: 'productname (do not edit)', productname: 'productname (do not edit)',
currentstock: 'currentstock (info)', currentstock: 'currentstock (info)',
@@ -69,16 +82,24 @@ const HEADER_LABELS: Record<string, string> = {
const INSTRUCTIONS: string[][] = [ const INSTRUCTIONS: string[][] = [
['How to record offline (counter) sales'], ['How to record offline (counter) sales'],
[''], [''],
['1.', 'Fill in the "qtysold" column on the Sales sheet for whatever you sold at the counter.'], ['This ONE file covers every one of your stores.'],
['Each row already says which store it belongs to, in the locationid and store columns.'],
['Fill in rows for as many stores as you like and upload the file once —'],
['each sale is deducted from the store named on its own row.'],
[''],
['1.', 'Fill in the "qtysold" column for whatever was sold at the counter.'],
['', 'Leave the row blank or 0 if the product did not sell — blank rows are ignored.'], ['', '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.'], ['2.', 'Do NOT edit tenantid, locationid, store, productid or productname.'],
['', 'If a product is missing from the sheet, add it to the store catalogue first,'], ['', 'They identify the store and the product, and must match.'],
['', 'then download a fresh template.'], ['', 'If a product is missing, add it to that store catalogue first, then download a'],
['3.', 'unitprice defaults to the store price shown. Change it if you sold at a different price.'], ['', 'fresh template.'],
['3.', 'unitprice defaults to that store price. Change it if you sold at a different price.'],
['', 'If the price shows 0, the product has no price set — type the real one or the sale'], ['', 'If the price shows 0, the product has no price set — type the real one or the sale'],
['', 'will be recorded with no revenue.'], ['', 'will be recorded with no revenue.'],
['4.', 'billno groups rows into one bill. Rows sharing a billno become a single order.'], ['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.'], ['', 'Bill numbers only need to be unique WITHIN a store — the same number at two'],
['', 'different stores is treated as two separate sales.'],
['', 'Leave billno empty and each store gets one bill for all its rows.'],
['5.', 'saledate accepts YYYY-MM-DD or DD-MM-YYYY. Blank means today.'], ['5.', 'saledate accepts YYYY-MM-DD or DD-MM-YYYY. Blank means today.'],
['6.', 'paymentmode accepts Cash, Card or UPI. Blank means Cash.'], ['6.', 'paymentmode accepts Cash, Card or UPI. Blank means Cash.'],
['7.', 'taxpercent is treated as already included in unitprice (MRP), so the amount'], ['7.', 'taxpercent is treated as already included in unitprice (MRP), so the amount'],
@@ -87,27 +108,33 @@ const INSTRUCTIONS: string[][] = [
['', 'attached to that shopper; leave it blank and it goes to a walk-in customer.'], ['', 'attached to that shopper; leave it blank and it goes to a walk-in customer.'],
[''], [''],
['Uploading the same file twice is safe.'], ['Uploading the same file twice is safe.'],
['Each bill is remembered, so a repeated bill is reported as already imported'], ['Each bill is remembered per store, so a repeated bill is reported as already'],
['and its stock is NOT deducted a second time.'], ['imported and its stock is NOT deducted a second time.'],
[''], [''],
['Imported sales reduce stock exactly like an app order, and appear in Orders'], ['Imported sales reduce stock exactly like an app order, and appear in Orders'],
['and in revenue reports marked as OFFLINE.'], ['and in revenue reports marked as OFFLINE.'],
]; ];
/** /**
* Build the workbook for one outlet. Every stocked product gets a row even when * Build the workbook. Every stocked product at every branch gets a row, even
* its stock is zero — the sheet is a worksheet to fill in, and hiding rows would * when its stock is zero — the sheet is a worksheet to fill in, and hiding rows
* just mean the operator cannot record a sale they actually made. * would mean an operator could not record a sale they actually made.
*
* Rows arrive already ordered by store then product, so a store's rows sit
* together and can be filled in as one block.
*/ */
export function buildSaleTemplateWorkbook(template: SaleTemplate): XLSX.WorkBook { export function buildSaleTemplateWorkbook(template: SaleTemplate): XLSX.WorkBook {
const header = COLUMNS.map((c) => HEADER_LABELS[c] ?? c); const header = COLUMNS.map((c) => HEADER_LABELS[c] ?? c);
const body = template.products.map((p) => [ const body = template.products.map((p) => [
p.tenantid,
p.locationid,
p.locationname,
p.productid, p.productid,
p.productname, p.productname,
p.currentstock, p.currentstock,
// qtysold onwards are left empty: these are the operator's columns, and // qtysold onwards are the operator's columns, left empty: pre-filling
// pre-filling qtysold with 0 invites a file of accidental zero-quantity rows. // qtysold with 0 invites a file of accidental zero-quantity rows.
'', '',
p.price > 0 ? p.price : '', p.price > 0 ? p.price : '',
'', '',
@@ -123,22 +150,24 @@ export function buildSaleTemplateWorkbook(template: SaleTemplate): XLSX.WorkBook
const sales = XLSX.utils.aoa_to_sheet([header, ...body]); const sales = XLSX.utils.aoa_to_sheet([header, ...body]);
sales['!cols'] = COLUMN_WIDTHS.map((w) => ({ wch: w })); sales['!cols'] = COLUMN_WIDTHS.map((w) => ({ wch: w }));
sales['!freeze'] = { xSplit: '0', ySplit: '1' }; sales['!freeze'] = { xSplit: '0', ySplit: '1' };
// Excel's filter dropdowns, so a store can isolate its own rows in a file
// that spans the whole business.
sales['!autofilter'] = { ref: XLSX.utils.encode_range({ s: { r: 0, c: 0 }, e: { r: body.length, c: COLUMNS.length - 1 } }) };
// 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([ const info = XLSX.utils.aoa_to_sheet([
['Field', 'Value'], ['Field', 'Value'],
['tenantid', template.tenantid], ['tenantid', template.tenantid],
['locationid', template.locationid],
['locationname', template.locationname],
['generatedon', new Date().toISOString()], ['generatedon', new Date().toISOString()],
['templateversion', TEMPLATE_VERSION], ['templateversion', TEMPLATE_VERSION],
['stores in this file', template.locations.length],
[''],
['Stores covered', 'Products'],
...template.locations.map((l) => [`${l.locationid}${l.locationname}`, l.productcount]),
[''], [''],
['Do not edit this sheet.'], ['Do not edit this sheet.'],
['These values tell the system which store the sales belong to.'], ['Each sale is routed by the locationid on its own row in the Sales sheet.'],
]); ]);
info['!cols'] = [{ wch: 18 }, { wch: 42 }]; info['!cols'] = [{ wch: 34 }, { wch: 42 }];
const help = XLSX.utils.aoa_to_sheet(INSTRUCTIONS); const help = XLSX.utils.aoa_to_sheet(INSTRUCTIONS);
help['!cols'] = [{ wch: 4 }, { wch: 92 }]; help['!cols'] = [{ wch: 4 }, { wch: 92 }];
@@ -150,14 +179,15 @@ export function buildSaleTemplateWorkbook(template: SaleTemplate): XLSX.WorkBook
return wb; return wb;
} }
/** `offline-sales-r-mart-2026-07-30.xlsx` — outlet and date, so a folder of /** Named for what it spans: one store by name, or "all-stores" for the full
* these stays sortable and it is obvious which store a file belongs to. */ * business, plus the date so a folder of these stays sortable. */
export function saleTemplateFilename(template: SaleTemplate): string { export function saleTemplateFilename(template: SaleTemplate): string {
const single = template.locations.length === 1 ? template.locations[0].locationname : '';
const slug = const slug =
template.locationname (single || 'all-stores')
.toLowerCase() .toLowerCase()
.replace(/[^a-z0-9]+/g, '-') .replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '') || `location-${template.locationid}`; .replace(/^-|-$/g, '') || 'all-stores';
return `offline-sales-${slug}-${new Date().toISOString().slice(0, 10)}.xlsx`; return `offline-sales-${slug}-${new Date().toISOString().slice(0, 10)}.xlsx`;
} }
@@ -171,6 +201,9 @@ export function downloadSaleTemplate(template: SaleTemplate): void {
export interface ParsedSaleRow { export interface ParsedSaleRow {
/** 1-based row number as shown in Excel, so an error can be pointed at. */ /** 1-based row number as shown in Excel, so an error can be pointed at. */
excelRow: number; excelRow: number;
tenantid: number | null;
locationid: number;
locationname: string;
productid: number; productid: number;
productname: string; productname: string;
currentstock: number | null; currentstock: number | null;
@@ -189,14 +222,12 @@ export interface ParsedSaleRow {
} }
export interface ParsedSheet { export interface ParsedSheet {
/** Outlet read from the Store Info sheet, when the file still has it. */ /** Tenant read from the Store Info sheet, when the file still has it. */
tenantid: number | null; tenantid: number | null;
locationid: number | null;
locationname: string;
/** Rows with a quantity — blank ones are dropped, not reported. */ /** Rows with a quantity — blank ones are dropped, not reported. */
rows: ParsedSaleRow[]; rows: ParsedSaleRow[];
/** Rows skipped for having no quantity. Counted so the operator can tell an /** Rows skipped for having no quantity. Counted so an empty column can be
* empty column apart from a file that genuinely had two sales in it. */ * told apart from a file that genuinely had two sales in it. */
skipped: number; skipped: number;
/** Problems with the file as a whole, not with a row. */ /** Problems with the file as a whole, not with a row. */
fatal: string[]; fatal: string[];
@@ -246,14 +277,25 @@ function normaliseHeader(raw: unknown): string {
const PAYMENT_MODES = new Set(['cash', 'card', 'upi']); const PAYMENT_MODES = new Set(['cash', 'card', 'upi']);
export interface ParseScope {
tenantid: number;
/** When set, the upload is pinned to this branch and rows for any other are
* rejected — the store-user case. Omit for a multi-branch upload. */
locationid?: number;
/** Branches the uploader may write to, for naming an unknown locationid in a
* useful way. Absence of this list disables the check, since the backend
* authorises every branch anyway. */
allowedLocationIds?: number[];
}
/** /**
* Parse an uploaded workbook. Never throws for row-level problems — those are * 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 * 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 * point of parsing client-side. Only a file that cannot be read at all, or has
* no recognisable columns, produces a fatal. * no recognisable columns, produces a fatal.
*/ */
export function parseSalesWorkbook(data: ArrayBuffer, expected?: { tenantid: number; locationid: number }): ParsedSheet { export function parseSalesWorkbook(data: ArrayBuffer, scope: ParseScope): ParsedSheet {
const out: ParsedSheet = { tenantid: null, locationid: null, locationname: '', rows: [], skipped: 0, fatal: [] }; const out: ParsedSheet = { tenantid: null, rows: [], skipped: 0, fatal: [] };
let wb: XLSX.WorkBook; let wb: XLSX.WorkBook;
try { try {
@@ -263,31 +305,18 @@ export function parseSalesWorkbook(data: ArrayBuffer, expected?: { tenantid: num
return out; 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]; const infoSheet = wb.Sheets[INFO_SHEET];
if (infoSheet) { if (infoSheet) {
const infoRows = XLSX.utils.sheet_to_json<unknown[]>(infoSheet, { header: 1, blankrows: false }); const infoRows = XLSX.utils.sheet_to_json<unknown[]>(infoSheet, { header: 1, blankrows: false });
for (const r of infoRows) { for (const r of infoRows) {
const key = toStr(r?.[0]).toLowerCase(); if (toStr(r?.[0]).toLowerCase() === 'tenantid') out.tenantid = toNum(r?.[1]);
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 !== scope.tenantid) {
if (out.tenantid !== null && out.tenantid !== expected.tenantid) { out.fatal.push(
out.fatal.push( `This file was generated for a different account (tenant ${out.tenantid}). Download a fresh template.`,
`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]]; const sheet = wb.Sheets[SALES_SHEET] ?? wb.Sheets[wb.SheetNames[0]];
@@ -314,6 +343,16 @@ export function parseSalesWorkbook(data: ArrayBuffer, expected?: { tenantid: num
); );
return out; return out;
} }
if (index.locationid === undefined && !scope.locationid) {
// Without a locationid column there is nothing to route a sale by, and
// guessing a branch would silently move the wrong store's stock.
out.fatal.push(
'The Sales sheet is missing the "locationid" column, so there is no way to tell which store each sale belongs to. Download a fresh template.',
);
return out;
}
const allowed = scope.allowedLocationIds?.length ? new Set(scope.allowedLocationIds) : null;
const cell = (row: unknown[], key: string): unknown => { const cell = (row: unknown[], key: string): unknown => {
const i = index[key]; const i = index[key];
@@ -326,21 +365,29 @@ export function parseSalesWorkbook(data: ArrayBuffer, expected?: { tenantid: num
const qty = toNum(cell(raw, 'qtysold')); const qty = toNum(cell(raw, 'qtysold'));
// Nothing sold on this line. Not an error — a template lists the whole // Nothing sold on this line. Not an error — a template lists the whole
// catalogue and most rows are expected to be empty. // catalogue of every store, and most rows are expected to be empty.
if (qty === null || qty === 0) { if (qty === null || qty === 0) {
out.skipped++; out.skipped++;
continue; continue;
} }
const productid = toNum(cell(raw, 'productid')); const productid = toNum(cell(raw, 'productid'));
const rowLocation = toNum(cell(raw, 'locationid'));
const unitprice = toNum(cell(raw, 'unitprice')); const unitprice = toNum(cell(raw, 'unitprice'));
const taxpercent = toNum(cell(raw, 'taxpercent')); const taxpercent = toNum(cell(raw, 'taxpercent'));
const discount = toNum(cell(raw, 'discountamount')) ?? 0; const discount = toNum(cell(raw, 'discountamount')) ?? 0;
const stock = toNum(cell(raw, 'currentstock')); const stock = toNum(cell(raw, 'currentstock'));
const paymentmode = toStr(cell(raw, 'paymentmode')); const paymentmode = toStr(cell(raw, 'paymentmode'));
// A pinned upload (store user) supplies the branch, so a sheet without the
// column still works for them.
const locationid = rowLocation ?? scope.locationid ?? 0;
const row: ParsedSaleRow = { const row: ParsedSaleRow = {
excelRow, excelRow,
tenantid: toNum(cell(raw, 'tenantid')),
locationid,
locationname: toStr(cell(raw, 'locationname')),
productid: productid ?? 0, productid: productid ?? 0,
productname: toStr(cell(raw, 'productname')), productname: toStr(cell(raw, 'productname')),
currentstock: stock, currentstock: stock,
@@ -361,6 +408,17 @@ export function parseSalesWorkbook(data: ArrayBuffer, expected?: { tenantid: num
if (!productid || productid <= 0) { if (!productid || productid <= 0) {
row.errors.push('productid is missing — do not delete that column'); row.errors.push('productid is missing — do not delete that column');
} }
if (!locationid || locationid <= 0) {
row.errors.push('locationid is missing — this row does not say which store it belongs to');
} else if (row.tenantid !== null && row.tenantid !== scope.tenantid) {
row.errors.push(`tenantid ${row.tenantid} is not your account`);
} else if (scope.locationid && locationid !== scope.locationid) {
// The store-user guard. The backend enforces this too; saying it here
// means they see it before uploading rather than as a rejected bill.
row.errors.push('this row is for another store, which you cannot upload for');
} else if (allowed && !allowed.has(locationid)) {
row.errors.push(`locationid ${locationid} is not one of your stores`);
}
if (qty < 0) { if (qty < 0) {
row.errors.push('qtysold cannot be negative'); row.errors.push('qtysold cannot be negative');
} }
@@ -401,17 +459,21 @@ export function parseSalesWorkbook(data: ArrayBuffer, expected?: { tenantid: num
/** /**
* Group parsed rows into bills for the API. * Group parsed rows into bills for the API.
* *
* Rows sharing a billno become one order. Rows with no billno collapse into a * Bills are keyed on BRANCH first and bill number second, so the same bill
* single unnumbered bill rather than one bill per row: a sheet where the * number at two stores stays two separate sales rather than colliding — which
* operator ignored the billno column is one shopping trip far more often than it * matters now that one file spans the whole business, and counter books at
* is fifty separate ones, and one bill per row would also mean one order per * different outlets routinely restart numbering from 1.
* row cluttering the order list. *
* Rows with no billno collapse into a single unnumbered bill per branch rather
* than one bill per row: a sheet where the operator ignored the billno column is
* one shopping trip far more often than it is fifty separate ones, and one bill
* per row would also mean one order per row cluttering the order list.
*/ */
export function toBills(rows: ParsedSaleRow[]): OfflineSaleBillInput[] { export function toBills(rows: ParsedSaleRow[]): OfflineSaleBillInput[] {
const groups = new Map<string, ParsedSaleRow[]>(); const groups = new Map<string, ParsedSaleRow[]>();
for (const r of rows) { for (const r of rows) {
const key = r.billno.trim().toUpperCase() || '__nobill__'; const key = `${r.locationid}::${r.billno.trim().toUpperCase() || '__nobill__'}`;
const bucket = groups.get(key); const bucket = groups.get(key);
if (bucket) bucket.push(r); if (bucket) bucket.push(r);
else groups.set(key, [r]); else groups.set(key, [r]);
@@ -424,6 +486,7 @@ export function toBills(rows: ParsedSaleRow[]): OfflineSaleBillInput[] {
const first = (pick: (r: ParsedSaleRow) => string): string => group.map(pick).find((v) => v !== '') ?? ''; const first = (pick: (r: ParsedSaleRow) => string): string => group.map(pick).find((v) => v !== '') ?? '';
return { return {
locationid: group[0].locationid,
billno: group[0].billno.trim(), billno: group[0].billno.trim(),
saledate: first((r) => r.saledate), saledate: first((r) => r.saledate),
paymentmode: first((r) => r.paymentmode), paymentmode: first((r) => r.paymentmode),
@@ -442,29 +505,57 @@ export function toBills(rows: ParsedSaleRow[]): OfflineSaleBillInput[] {
}); });
} }
/** Totals for the preview bar. Amounts mirror the backend's arithmetic (tax export interface SaleSummary {
* inclusive), so the figure shown before upload is the one that gets recorded. */
export function summarise(rows: ParsedSaleRow[]): {
lines: number; lines: number;
units: number; units: number;
amount: number; amount: number;
errors: number; errors: number;
warnings: number; warnings: number;
bills: number; bills: number;
} { stores: number;
/** Per-branch breakdown, so a multi-store upload can be checked store by
* store before it is committed. */
byStore: { locationid: number; locationname: string; lines: number; units: number; amount: number; errors: number }[];
}
/** Totals for the preview bar. Amounts mirror the backend's arithmetic (tax
* inclusive), so the figure shown before upload is the one that gets recorded. */
export function summarise(rows: ParsedSaleRow[]): SaleSummary {
let units = 0; let units = 0;
let amount = 0; let amount = 0;
let errors = 0; let errors = 0;
let warnings = 0; let warnings = 0;
const bills = new Set<string>(); const bills = new Set<string>();
const stores = new Map<number, SaleSummary['byStore'][number]>();
for (const r of rows) { for (const r of rows) {
const lineAmount = Math.max(0, (r.unitprice ?? 0) * r.qtysold - r.discountamount);
units += r.qtysold; units += r.qtysold;
amount += Math.max(0, (r.unitprice ?? 0) * r.qtysold - r.discountamount); amount += lineAmount;
if (r.errors.length) errors++; if (r.errors.length) errors++;
if (r.warnings.length) warnings++; if (r.warnings.length) warnings++;
bills.add(r.billno.trim().toUpperCase() || '__nobill__'); bills.add(`${r.locationid}::${r.billno.trim().toUpperCase() || '__nobill__'}`);
let store = stores.get(r.locationid);
if (!store) {
store = { locationid: r.locationid, locationname: r.locationname, lines: 0, units: 0, amount: 0, errors: 0 };
stores.set(r.locationid, store);
}
store.lines++;
store.units += r.qtysold;
store.amount += lineAmount;
if (r.errors.length) store.errors++;
if (!store.locationname && r.locationname) store.locationname = r.locationname;
} }
return { lines: rows.length, units, amount, errors, warnings, bills: bills.size }; return {
lines: rows.length,
units,
amount,
errors,
warnings,
bills: bills.size,
stores: stores.size,
byStore: Array.from(stores.values()).sort((a, b) => b.amount - a.amount),
};
} }