Route offline sales by the branch named on each spreadsheet row

The offline-sales import required one workbook per outlet and a store
picked in the UI. A merchant running several branches had to download,
fill and upload a file per branch, and the picker defaulted to the
tenant's first outlet — so an admin who never touched it silently
credited the wrong store, which no validation could catch because the
file and the selection agreed with each other.

One workbook now covers every branch. getsaletemplate takes locationid=0
(the default) to span the tenant, stamping tenantid, locationid and the
store name onto every row, and that row's locationid is what decides
which branch a sale is deducted from. The INNER JOIN on tenantlocations
confines it to outlets the tenant owns, so a template can never disclose
another merchant's catalogue.

uploadofflinesales accordingly takes locationid on each bill. The
locationid on the request itself becomes a scope constraint rather than
a destination: left at 0 the bills go where their rows say, and set to a
branch it pins the upload there and refuses anything else. That is what
holds a store user to their own store — the pin comes from their session,
so editing the locationid column in the spreadsheet changes nothing.
Every branch referenced is checked against the tenant regardless.

Branch context and catalogue are resolved once per branch and reused; a
workbook covering six outlets would otherwise re-run both queries for
every bill in it.

Duplicate detection is now per branch. Bill numbers only have to be
unique within a store, since counter books at different outlets
routinely restart numbering at 1, and treating a shared number as a
repeat would have silently dropped a real sale.

Verified against tenant 1087, whose two branches both stock product
6998 at 100 units: a single upload of two bills moved 1097 to 97 and
1135 to 95 independently; the same bill number at both branches imported
as two separate orders; an upload pinned to 1097 imported its own bill
and refused the 1135 one; a row naming another tenant's outlet was
refused; and re-uploading the file deducted nothing. All five test
orders were cancelled afterwards and both branches confirmed back at 100.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 12:17:52 +05:30
parent 583cd89063
commit c290e1729a
6 changed files with 234 additions and 83 deletions

View File

@@ -1672,29 +1672,52 @@ func (r *orderRepository) resolveOfflineCustomer(tx *gorm.DB, ctx *offlineLocati
// instead of a race: two uploads of the same file arriving together would
// otherwise both read "not yet imported" and both commit.
func (r *orderRepository) UploadOfflineSales(input models.OfflineSalesUpload) (*models.OfflineSalesUploadResponse, error) {
if input.Tenantid <= 0 {
return nil, errors.New("tenantid is required")
}
if len(input.Bills) == 0 {
return nil, errors.New("no sales rows found in the upload")
}
ctx, err := r.resolveOfflineLocationContext(input.Tenantid, input.Locationid)
if err != nil {
return nil, err
// When the caller pins the upload to one branch, that branch is resolved
// (and authorised) up front so an outlet the merchant does not own fails
// the whole request rather than each bill in turn.
if input.Locationid > 0 {
if _, err := r.resolveOfflineLocationContext(input.Tenantid, input.Locationid); err != nil {
return nil, err
}
}
products, err := r.loadOfflineProducts(input.Tenantid, input.Locationid)
if err != nil {
return nil, err
}
if len(products) == 0 {
return nil, fmt.Errorf("outlet '%s' has no products stocked against it", ctx.Locationname)
// Branch context and catalogue are resolved once per branch and reused. A
// workbook covering six outlets would otherwise re-run both queries for
// every bill in it.
contexts := make(map[int]*offlineLocationContext)
catalogues := make(map[int]map[int]offlineProduct)
resolve := func(locationID int) (*offlineLocationContext, map[int]offlineProduct, error) {
if ctx, ok := contexts[locationID]; ok {
return ctx, catalogues[locationID], nil
}
ctx, err := r.resolveOfflineLocationContext(input.Tenantid, locationID)
if err != nil {
return nil, nil, err
}
products, err := r.loadOfflineProducts(input.Tenantid, locationID)
if err != nil {
return nil, nil, err
}
if len(products) == 0 {
return nil, nil, fmt.Errorf("outlet '%s' has no products stocked against it", ctx.Locationname)
}
contexts[locationID] = ctx
catalogues[locationID] = products
return ctx, products, nil
}
resp := &models.OfflineSalesUploadResponse{Results: make([]models.OfflineSaleResult, 0, len(input.Bills))}
for _, bill := range input.Bills {
result := r.importOfflineBill(ctx, products, input.Userid, bill)
record := func(result models.OfflineSaleResult) {
resp.Results = append(resp.Results, result)
switch result.Status {
case models.OfflineSaleImported:
resp.Imported++
@@ -1706,6 +1729,49 @@ func (r *orderRepository) UploadOfflineSales(input models.OfflineSalesUpload) (*
}
}
for _, bill := range input.Bills {
billLocation := bill.Locationid
if billLocation <= 0 {
billLocation = input.Locationid
}
if billLocation <= 0 {
record(models.OfflineSaleResult{
Billno: strings.TrimSpace(bill.Billno),
Status: models.OfflineSaleFailed,
Message: "no locationid on these rows — the sheet must say which branch the sale belongs to",
})
continue
}
// A pinned upload refuses bills for anywhere else. This is what keeps a
// store user inside their own branch: editing the locationid column in
// the spreadsheet changes nothing, because the pin is set from their
// session and not from the file.
if input.Locationid > 0 && billLocation != input.Locationid {
record(models.OfflineSaleResult{
Locationid: billLocation,
Billno: strings.TrimSpace(bill.Billno),
Status: models.OfflineSaleFailed,
Message: fmt.Sprintf("this upload is limited to outlet %d, but these rows are for outlet %d", input.Locationid, billLocation),
})
continue
}
ctx, products, err := resolve(billLocation)
if err != nil {
record(models.OfflineSaleResult{
Locationid: billLocation,
Billno: strings.TrimSpace(bill.Billno),
Status: models.OfflineSaleFailed,
Message: err.Error(),
})
continue
}
record(r.importOfflineBill(ctx, products, input.Userid, bill))
}
return resp, nil
}
@@ -1723,9 +1789,11 @@ func (r *orderRepository) importOfflineBill(
fail := func(format string, args ...any) models.OfflineSaleResult {
return models.OfflineSaleResult{
Billno: billLabel,
Status: models.OfflineSaleFailed,
Message: fmt.Sprintf(format, args...),
Locationid: ctx.Locationid,
Locationname: ctx.Locationname,
Billno: billLabel,
Status: models.OfflineSaleFailed,
Message: fmt.Sprintf(format, args...),
}
}
@@ -1855,9 +1923,11 @@ func (r *orderRepository) importOfflineBill(
if already > 0 {
tx.Rollback()
return models.OfflineSaleResult{
Billno: billLabel,
Status: models.OfflineSaleDuplicate,
Message: fmt.Sprintf("bill %s was already imported for this outlet; stock was not deducted again", billLabel),
Locationid: ctx.Locationid,
Locationname: ctx.Locationname,
Billno: billLabel,
Status: models.OfflineSaleDuplicate,
Message: fmt.Sprintf("bill %s was already imported for %s; stock was not deducted again", billLabel, ctx.Locationname),
}
}
@@ -1908,13 +1978,15 @@ func (r *orderRepository) importOfflineBill(
}
return models.OfflineSaleResult{
Locationid: ctx.Locationid,
Locationname: ctx.Locationname,
Billno: billLabel,
Status: models.OfflineSaleImported,
Orderid: created.Orderid,
Orderheaderid: created.Orderheaderid,
Itemcount: len(items),
Amount: orderAmount,
Message: fmt.Sprintf("imported as order %s", created.Orderid),
Message: fmt.Sprintf("imported as order %s at %s", created.Orderid, ctx.Locationname),
}
}