Merge remote-tracking branch 'origin/main'
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{
|
||||
"code": http.StatusBadRequest,
|
||||
"message": "tenantid and locationid are required",
|
||||
"message": "tenantid is required",
|
||||
"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
|
||||
// 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",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -188,8 +188,19 @@ 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
|
||||
FROM customers a
|
||||
@@ -204,11 +215,15 @@ 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
|
||||
FROM customers a
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user