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>
{/* Offline (counter) sales import. `locations` is passed so an admin picks
the branch the bills belong to — this view otherwise operates on the
tenant's first outlet, which would silently credit the wrong store. */}
{/* Offline (counter) sales import. No locationId is passed: the admin gets
one workbook covering every branch, and each row's own locationid
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 && (
<OfflineSalesUpload
tenantId={tenantId}
locationId={primaryLocationId}
locations={locations.map(({ locationid, locationname }) => ({ locationid, locationname }))}
onClose={() => setShowOfflineSales(false)}
/>
)}

View File

@@ -7,18 +7,24 @@
* Offline (counter) sales import.
*
* 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
* pre-filled with the outlet's catalogue, type sold quantities into it, upload
* it back. Imported sales become real orders, so they reduce stock through the
* same path an app order uses and show up in revenue reporting.
* its stock. This is how that stock gets deducted: download a spreadsheet
* pre-filled with the catalogue, type sold quantities into it, upload it back.
* Imported sales become real orders, so they reduce stock through the same path
* 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`
* 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
* 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;
* the store user's page passes their own, which is the only one they can reach.
* Used by both surfaces. The admin console passes no locationId, so the file
* 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';
@@ -30,6 +36,7 @@ import {
FileSpreadsheet,
Loader2,
RotateCcw,
Store,
Upload,
X,
XCircle,
@@ -49,19 +56,15 @@ import {
interface OfflineSalesUploadProps {
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`. */
locationId: number;
/** Shown in the header so it is unambiguous which store is being credited. */
/**
* Pins the upload to a single branch. Passed by the store user's page so they
* can only ever import for their own store. Omitted by the admin console, so
* 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;
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;
}
@@ -73,28 +76,29 @@ export default function OfflineSalesUpload({
locationId,
storeName,
userId,
locations,
onClose,
}: OfflineSalesUploadProps) {
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const [activeLocationId, setActiveLocationId] = useState(locationId);
const [parsed, setParsed] = useState<ParsedSheet | null>(null);
const [fileName, setFileName] = useState('');
const [dragging, setDragging] = useState(false);
const [result, setResult] = useState<OfflineSalesUploadResponse | null>(null);
const [uploadError, setUploadError] = useState('');
const pinned = (locationId ?? 0) > 0;
const templateQuery = useQuery({
queryKey: ['saleTemplate', tenantId, activeLocationId],
queryFn: () => getSaleTemplate({ tenantid: tenantId, locationid: activeLocationId }),
enabled: tenantId > 0 && activeLocationId > 0,
queryKey: ['saleTemplate', tenantId, locationId ?? 0],
queryFn: () => getSaleTemplate({ tenantid: tenantId, locationid: locationId ?? 0 }),
enabled: tenantId > 0,
// 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,
});
const template = templateQuery.data;
const summary = useMemo(() => (parsed ? summarise(parsed.rows) : null), [parsed]);
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.');
return uploadOfflineSales({
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,
bills: toBills(parsed.rows),
});
@@ -111,9 +117,10 @@ export default function OfflineSalesUpload({
onSuccess: (res) => {
setResult(res);
setUploadError('');
// Stock has moved, so every view reading it is now stale. Invalidating
// broadly is deliberate — a partially-refreshed inventory screen after an
// import is worse than a few extra refetches.
// Stock has moved at potentially several branches, so every view reading
// it is now stale. Invalidating broadly is deliberate — a partially
// refreshed inventory screen after an import is worse than a few extra
// refetches.
queryClient.invalidateQueries();
},
onError: (err: Error) => setUploadError(err.message),
@@ -126,19 +133,23 @@ export default function OfflineSalesUpload({
setFileName(file.name);
try {
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 {
setParsed({
tenantid: null,
locationid: null,
locationname: '',
rows: [],
skipped: 0,
fatal: ['That file could not be read. Upload the .xlsx template you downloaded.'],
});
}
},
[tenantId, activeLocationId],
[tenantId, locationId, template],
);
const reset = () => {
@@ -149,12 +160,12 @@ export default function OfflineSalesUpload({
if (fileInputRef.current) fileInputRef.current.value = '';
};
const picker = locations && locations.length > 1 ? locations : null;
const outletLabel =
picker?.find((l) => l.locationid === activeLocationId)?.locationname ||
storeName ||
templateQuery.data?.locationname ||
`Outlet ${activeLocationId}`;
const storeCount = template?.locations.length ?? 0;
const scopeLabel = pinned
? storeName || template?.locations[0]?.locationname || `Outlet ${locationId}`
: storeCount === 1
? template?.locations[0]?.locationname || 'your store'
: `all ${storeCount} stores`;
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4">
@@ -167,7 +178,7 @@ export default function OfflineSalesUpload({
<div>
<h2 className="text-lg font-bold tracking-tight text-white">Offline Sales Upload</h2>
<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>
</div>
</div>
@@ -185,36 +196,6 @@ export default function OfflineSalesUpload({
<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
uploading anything else will not work. */}
<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">
1
</span>
Download the template for this store
Download the template
</h3>
<p className="mt-1.5 text-xs leading-relaxed text-slate-600">
{templateQuery.isLoading
? 'Loading this outlets catalogue…'
? 'Loading your catalogue…'
: templateQuery.isError
? 'Could not load this outlets catalogue.'
: `${templateQuery.data?.products.length ?? 0} products stocked here. Fill in the qtysold column and upload the file back.`}
? 'Could not load your catalogue.'
: 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>
</div>
<button
onClick={() => templateQuery.data && downloadSaleTemplate(templateQuery.data)}
disabled={!templateQuery.data || templateQuery.isLoading}
onClick={() => template && downloadSaleTemplate(template)}
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"
>
{templateQuery.isLoading ? (
@@ -247,6 +230,25 @@ export default function OfflineSalesUpload({
Download Template
</button>
</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 && (
<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}
@@ -325,7 +327,8 @@ export default function OfflineSalesUpload({
{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="Lines" value={String(summary.lines)} />
<Stat label="Units" value={String(summary.units)} />
@@ -337,6 +340,45 @@ export default function OfflineSalesUpload({
/>
</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 && (
<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} />
@@ -357,10 +399,11 @@ export default function OfflineSalesUpload({
)}
<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">
<tr>
<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 text-right font-semibold">Qty</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'}
>
<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">
<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>
@@ -425,8 +471,8 @@ export default function OfflineSalesUpload({
{!result && (
<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">
Imported sales reduce stock and appear in Orders marked <span className="font-semibold">OFFLINE</span>.
Re-uploading the same file will not deduct twice.
Each sale is deducted from the store named on its own row, and appears in Orders marked{' '}
<span className="font-semibold">OFFLINE</span>. Re-uploading the same file will not deduct twice.
</p>
<div className="flex items-center gap-3">
{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>
@@ -519,6 +566,7 @@ function ResultPanel({
<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 font-semibold">Bill</th>
<th className="px-3 py-2 font-semibold">Result</th>
<th className="px-3 py-2 font-semibold">Order</th>
@@ -530,11 +578,12 @@ function ResultPanel({
<tbody className="divide-y divide-slate-100">
{result.results.map((r, i) => (
<tr
key={`${r.billno}-${i}`}
key={`${r.locationid}-${r.billno}-${i}`}
className={
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">
{r.status === 'imported' && (

View File

@@ -1292,6 +1292,9 @@ export async function getSalesSummary(opts: {
// ════════════════════════════════════════════════════════════════════════════
export interface SaleTemplateRow {
tenantid: number;
locationid: number;
locationname: string;
productid: number;
productname: string;
productunit: string;
@@ -1302,33 +1305,45 @@ export interface SaleTemplateRow {
taxpercent: number;
}
export interface SaleTemplate {
tenantid: number;
export interface SaleTemplateLocation {
locationid: number;
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[];
}
/**
* GET /products/getsaletemplate — every product stocked at one outlet, with its
* live ledger balance and price.
* GET /products/getsaletemplate — products stocked across the tenant's
* 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
* 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.
* unique enough either. Pre-filling productid and locationid removes both
* problems at once.
*/
export async function getSaleTemplate(opts: {
tenantid: number;
locationid: number;
locationid?: number;
}): Promise<SaleTemplate> {
const res = await fiestaGet<{ details: SaleTemplate | null }>('products/getsaletemplate', {
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;
}
@@ -1342,6 +1357,8 @@ export interface OfflineSaleItemInput {
}
export interface OfflineSaleBillInput {
/** Branch this bill was rung up at, taken from the spreadsheet row. */
locationid: number;
billno?: string;
saledate?: string;
paymentmode?: string;
@@ -1352,6 +1369,8 @@ export interface OfflineSaleBillInput {
}
export interface OfflineSaleResult {
locationid: number;
locationname: string;
billno: string;
status: 'imported' | 'duplicate' | 'failed';
orderid: string;
@@ -1379,10 +1398,16 @@ export interface OfflineSalesUploadResponse {
*
* 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.
*
* `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: {
tenantid: number;
locationid: number;
locationid?: number;
userid?: number;
bills: OfflineSaleBillInput[];
}): Promise<OfflineSalesUploadResponse> {
@@ -1391,7 +1416,7 @@ export async function uploadOfflineSales(input: {
'POST',
{
tenantid: input.tenantid,
locationid: input.locationid,
locationid: input.locationid ?? 0,
userid: input.userid ?? 0,
bills: input.bills,
},

View File

@@ -4,15 +4,21 @@
*/
/**
* 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 spreadsheet half of offline-sales import: turning a merchant's catalogue
* into a workbook the stores fill 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.
* ONE workbook covers EVERY branch. Each row carries its own `tenantid` and
* `locationid`, and that row's `locationid` is what decides which branch the
* sale is deducted from. A merchant running six outlets downloads one file, and
* rows for all six can be filled in and uploaded together — nobody picks a
* 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';
@@ -25,13 +31,17 @@ 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;
export const TEMPLATE_VERSION = 2;
/**
* 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.
* Column headers, in the order they appear. The first six are locked reference
* data — `locationid` among them, since it routes the sale — and `qtysold` is
* the one the user is expected to type in.
*/
const COLUMNS = [
'tenantid',
'locationid',
'locationname',
'productid',
'productname',
'currentstock',
@@ -47,10 +57,13 @@ const COLUMNS = [
'remarks',
] 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. */
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)',
productname: 'productname (do not edit)',
currentstock: 'currentstock (info)',
@@ -69,16 +82,24 @@ const HEADER_LABELS: Record<string, string> = {
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.'],
['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.'],
['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.'],
['2.', 'Do NOT edit tenantid, locationid, store, productid or productname.'],
['', 'They identify the store and the product, and must match.'],
['', 'If a product is missing, add it to that store catalogue first, then download a'],
['', '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'],
['', '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.'],
['', '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.'],
['6.', 'paymentmode accepts Cash, Card or UPI. Blank means Cash.'],
['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.'],
[''],
['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.'],
['Each bill is remembered per store, 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.
* Build the workbook. Every stocked product at every branch gets a row, even
* when its stock is zero — the sheet is a worksheet to fill in, and hiding rows
* 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 {
const header = COLUMNS.map((c) => HEADER_LABELS[c] ?? c);
const body = template.products.map((p) => [
p.tenantid,
p.locationid,
p.locationname,
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.
// qtysold onwards are the operator's columns, left empty: pre-filling
// qtysold with 0 invites a file of accidental zero-quantity rows.
'',
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]);
sales['!cols'] = COLUMN_WIDTHS.map((w) => ({ wch: w }));
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([
['Field', 'Value'],
['tenantid', template.tenantid],
['locationid', template.locationid],
['locationname', template.locationname],
['generatedon', new Date().toISOString()],
['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.'],
['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);
help['!cols'] = [{ wch: 4 }, { wch: 92 }];
@@ -150,14 +179,15 @@ export function buildSaleTemplateWorkbook(template: SaleTemplate): XLSX.WorkBook
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. */
/** Named for what it spans: one store by name, or "all-stores" for the full
* business, plus the date so a folder of these stays sortable. */
export function saleTemplateFilename(template: SaleTemplate): string {
const single = template.locations.length === 1 ? template.locations[0].locationname : '';
const slug =
template.locationname
(single || 'all-stores')
.toLowerCase()
.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`;
}
@@ -171,6 +201,9 @@ export function downloadSaleTemplate(template: SaleTemplate): void {
export interface ParsedSaleRow {
/** 1-based row number as shown in Excel, so an error can be pointed at. */
excelRow: number;
tenantid: number | null;
locationid: number;
locationname: string;
productid: number;
productname: string;
currentstock: number | null;
@@ -189,14 +222,12 @@ export interface ParsedSaleRow {
}
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;
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. */
/** Rows skipped for having no quantity. Counted so an empty column can be
* told 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[];
@@ -246,14 +277,25 @@ function normaliseHeader(raw: unknown): string {
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
* 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: [] };
export function parseSalesWorkbook(data: ArrayBuffer, scope: ParseScope): ParsedSheet {
const out: ParsedSheet = { tenantid: null, rows: [], skipped: 0, fatal: [] };
let wb: XLSX.WorkBook;
try {
@@ -263,31 +305,18 @@ export function parseSalesWorkbook(data: ArrayBuffer, expected?: { tenantid: num
return out;
}
// Store Info is read first: knowing the outlet lets a mismatched file be
// caught before any row is interpreted against the wrong catalogue.
const infoSheet = wb.Sheets[INFO_SHEET];
if (infoSheet) {
const infoRows = XLSX.utils.sheet_to_json<unknown[]>(infoSheet, { header: 1, blankrows: false });
for (const r of infoRows) {
const key = toStr(r?.[0]).toLowerCase();
const val = r?.[1];
if (key === 'tenantid') out.tenantid = toNum(val);
else if (key === 'locationid') out.locationid = toNum(val);
else if (key === 'locationname') out.locationname = toStr(val);
if (toStr(r?.[0]).toLowerCase() === 'tenantid') out.tenantid = toNum(r?.[1]);
}
}
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.`,
);
}
if (out.tenantid !== null && out.tenantid !== scope.tenantid) {
out.fatal.push(
`This file was generated for a different account (tenant ${out.tenantid}). Download a fresh template.`,
);
}
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;
}
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 i = index[key];
@@ -326,21 +365,29 @@ export function parseSalesWorkbook(data: ArrayBuffer, expected?: { tenantid: num
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.
// catalogue of every store, and most rows are expected to be empty.
if (qty === null || qty === 0) {
out.skipped++;
continue;
}
const productid = toNum(cell(raw, 'productid'));
const rowLocation = toNum(cell(raw, 'locationid'));
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'));
// 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 = {
excelRow,
tenantid: toNum(cell(raw, 'tenantid')),
locationid,
locationname: toStr(cell(raw, 'locationname')),
productid: productid ?? 0,
productname: toStr(cell(raw, 'productname')),
currentstock: stock,
@@ -361,6 +408,17 @@ export function parseSalesWorkbook(data: ArrayBuffer, expected?: { tenantid: num
if (!productid || productid <= 0) {
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) {
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.
*
* 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.
* Bills are keyed on BRANCH first and bill number second, so the same bill
* number at two stores stays two separate sales rather than colliding — which
* matters now that one file spans the whole business, and counter books at
* different outlets routinely restart numbering from 1.
*
* 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[] {
const groups = new Map<string, ParsedSaleRow[]>();
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);
if (bucket) bucket.push(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 !== '') ?? '';
return {
locationid: group[0].locationid,
billno: group[0].billno.trim(),
saledate: first((r) => r.saledate),
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
* inclusive), so the figure shown before upload is the one that gets recorded. */
export function summarise(rows: ParsedSaleRow[]): {
export interface SaleSummary {
lines: number;
units: number;
amount: number;
errors: number;
warnings: 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 amount = 0;
let errors = 0;
let warnings = 0;
const bills = new Set<string>();
const stores = new Map<number, SaleSummary['byStore'][number]>();
for (const r of rows) {
const lineAmount = Math.max(0, (r.unitprice ?? 0) * r.qtysold - r.discountamount);
units += r.qtysold;
amount += Math.max(0, (r.unitprice ?? 0) * r.qtysold - r.discountamount);
amount += lineAmount;
if (r.errors.length) errors++;
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),
};
}