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{
|
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,14 +358,23 @@ 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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -188,8 +188,19 @@ func (r *customerRepository) GetTenantCustomers(tid, lid, pageno, pagesize int,
|
|||||||
var args []interface{}
|
var args []interface{}
|
||||||
searchLike := "%" + keyword + "%"
|
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 {
|
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.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
|
FROM customers a
|
||||||
@@ -204,11 +215,15 @@ func (r *customerRepository) GetTenantCustomers(tid, lid, pageno, pagesize int,
|
|||||||
args = append(args, searchLike, searchLike, searchLike)
|
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)
|
args = append(args, pagesize, offset)
|
||||||
|
|
||||||
} else {
|
} 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.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
|
FROM customers a
|
||||||
@@ -223,12 +238,10 @@ func (r *customerRepository) GetTenantCustomers(tid, lid, pageno, pagesize int,
|
|||||||
args = append(args, searchLike, searchLike, searchLike)
|
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)
|
args = append(args, pagesize, offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
print(q1)
|
|
||||||
|
|
||||||
r.db.Raw(q1, args...).Find(&data)
|
r.db.Raw(q1, args...).Find(&data)
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
package repositories
|
package repositories
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"nearle/models"
|
"nearle/models"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/jinzhu/copier"
|
"github.com/jinzhu/copier"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -147,18 +149,75 @@ func (r *deliveriesRepository) UpdateDelivery(data models.UpdateDeliveryStatus)
|
|||||||
var ord models.Updateorderstatus
|
var ord models.Updateorderstatus
|
||||||
var cloc models.Customerlocations
|
var cloc models.Customerlocations
|
||||||
|
|
||||||
|
if data.Deliveryid == 0 {
|
||||||
|
return errors.New("deliveryid is required")
|
||||||
|
}
|
||||||
|
|
||||||
tx := r.db.Begin()
|
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 {
|
if err := tx.Table("deliveries").Where("deliveryid = ?", data.Deliveryid).Updates(&data).Error; err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
return err
|
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 {
|
switch data.Orderstatus {
|
||||||
case "pending":
|
case "pending":
|
||||||
ord.Orderstatus = data.Orderstatus
|
ord.Orderstatus = data.Orderstatus
|
||||||
ord.Pending = data.Assigntime
|
ord.Pending = stamp(data.Assigntime)
|
||||||
if err := tx.Table("orders").Where("orderheaderid = ?", data.Orderheaderid).Updates(&ord).Error; err != nil {
|
if err := syncOrder(); err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -181,8 +240,8 @@ func (r *deliveriesRepository) UpdateDelivery(data models.UpdateDeliveryStatus)
|
|||||||
|
|
||||||
case "delivered":
|
case "delivered":
|
||||||
ord.Orderstatus = data.Orderstatus
|
ord.Orderstatus = data.Orderstatus
|
||||||
ord.Delivered = data.Deliverytime
|
ord.Delivered = stamp(data.Deliverytime)
|
||||||
if err := tx.Table("orders").Where("orderheaderid = ?", data.Orderheaderid).Updates(&ord).Error; err != nil {
|
if err := syncOrder(); err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -204,8 +263,8 @@ func (r *deliveriesRepository) UpdateDelivery(data models.UpdateDeliveryStatus)
|
|||||||
|
|
||||||
case "cancelled":
|
case "cancelled":
|
||||||
ord.Orderstatus = data.Orderstatus
|
ord.Orderstatus = data.Orderstatus
|
||||||
ord.Cancelled = data.Canceltime
|
ord.Cancelled = stamp(data.Canceltime)
|
||||||
if err := tx.Table("orders").Where("orderheaderid = ?", data.Orderheaderid).Updates(&ord).Error; err != nil {
|
if err := syncOrder(); err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
return err
|
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
|
// 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
|
||||||
|
// 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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
products, err := r.loadOfflineProducts(input.Tenantid, input.Locationid)
|
// 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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
products, err := r.loadOfflineProducts(input.Tenantid, locationID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
if len(products) == 0 {
|
if len(products) == 0 {
|
||||||
return nil, fmt.Errorf("outlet '%s' has no products stocked against it", ctx.Locationname)
|
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++
|
||||||
@@ -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
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1836,6 +1902,8 @@ 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{
|
||||||
|
Locationid: ctx.Locationid,
|
||||||
|
Locationname: ctx.Locationname,
|
||||||
Billno: billLabel,
|
Billno: billLabel,
|
||||||
Status: models.OfflineSaleFailed,
|
Status: models.OfflineSaleFailed,
|
||||||
Message: fmt.Sprintf(format, args...),
|
Message: fmt.Sprintf(format, args...),
|
||||||
@@ -1968,9 +2036,11 @@ func (r *orderRepository) importOfflineBill(
|
|||||||
if already > 0 {
|
if already > 0 {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
return models.OfflineSaleResult{
|
return models.OfflineSaleResult{
|
||||||
|
Locationid: ctx.Locationid,
|
||||||
|
Locationname: ctx.Locationname,
|
||||||
Billno: billLabel,
|
Billno: billLabel,
|
||||||
Status: models.OfflineSaleDuplicate,
|
Status: models.OfflineSaleDuplicate,
|
||||||
Message: fmt.Sprintf("bill %s was already imported for this outlet; stock was not deducted again", billLabel),
|
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{
|
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,41 +503,60 @@ 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.
|
||||||
|
if locationID > 0 {
|
||||||
|
var locationName string
|
||||||
err := r.db.Raw(
|
err := r.db.Raw(
|
||||||
`SELECT locationname FROM tenantlocations WHERE tenantid = ? AND locationid = ?`,
|
`SELECT COALESCE(locationname, '') FROM tenantlocations WHERE tenantid = ? AND locationid = ?`,
|
||||||
tenantID, locationID,
|
tenantID, locationID,
|
||||||
).Scan(&loc).Error
|
).Scan(&locationName).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(loc.Locationname) == "" {
|
if strings.TrimSpace(locationName) == "" {
|
||||||
return nil, fmt.Errorf("location %d does not belong to tenant %d", locationID, tenantID)
|
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,
|
||||||
@@ -548,22 +567,40 @@ 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ func RegisterUtilsRoutes(api fiber.Router, f *facade.Facade) {
|
|||||||
utils := api.Group("/v1/web/utils")
|
utils := api.Group("/v1/web/utils")
|
||||||
|
|
||||||
utils.Get("/getapptypes", f.UtilsController.GetAppTypes)
|
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("/getsubcategories", f.UtilsController.GetSubcategories)
|
||||||
utils.Get("/getapplocations", f.UtilsController.GetApplocations)
|
utils.Get("/getapplocations", f.UtilsController.GetApplocations)
|
||||||
utils.Get("/getappcategories", f.UtilsController.GetAppCategory)
|
utils.Get("/getappcategories", f.UtilsController.GetAppCategory)
|
||||||
|
|||||||
Reference in New Issue
Block a user