diff --git a/controllers/orderController.go b/controllers/orderController.go index fa074fc..e4ce87a 100644 --- a/controllers/orderController.go +++ b/controllers/orderController.go @@ -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{ "code": http.StatusBadRequest, - "message": "tenantid and locationid are required", + "message": "tenantid is required", "status": false, }) } diff --git a/controllers/productController.go b/controllers/productController.go index 09b03b1..b8acdd8 100644 --- a/controllers/productController.go +++ b/controllers/productController.go @@ -310,19 +310,21 @@ func (ctl *ProductController) GetLocationProducts(c *fiber.Ctx) error { } // GetSaleTemplate serves the data the web app turns into the offline-sales -// spreadsheet. Both tenantid and locationid are required rather than defaulted: -// a template generated against the wrong outlet would carry productids the -// import then rejects, which is a confusing failure a missing parameter should -// not be able to cause. +// spreadsheet. +// +// locationid is optional and defaults to 0, meaning every branch the tenant +// 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 { 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{ "status": false, "code": http.StatusBadRequest, - "message": "tenantid and locationid are required", + "message": "tenantid is required", }) } diff --git a/models/order.go b/models/order.go index 22ae0d2..566eecb 100644 --- a/models/order.go +++ b/models/order.go @@ -460,9 +460,18 @@ type OfflineSaleItem struct { } // 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 -// recorded on the order and refused if it is already present for this outlet. +// their branch and bill number. +// +// 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 { + Locationid int `json:"locationid"` Billno string `json:"billno"` Saledate string `json:"saledate"` Paymentmode string `json:"paymentmode"` @@ -472,9 +481,16 @@ type OfflineSaleBill struct { Items []OfflineSaleItem `json:"items"` } -// OfflineSalesUpload is the request body. Locationid is the outlet the sales -// belong to and is authorised server-side against Tenantid — a store user -// editing the spreadsheet cannot post sales into another branch. +// OfflineSalesUpload is the request body. +// +// 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 { Tenantid int `json:"tenantid"` Locationid int `json:"locationid"` @@ -492,7 +508,11 @@ const ( // 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. +// 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 { + Locationid int `json:"locationid"` + Locationname string `json:"locationname"` Billno string `json:"billno"` Status string `json:"status"` Orderid string `json:"orderid"` diff --git a/models/product.go b/models/product.go index 6114127..661937b 100644 --- a/models/product.go +++ b/models/product.go @@ -327,19 +327,27 @@ type ProductLocationRef struct { Productid int } -// SaleTemplateRow is one line of the downloadable offline-sales spreadsheet: -// a product actually stocked at one outlet, with the numbers the person at the -// till needs to see before they type a sold quantity against it. +// SaleTemplateRow is one line of the downloadable offline-sales spreadsheet: a +// product stocked at one branch, with the numbers the person at the till needs +// 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 // 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 -// blank, so a sku is not a key. Productname is nearly unique per tenant but -// not reliably ("rice" appears 6 times for one tenant), so it travels as a +// blank, so a sku is not a key. Productname is nearly unique per tenant but not +// 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 // 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 { + Tenantid int `json:"tenantid"` + Locationid int `json:"locationid"` + Locationname string `json:"locationname"` Productid int `json:"productid"` Productname string `json:"productname"` Productunit string `json:"productunit"` @@ -350,15 +358,24 @@ type SaleTemplateRow struct { Taxpercent float64 `json:"taxpercent"` } -// SaleTemplate is the payload the web app turns into an .xlsx workbook. The -// tenant/location identity travels with it so the generated file records which -// outlet it belongs to, and the upload can be checked against the file it came -// from instead of trusting a hand-typed location. +// SaleTemplateLocation is one branch covered by the workbook, so the sheet can +// list what it spans and the UI can summarise it without walking every row. +type SaleTemplateLocation struct { + 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 { - Tenantid int `json:"tenantid"` - Locationid int `json:"locationid"` - Locationname string `json:"locationname"` - Products []SaleTemplateRow `json:"products"` + Tenantid int `json:"tenantid"` + Locationid int `json:"locationid"` + Locations []SaleTemplateLocation `json:"locations"` + Products []SaleTemplateRow `json:"products"` } type ProductSubcategory struct { diff --git a/repositories/customerRepository.go b/repositories/customerRepository.go index 9d4fc08..2182c49 100644 --- a/repositories/customerRepository.go +++ b/repositories/customerRepository.go @@ -188,10 +188,21 @@ func (r *customerRepository) GetTenantCustomers(tid, lid, pageno, pagesize int, var args []interface{} searchLike := "%" + keyword + "%" + // DISTINCT ON collapses to one row per customer BEFORE the LIMIT is applied. + // Without it the store-scoped branch below paginated the joined + // customerlocations rows — one per saved address — so `pagesize` bought a + // page of addresses, not of customers. Live example: locationid 1185 returned + // 12 rows that were only 2 people, 11 of them one customer's addresses. A + // store with a page size of 20 therefore listed roughly three customers and + // gave no hint that the rest existed. + // + // The ORDER BY must lead with the DISTINCT ON expression, so customerid sorts + // first; the trailing keys only decide WHICH address represents a customer, + // preferring the one flagged primary. if lid != 0 { - q1 = `SELECT a.customerid,a.firstname,a.lastname,a.contactno,a.email, + q1 = `SELECT DISTINCT ON (a.customerid) a.customerid,a.firstname,a.lastname,a.contactno,a.email, b.locationid as deliverylocationid,b.address,b.suburb,b.city,b.state,b.landmark,b.doorno,b.postcode, - b.latitude,b.longitude,a.applocationid,c.locationid as tenantlocationid,a.status + b.latitude,b.longitude,a.applocationid,c.locationid as tenantlocationid,a.status FROM customers a LEFT JOIN customerlocations b ON a.customerid=b.customerid INNER JOIN tenantcustomers c ON a.customerid=c.customerid @@ -204,13 +215,17 @@ func (r *customerRepository) GetTenantCustomers(tid, lid, pageno, pagesize int, args = append(args, searchLike, searchLike, searchLike) } - q1 += ` ORDER BY a.customerid DESC LIMIT ? OFFSET ?` + q1 += ` ORDER BY a.customerid DESC, b.primaryaddress DESC NULLS LAST, b.locationid ASC + LIMIT ? OFFSET ?` args = append(args, pagesize, offset) } else { - q1 = `SELECT a.customerid,a.firstname,a.lastname,a.contactno,a.email, + // A customer linked to several outlets of the same tenant has one + // tenantcustomers row per outlet, so this branch double-counted them + // against the LIMIT too. + q1 = `SELECT DISTINCT ON (a.customerid) a.customerid,a.firstname,a.lastname,a.contactno,a.email, a.address,a.suburb,a.city,a.state,a.landmark,a.doorno,a.postcode, - a.latitude,a.longitude,a.applocationid,c.locationid as tenantlocationid,a.status + a.latitude,a.longitude,a.applocationid,c.locationid as tenantlocationid,a.status FROM customers a INNER JOIN tenantcustomers c ON a.customerid=c.customerid WHERE c.tenantid = ?` @@ -223,12 +238,10 @@ func (r *customerRepository) GetTenantCustomers(tid, lid, pageno, pagesize int, args = append(args, searchLike, searchLike, searchLike) } - q1 += ` ORDER BY a.customerid DESC LIMIT ? OFFSET ?` + q1 += ` ORDER BY a.customerid DESC, c.locationid ASC LIMIT ? OFFSET ?` args = append(args, pagesize, offset) } - print(q1) - r.db.Raw(q1, args...).Find(&data) return data } diff --git a/repositories/deliveriesRepository.go b/repositories/deliveriesRepository.go index 8f79c5d..4515bc7 100644 --- a/repositories/deliveriesRepository.go +++ b/repositories/deliveriesRepository.go @@ -1,11 +1,13 @@ package repositories import ( + "errors" "fmt" "log" "nearle/models" "strconv" "strings" + "time" "github.com/jinzhu/copier" "gorm.io/gorm" @@ -147,18 +149,75 @@ func (r *deliveriesRepository) UpdateDelivery(data models.UpdateDeliveryStatus) var ord models.Updateorderstatus var cloc models.Customerlocations + if data.Deliveryid == 0 { + return errors.New("deliveryid is required") + } + tx := r.db.Begin() + if tx.Error != nil { + return tx.Error + } if err := tx.Table("deliveries").Where("deliveryid = ?", data.Deliveryid).Updates(&data).Error; err != nil { tx.Rollback() return err } + // The parent order is resolved from the delivery row rather than taken from + // the request. Every status branch below writes the order with + // "WHERE orderheaderid = ?", and a client that omits orderheaderid made that + // "WHERE orderheaderid = 0", matching nothing. GORM reports no error for an + // update that affects no rows, so the handler still answered 201 Success + // while the order silently kept its old status — 635 deliveries are marked + // delivered against an order still reading pending because of this. + // + // deliveryid is the one field every caller must send (it is how the row + // above is found), so deriving the link from it makes the sync independent + // of how complete the client's payload is. + orderHeaderID := data.Orderheaderid + if orderHeaderID == 0 { + if err := tx.Table("deliveries"). + Select("orderheaderid"). + Where("deliveryid = ?", data.Deliveryid). + Scan(&orderHeaderID).Error; err != nil { + tx.Rollback() + return err + } + } + if orderHeaderID == 0 { + tx.Rollback() + return fmt.Errorf("delivery %d has no order attached", data.Deliveryid) + } + + // syncOrder applies the status to the parent order and fails loudly if the + // row is not there, instead of reporting success for a write that landed + // nowhere. + syncOrder := func() error { + res := tx.Table("orders").Where("orderheaderid = ?", orderHeaderID).Updates(&ord) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return fmt.Errorf("order %d not found for delivery %d", orderHeaderID, data.Deliveryid) + } + return nil + } + + // The lifecycle timestamp mirrored onto the order. Clients frequently send + // the status without one, and because Updates() skips zero-valued struct + // fields the order's own column was left blank while its status moved on. + stamp := func(supplied string) string { + if strings.TrimSpace(supplied) != "" { + return supplied + } + return time.Now().Format("2006-01-02 15:04:05") + } + switch data.Orderstatus { case "pending": ord.Orderstatus = data.Orderstatus - ord.Pending = data.Assigntime - if err := tx.Table("orders").Where("orderheaderid = ?", data.Orderheaderid).Updates(&ord).Error; err != nil { + ord.Pending = stamp(data.Assigntime) + if err := syncOrder(); err != nil { tx.Rollback() return err } @@ -181,8 +240,8 @@ func (r *deliveriesRepository) UpdateDelivery(data models.UpdateDeliveryStatus) case "delivered": ord.Orderstatus = data.Orderstatus - ord.Delivered = data.Deliverytime - if err := tx.Table("orders").Where("orderheaderid = ?", data.Orderheaderid).Updates(&ord).Error; err != nil { + ord.Delivered = stamp(data.Deliverytime) + if err := syncOrder(); err != nil { tx.Rollback() return err } @@ -204,8 +263,8 @@ func (r *deliveriesRepository) UpdateDelivery(data models.UpdateDeliveryStatus) case "cancelled": ord.Orderstatus = data.Orderstatus - ord.Cancelled = data.Canceltime - if err := tx.Table("orders").Where("orderheaderid = ?", data.Orderheaderid).Updates(&ord).Error; err != nil { + ord.Cancelled = stamp(data.Canceltime) + if err := syncOrder(); err != nil { tx.Rollback() return err } diff --git a/repositories/orderRepository.go b/repositories/orderRepository.go index 7497dd5..c668b74 100644 --- a/repositories/orderRepository.go +++ b/repositories/orderRepository.go @@ -1785,29 +1785,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++ @@ -1819,6 +1842,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 } @@ -1836,9 +1902,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...), } } @@ -1968,9 +2036,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), } } @@ -2021,13 +2091,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), } } diff --git a/repositories/productRepository.go b/repositories/productRepository.go index c1373c8..57c29f8 100644 --- a/repositories/productRepository.go +++ b/repositories/productRepository.go @@ -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 } diff --git a/routes/utilsroutes.go b/routes/utilsroutes.go index fe20429..5ebeaa1 100644 --- a/routes/utilsroutes.go +++ b/routes/utilsroutes.go @@ -11,7 +11,12 @@ func RegisterUtilsRoutes(api fiber.Router, f *facade.Facade) { utils := api.Group("/v1/web/utils") utils.Get("/getapptypes", f.UtilsController.GetAppTypes) - // utils.Post("/notifyuser", f.UtilsController.NotifyUser) + // Commented out since the initial commit, which meant every rider push the + // admin console has ever sent returned 404 — riders were assigned deliveries + // and never told. The handler, the FcmNotification model, the Firebase + // service account and the Dockerfile COPY that puts it in the image were all + // already in place; only the route was missing. + utils.Post("/notifyuser", f.UtilsController.NotifyUser) utils.Get("/getsubcategories", f.UtilsController.GetSubcategories) utils.Get("/getapplocations", f.UtilsController.GetApplocations) utils.Get("/getappcategories", f.UtilsController.GetAppCategory)