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:
@@ -503,44 +503,63 @@ func (r *productRepository) GetLocationProducts(tenantID, locationID, subcategor
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// GetSaleTemplate lists every product stocked at one outlet, with its live
|
||||
// ledger balance, so the web app can generate a pre-filled offline-sales
|
||||
// spreadsheet. It deliberately returns the whole catalogue for the outlet
|
||||
// unpaged — a spreadsheet the user is meant to fill in and hand back is only
|
||||
// useful if it contains every product they could have sold.
|
||||
// GetSaleTemplate lists products stocked at a tenant's branches, with each
|
||||
// one's live ledger balance, so the web app can generate a pre-filled
|
||||
// offline-sales spreadsheet.
|
||||
//
|
||||
// locationID = 0 means "every branch this tenant runs", which is the normal
|
||||
// case: a merchant with several outlets gets ONE workbook covering all of them,
|
||||
// with tenantid and locationid stamped on every row. The row's own locationid
|
||||
// is what later decides which branch a sale is deducted from, so the operator
|
||||
// never has to pick a store or juggle a file per outlet. Passing a specific
|
||||
// locationID narrows it to that branch, which is what a store user gets.
|
||||
//
|
||||
// It deliberately returns the whole catalogue unpaged — a spreadsheet meant to
|
||||
// be filled in and handed back is only useful if it contains every product that
|
||||
// could have been sold.
|
||||
//
|
||||
// The balance is the same SUM(in) - SUM(out) expression CreateOrder validates
|
||||
// against, so the "currentstock" the user reads in the sheet is exactly the
|
||||
// number the import will later check their quantity against. LOWER() covers
|
||||
// the mixed-case stocktype values in production ('out', 'IN', 'in').
|
||||
// against, so the "currentstock" read in the sheet is exactly the number the
|
||||
// import will check the typed quantity against. LOWER() covers the mixed-case
|
||||
// stocktype values in production ('out', 'IN', 'in').
|
||||
//
|
||||
// A location that does not belong to the tenant yields no template rather than
|
||||
// another tenant's catalogue: the caller treats that as "not your outlet".
|
||||
// The INNER JOIN on tenantlocations is load-bearing: it confines the result to
|
||||
// branches the tenant actually owns, so a template can never disclose another
|
||||
// merchant's catalogue even if a stray productlocations row pointed at one.
|
||||
func (r *productRepository) GetSaleTemplate(tenantID, locationID int) (*models.SaleTemplate, error) {
|
||||
if tenantID <= 0 || locationID <= 0 {
|
||||
return nil, errors.New("tenantid and locationid are required")
|
||||
if tenantID <= 0 {
|
||||
return nil, errors.New("tenantid is required")
|
||||
}
|
||||
if locationID < 0 {
|
||||
locationID = 0
|
||||
}
|
||||
|
||||
var loc struct {
|
||||
Locationname string
|
||||
}
|
||||
err := r.db.Raw(
|
||||
`SELECT locationname FROM tenantlocations WHERE tenantid = ? AND locationid = ?`,
|
||||
tenantID, locationID,
|
||||
).Scan(&loc).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(loc.Locationname) == "" {
|
||||
return nil, fmt.Errorf("location %d does not belong to tenant %d", locationID, tenantID)
|
||||
// Only checked when the caller narrowed to one branch. Without it a
|
||||
// mistyped locationid would silently yield an empty template rather than
|
||||
// saying the outlet is not theirs.
|
||||
if locationID > 0 {
|
||||
var locationName string
|
||||
err := r.db.Raw(
|
||||
`SELECT COALESCE(locationname, '') FROM tenantlocations WHERE tenantid = ? AND locationid = ?`,
|
||||
tenantID, locationID,
|
||||
).Scan(&locationName).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(locationName) == "" {
|
||||
return nil, fmt.Errorf("location %d does not belong to tenant %d", locationID, tenantID)
|
||||
}
|
||||
}
|
||||
|
||||
rows := make([]models.SaleTemplateRow, 0)
|
||||
query := `
|
||||
SELECT a.productid,
|
||||
SELECT a.tenantid,
|
||||
b.locationid,
|
||||
COALESCE(tl.locationname, '') AS locationname,
|
||||
a.productid,
|
||||
a.productname,
|
||||
COALESCE(a.productunit, '') AS productunit,
|
||||
COALESCE(a.unitvalue, '') AS unitvalue,
|
||||
COALESCE(a.productunit, '') AS productunit,
|
||||
COALESCE(a.unitvalue, '') AS unitvalue,
|
||||
COALESCE(d.categoryname, '') AS categoryname,
|
||||
COALESCE(SUM(CASE WHEN LOWER(c.stocktype) = 'in' THEN c.quantity ELSE 0 END) -
|
||||
SUM(CASE WHEN LOWER(c.stocktype) = 'out' THEN c.quantity ELSE 0 END), 0) AS currentstock,
|
||||
@@ -548,23 +567,41 @@ func (r *productRepository) GetSaleTemplate(tenantID, locationID int) (*models.S
|
||||
COALESCE(a.taxpercent, 0) AS taxpercent
|
||||
FROM products a
|
||||
INNER JOIN productlocations b ON a.productid = b.productid AND a.tenantid = b.tenantid
|
||||
INNER JOIN tenantlocations tl ON tl.locationid = b.locationid AND tl.tenantid = a.tenantid
|
||||
LEFT JOIN productstocks c
|
||||
ON a.productid = c.productid AND b.locationid = c.locationid AND a.tenantid = c.tenantid
|
||||
LEFT JOIN productcategories d ON a.categoryid = d.categoryid
|
||||
WHERE a.approve = 1 AND a.tenantid = ? AND b.locationid = ?
|
||||
GROUP BY a.productid, a.productname, a.productunit, a.unitvalue, d.categoryname,
|
||||
b.price, a.retailprice, a.taxpercent
|
||||
ORDER BY a.productname ASC`
|
||||
WHERE a.approve = 1 AND a.tenantid = ? AND (? = 0 OR b.locationid = ?)
|
||||
GROUP BY a.tenantid, b.locationid, tl.locationname, a.productid, a.productname,
|
||||
a.productunit, a.unitvalue, d.categoryname, b.price, a.retailprice, a.taxpercent
|
||||
ORDER BY tl.locationname ASC, a.productname ASC`
|
||||
|
||||
if err := r.db.Raw(query, tenantID, locationID).Scan(&rows).Error; err != nil {
|
||||
if err := r.db.Raw(query, tenantID, locationID, locationID).Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Summarised from the rows themselves rather than queried separately, so
|
||||
// the branch list can never disagree with what the sheet actually contains.
|
||||
locations := make([]models.SaleTemplateLocation, 0)
|
||||
seen := make(map[int]int)
|
||||
for _, row := range rows {
|
||||
if idx, ok := seen[row.Locationid]; ok {
|
||||
locations[idx].Productcount++
|
||||
continue
|
||||
}
|
||||
seen[row.Locationid] = len(locations)
|
||||
locations = append(locations, models.SaleTemplateLocation{
|
||||
Locationid: row.Locationid,
|
||||
Locationname: strings.TrimSpace(row.Locationname),
|
||||
Productcount: 1,
|
||||
})
|
||||
}
|
||||
|
||||
return &models.SaleTemplate{
|
||||
Tenantid: tenantID,
|
||||
Locationid: locationID,
|
||||
Locationname: strings.TrimSpace(loc.Locationname),
|
||||
Products: rows,
|
||||
Tenantid: tenantID,
|
||||
Locationid: locationID,
|
||||
Locations: locations,
|
||||
Products: rows,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user