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

@@ -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,
},