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:
@@ -390,10 +390,13 @@ func (ctl *OrderController) UploadOfflineSales(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if input.Tenantid <= 0 || input.Locationid <= 0 {
|
// locationid is optional: 0 means the bills carry their own branch, which
|
||||||
|
// is how one workbook covers every outlet a merchant runs. Supplying it
|
||||||
|
// pins the upload to that branch and rejects anything else in the file.
|
||||||
|
if input.Tenantid <= 0 {
|
||||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
"code": http.StatusBadRequest,
|
"code": http.StatusBadRequest,
|
||||||
"message": "tenantid and locationid are required",
|
"message": "tenantid is required",
|
||||||
"status": false,
|
"status": false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -310,19 +310,21 @@ func (ctl *ProductController) GetLocationProducts(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetSaleTemplate serves the data the web app turns into the offline-sales
|
// GetSaleTemplate serves the data the web app turns into the offline-sales
|
||||||
// spreadsheet. Both tenantid and locationid are required rather than defaulted:
|
// spreadsheet.
|
||||||
// a template generated against the wrong outlet would carry productids the
|
//
|
||||||
// import then rejects, which is a confusing failure a missing parameter should
|
// locationid is optional and defaults to 0, meaning every branch the tenant
|
||||||
// not be able to cause.
|
// runs — one workbook for the whole business, with each row carrying the branch
|
||||||
|
// its stock belongs to. A store user passes their own locationid to get just
|
||||||
|
// theirs. tenantid is required: without it there is no scope at all.
|
||||||
func (ctl *ProductController) GetSaleTemplate(c *fiber.Ctx) error {
|
func (ctl *ProductController) GetSaleTemplate(c *fiber.Ctx) error {
|
||||||
tenantID, _ := strconv.Atoi(c.Query("tenantid"))
|
tenantID, _ := strconv.Atoi(c.Query("tenantid"))
|
||||||
locationID, _ := strconv.Atoi(c.Query("locationid"))
|
locationID, _ := strconv.Atoi(c.Query("locationid", "0"))
|
||||||
|
|
||||||
if tenantID <= 0 || locationID <= 0 {
|
if tenantID <= 0 {
|
||||||
return c.JSON(fiber.Map{
|
return c.JSON(fiber.Map{
|
||||||
"status": false,
|
"status": false,
|
||||||
"code": http.StatusBadRequest,
|
"code": http.StatusBadRequest,
|
||||||
"message": "tenantid and locationid are required",
|
"message": "tenantid is required",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -460,9 +460,18 @@ type OfflineSaleItem struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// OfflineSaleBill is one counter bill — the rows of a spreadsheet grouped by
|
// OfflineSaleBill is one counter bill — the rows of a spreadsheet grouped by
|
||||||
// their billno. Billno is what makes a re-upload of the same file safe: it is
|
// their branch and bill number.
|
||||||
// recorded on the order and refused if it is already present for this outlet.
|
//
|
||||||
|
// Locationid is the branch the bill was rung up at, taken from the spreadsheet
|
||||||
|
// row rather than from a store the operator picked in the UI. One workbook can
|
||||||
|
// therefore carry sales for every branch a merchant runs, and each bill's stock
|
||||||
|
// comes out of its own outlet. Bills are grouped per branch, so the same bill
|
||||||
|
// number at two outlets is two separate sales, not a duplicate.
|
||||||
|
//
|
||||||
|
// Billno is what makes a re-upload of the same file safe: it is recorded on the
|
||||||
|
// order and refused if already present for that branch.
|
||||||
type OfflineSaleBill struct {
|
type OfflineSaleBill struct {
|
||||||
|
Locationid int `json:"locationid"`
|
||||||
Billno string `json:"billno"`
|
Billno string `json:"billno"`
|
||||||
Saledate string `json:"saledate"`
|
Saledate string `json:"saledate"`
|
||||||
Paymentmode string `json:"paymentmode"`
|
Paymentmode string `json:"paymentmode"`
|
||||||
@@ -472,9 +481,16 @@ type OfflineSaleBill struct {
|
|||||||
Items []OfflineSaleItem `json:"items"`
|
Items []OfflineSaleItem `json:"items"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// OfflineSalesUpload is the request body. Locationid is the outlet the sales
|
// OfflineSalesUpload is the request body.
|
||||||
// belong to and is authorised server-side against Tenantid — a store user
|
//
|
||||||
// editing the spreadsheet cannot post sales into another branch.
|
// Locationid here is a scope constraint, not the destination. Left at 0 the
|
||||||
|
// bills go to whichever branch each one names, which is what a multi-branch
|
||||||
|
// owner uploads. Set to a branch it pins the whole upload to that outlet and
|
||||||
|
// any bill naming a different one is refused — that is how a store user is
|
||||||
|
// held to their own store no matter what the spreadsheet says.
|
||||||
|
//
|
||||||
|
// Every branch referenced is checked against Tenantid regardless, so no upload
|
||||||
|
// can reach an outlet the merchant does not own.
|
||||||
type OfflineSalesUpload struct {
|
type OfflineSalesUpload struct {
|
||||||
Tenantid int `json:"tenantid"`
|
Tenantid int `json:"tenantid"`
|
||||||
Locationid int `json:"locationid"`
|
Locationid int `json:"locationid"`
|
||||||
@@ -492,7 +508,11 @@ const (
|
|||||||
|
|
||||||
// OfflineSaleResult reports one bill's fate. Bills are independent, so a file
|
// OfflineSaleResult reports one bill's fate. Bills are independent, so a file
|
||||||
// with one bad bill still imports the rest and names exactly what it skipped.
|
// with one bad bill still imports the rest and names exactly what it skipped.
|
||||||
|
// The branch is echoed back because a single upload spans several, and "bill 7
|
||||||
|
// failed" is not actionable without knowing which store it belonged to.
|
||||||
type OfflineSaleResult struct {
|
type OfflineSaleResult struct {
|
||||||
|
Locationid int `json:"locationid"`
|
||||||
|
Locationname string `json:"locationname"`
|
||||||
Billno string `json:"billno"`
|
Billno string `json:"billno"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Orderid string `json:"orderid"`
|
Orderid string `json:"orderid"`
|
||||||
|
|||||||
@@ -327,19 +327,27 @@ type ProductLocationRef struct {
|
|||||||
Productid int
|
Productid int
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaleTemplateRow is one line of the downloadable offline-sales spreadsheet:
|
// SaleTemplateRow is one line of the downloadable offline-sales spreadsheet: a
|
||||||
// a product actually stocked at one outlet, with the numbers the person at the
|
// product stocked at one branch, with the numbers the person at the till needs
|
||||||
// till needs to see before they type a sold quantity against it.
|
// to see before typing a sold quantity against it.
|
||||||
|
//
|
||||||
|
// Tenantid and Locationid ride on every row because one workbook covers every
|
||||||
|
// branch a merchant runs. The row's own Locationid decides which branch's stock
|
||||||
|
// its sale comes out of — a tenant-level import would be wrong, since the same
|
||||||
|
// product is held separately at each outlet.
|
||||||
//
|
//
|
||||||
// Productid is the only field that identifies the product. It cannot be
|
// Productid is the only field that identifies the product. It cannot be
|
||||||
// productsku: across the live catalogue 6,245 products share just 93 distinct
|
// productsku: across the live catalogue 6,245 products share just 93 distinct
|
||||||
// sku values (one tenant has 463 products all carrying sku "1"), and 154 are
|
// sku values (one tenant has 463 products all carrying sku "1"), and 154 are
|
||||||
// blank, so a sku is not a key. Productname is nearly unique per tenant but
|
// blank, so a sku is not a key. Productname is nearly unique per tenant but not
|
||||||
// not reliably ("rice" appears 6 times for one tenant), so it travels as a
|
// reliably ("rice" appears 6 times for one tenant), so it travels as a
|
||||||
// human-readable confirmation only and is never matched on. That is why the
|
// human-readable confirmation only and is never matched on. That is why the
|
||||||
// spreadsheet has to be generated from this endpoint rather than typed from
|
// spreadsheet has to be generated from this endpoint rather than typed from
|
||||||
// scratch — the productid column is filled in for the user.
|
// scratch — productid and locationid are filled in for the user.
|
||||||
type SaleTemplateRow struct {
|
type SaleTemplateRow struct {
|
||||||
|
Tenantid int `json:"tenantid"`
|
||||||
|
Locationid int `json:"locationid"`
|
||||||
|
Locationname string `json:"locationname"`
|
||||||
Productid int `json:"productid"`
|
Productid int `json:"productid"`
|
||||||
Productname string `json:"productname"`
|
Productname string `json:"productname"`
|
||||||
Productunit string `json:"productunit"`
|
Productunit string `json:"productunit"`
|
||||||
@@ -350,15 +358,24 @@ type SaleTemplateRow struct {
|
|||||||
Taxpercent float64 `json:"taxpercent"`
|
Taxpercent float64 `json:"taxpercent"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaleTemplate is the payload the web app turns into an .xlsx workbook. The
|
// SaleTemplateLocation is one branch covered by the workbook, so the sheet can
|
||||||
// tenant/location identity travels with it so the generated file records which
|
// list what it spans and the UI can summarise it without walking every row.
|
||||||
// outlet it belongs to, and the upload can be checked against the file it came
|
type SaleTemplateLocation struct {
|
||||||
// from instead of trusting a hand-typed location.
|
Locationid int `json:"locationid"`
|
||||||
|
Locationname string `json:"locationname"`
|
||||||
|
Productcount int `json:"productcount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaleTemplate is the payload the web app turns into an .xlsx workbook.
|
||||||
|
//
|
||||||
|
// Locationid is 0 when the template spans every branch of the tenant, which is
|
||||||
|
// the normal case for an owner or admin. A store user gets a template for their
|
||||||
|
// own branch only, and it is then the single entry in Locations.
|
||||||
type SaleTemplate struct {
|
type SaleTemplate struct {
|
||||||
Tenantid int `json:"tenantid"`
|
Tenantid int `json:"tenantid"`
|
||||||
Locationid int `json:"locationid"`
|
Locationid int `json:"locationid"`
|
||||||
Locationname string `json:"locationname"`
|
Locations []SaleTemplateLocation `json:"locations"`
|
||||||
Products []SaleTemplateRow `json:"products"`
|
Products []SaleTemplateRow `json:"products"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProductSubcategory struct {
|
type ProductSubcategory struct {
|
||||||
|
|||||||
@@ -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
|
// instead of a race: two uploads of the same file arriving together would
|
||||||
// otherwise both read "not yet imported" and both commit.
|
// otherwise both read "not yet imported" and both commit.
|
||||||
func (r *orderRepository) UploadOfflineSales(input models.OfflineSalesUpload) (*models.OfflineSalesUploadResponse, error) {
|
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 {
|
if len(input.Bills) == 0 {
|
||||||
return nil, errors.New("no sales rows found in the upload")
|
return nil, errors.New("no sales rows found in the upload")
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, err := r.resolveOfflineLocationContext(input.Tenantid, input.Locationid)
|
// When the caller pins the upload to one branch, that branch is resolved
|
||||||
if err != nil {
|
// (and authorised) up front so an outlet the merchant does not own fails
|
||||||
return nil, err
|
// 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)
|
// Branch context and catalogue are resolved once per branch and reused. A
|
||||||
if err != nil {
|
// workbook covering six outlets would otherwise re-run both queries for
|
||||||
return nil, err
|
// every bill in it.
|
||||||
}
|
contexts := make(map[int]*offlineLocationContext)
|
||||||
if len(products) == 0 {
|
catalogues := make(map[int]map[int]offlineProduct)
|
||||||
return nil, fmt.Errorf("outlet '%s' has no products stocked against it", ctx.Locationname)
|
|
||||||
|
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))}
|
resp := &models.OfflineSalesUploadResponse{Results: make([]models.OfflineSaleResult, 0, len(input.Bills))}
|
||||||
|
|
||||||
for _, bill := range input.Bills {
|
record := func(result models.OfflineSaleResult) {
|
||||||
result := r.importOfflineBill(ctx, products, input.Userid, bill)
|
|
||||||
resp.Results = append(resp.Results, result)
|
resp.Results = append(resp.Results, result)
|
||||||
|
|
||||||
switch result.Status {
|
switch result.Status {
|
||||||
case models.OfflineSaleImported:
|
case models.OfflineSaleImported:
|
||||||
resp.Imported++
|
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
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1723,9 +1789,11 @@ func (r *orderRepository) importOfflineBill(
|
|||||||
|
|
||||||
fail := func(format string, args ...any) models.OfflineSaleResult {
|
fail := func(format string, args ...any) models.OfflineSaleResult {
|
||||||
return models.OfflineSaleResult{
|
return models.OfflineSaleResult{
|
||||||
Billno: billLabel,
|
Locationid: ctx.Locationid,
|
||||||
Status: models.OfflineSaleFailed,
|
Locationname: ctx.Locationname,
|
||||||
Message: fmt.Sprintf(format, args...),
|
Billno: billLabel,
|
||||||
|
Status: models.OfflineSaleFailed,
|
||||||
|
Message: fmt.Sprintf(format, args...),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1855,9 +1923,11 @@ func (r *orderRepository) importOfflineBill(
|
|||||||
if already > 0 {
|
if already > 0 {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
return models.OfflineSaleResult{
|
return models.OfflineSaleResult{
|
||||||
Billno: billLabel,
|
Locationid: ctx.Locationid,
|
||||||
Status: models.OfflineSaleDuplicate,
|
Locationname: ctx.Locationname,
|
||||||
Message: fmt.Sprintf("bill %s was already imported for this outlet; stock was not deducted again", billLabel),
|
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{
|
return models.OfflineSaleResult{
|
||||||
|
Locationid: ctx.Locationid,
|
||||||
|
Locationname: ctx.Locationname,
|
||||||
Billno: billLabel,
|
Billno: billLabel,
|
||||||
Status: models.OfflineSaleImported,
|
Status: models.OfflineSaleImported,
|
||||||
Orderid: created.Orderid,
|
Orderid: created.Orderid,
|
||||||
Orderheaderid: created.Orderheaderid,
|
Orderheaderid: created.Orderheaderid,
|
||||||
Itemcount: len(items),
|
Itemcount: len(items),
|
||||||
Amount: orderAmount,
|
Amount: orderAmount,
|
||||||
Message: fmt.Sprintf("imported as order %s", created.Orderid),
|
Message: fmt.Sprintf("imported as order %s at %s", created.Orderid, ctx.Locationname),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -503,44 +503,63 @@ func (r *productRepository) GetLocationProducts(tenantID, locationID, subcategor
|
|||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSaleTemplate lists every product stocked at one outlet, with its live
|
// GetSaleTemplate lists products stocked at a tenant's branches, with each
|
||||||
// ledger balance, so the web app can generate a pre-filled offline-sales
|
// one's live ledger balance, so the web app can generate a pre-filled
|
||||||
// spreadsheet. It deliberately returns the whole catalogue for the outlet
|
// offline-sales spreadsheet.
|
||||||
// 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.
|
// 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
|
// 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
|
// against, so the "currentstock" read in the sheet is exactly the number the
|
||||||
// number the import will later check their quantity against. LOWER() covers
|
// import will check the typed quantity against. LOWER() covers the mixed-case
|
||||||
// the mixed-case stocktype values in production ('out', 'IN', 'in').
|
// stocktype values in production ('out', 'IN', 'in').
|
||||||
//
|
//
|
||||||
// A location that does not belong to the tenant yields no template rather than
|
// The INNER JOIN on tenantlocations is load-bearing: it confines the result to
|
||||||
// another tenant's catalogue: the caller treats that as "not your outlet".
|
// 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) {
|
func (r *productRepository) GetSaleTemplate(tenantID, locationID int) (*models.SaleTemplate, error) {
|
||||||
if tenantID <= 0 || locationID <= 0 {
|
if tenantID <= 0 {
|
||||||
return nil, errors.New("tenantid and locationid are required")
|
return nil, errors.New("tenantid is required")
|
||||||
|
}
|
||||||
|
if locationID < 0 {
|
||||||
|
locationID = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
var loc struct {
|
// Only checked when the caller narrowed to one branch. Without it a
|
||||||
Locationname string
|
// mistyped locationid would silently yield an empty template rather than
|
||||||
}
|
// saying the outlet is not theirs.
|
||||||
err := r.db.Raw(
|
if locationID > 0 {
|
||||||
`SELECT locationname FROM tenantlocations WHERE tenantid = ? AND locationid = ?`,
|
var locationName string
|
||||||
tenantID, locationID,
|
err := r.db.Raw(
|
||||||
).Scan(&loc).Error
|
`SELECT COALESCE(locationname, '') FROM tenantlocations WHERE tenantid = ? AND locationid = ?`,
|
||||||
if err != nil {
|
tenantID, locationID,
|
||||||
return nil, err
|
).Scan(&locationName).Error
|
||||||
}
|
if err != nil {
|
||||||
if strings.TrimSpace(loc.Locationname) == "" {
|
return nil, err
|
||||||
return nil, fmt.Errorf("location %d does not belong to tenant %d", locationID, tenantID)
|
}
|
||||||
|
if strings.TrimSpace(locationName) == "" {
|
||||||
|
return nil, fmt.Errorf("location %d does not belong to tenant %d", locationID, tenantID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rows := make([]models.SaleTemplateRow, 0)
|
rows := make([]models.SaleTemplateRow, 0)
|
||||||
query := `
|
query := `
|
||||||
SELECT a.productid,
|
SELECT a.tenantid,
|
||||||
|
b.locationid,
|
||||||
|
COALESCE(tl.locationname, '') AS locationname,
|
||||||
|
a.productid,
|
||||||
a.productname,
|
a.productname,
|
||||||
COALESCE(a.productunit, '') AS productunit,
|
COALESCE(a.productunit, '') AS productunit,
|
||||||
COALESCE(a.unitvalue, '') AS unitvalue,
|
COALESCE(a.unitvalue, '') AS unitvalue,
|
||||||
COALESCE(d.categoryname, '') AS categoryname,
|
COALESCE(d.categoryname, '') AS categoryname,
|
||||||
COALESCE(SUM(CASE WHEN LOWER(c.stocktype) = 'in' THEN c.quantity ELSE 0 END) -
|
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,
|
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
|
COALESCE(a.taxpercent, 0) AS taxpercent
|
||||||
FROM products a
|
FROM products a
|
||||||
INNER JOIN productlocations b ON a.productid = b.productid AND a.tenantid = b.tenantid
|
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
|
LEFT JOIN productstocks c
|
||||||
ON a.productid = c.productid AND b.locationid = c.locationid AND a.tenantid = c.tenantid
|
ON a.productid = c.productid AND b.locationid = c.locationid AND a.tenantid = c.tenantid
|
||||||
LEFT JOIN productcategories d ON a.categoryid = d.categoryid
|
LEFT JOIN productcategories d ON a.categoryid = d.categoryid
|
||||||
WHERE a.approve = 1 AND a.tenantid = ? AND b.locationid = ?
|
WHERE a.approve = 1 AND a.tenantid = ? AND (? = 0 OR b.locationid = ?)
|
||||||
GROUP BY a.productid, a.productname, a.productunit, a.unitvalue, d.categoryname,
|
GROUP BY a.tenantid, b.locationid, tl.locationname, a.productid, a.productname,
|
||||||
b.price, a.retailprice, a.taxpercent
|
a.productunit, a.unitvalue, d.categoryname, b.price, a.retailprice, a.taxpercent
|
||||||
ORDER BY a.productname ASC`
|
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
|
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{
|
return &models.SaleTemplate{
|
||||||
Tenantid: tenantID,
|
Tenantid: tenantID,
|
||||||
Locationid: locationID,
|
Locationid: locationID,
|
||||||
Locationname: strings.TrimSpace(loc.Locationname),
|
Locations: locations,
|
||||||
Products: rows,
|
Products: rows,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user