product price
This commit is contained in:
@@ -77,8 +77,16 @@ type Products struct {
|
||||
Productcombo int `json:"productcombo" gorm:"default:0"`
|
||||
Variants int `json:"variants" gorm:"default:0"`
|
||||
Quantity int `json:"quantity"`
|
||||
Retailprice float64 `json:"retailprice,omitempty"`
|
||||
Diffprice float64 `json:"diffprice,omitempty"`
|
||||
// Price is the EFFECTIVE selling price at the location a query was scoped
|
||||
// to: productlocations.price when the store has set one, otherwise the
|
||||
// master Retailprice below. Read-only — it is computed by the query, never
|
||||
// written through this struct. Location-scoped endpoints must expose it, or
|
||||
// a price the admin sets per store can never reach the customer app: they
|
||||
// returned only Retailprice, which the admin catalogue never writes.
|
||||
// Same meaning as Locationproducts.Price, so both product feeds agree.
|
||||
Price float64 `json:"price" gorm:"->"`
|
||||
Retailprice float64 `json:"retailprice,omitempty"`
|
||||
Diffprice float64 `json:"diffprice,omitempty"`
|
||||
Diffpercent float64 `json:"diffpercent,omitempty"`
|
||||
Othercost float64 `json:"othercost,omitempty"`
|
||||
Approve int `json:"approve"`
|
||||
|
||||
@@ -1383,6 +1383,110 @@ func (r *orderRepository) reloadOrder(orderHeaderID int) (models.Orders, error)
|
||||
// use tx again. On success tx is left open and uncommitted, so the caller can
|
||||
// include its own work — a duplicate-bill guard, an advisory lock — in the same
|
||||
// transaction as the order that work protects.
|
||||
// priceOrderLines fills in any line the client sent without a price, using the
|
||||
// merchant's own catalogue, and brings the header totals in line with the
|
||||
// result. It mutates data in place and is a no-op for an order that already
|
||||
// arrived fully priced.
|
||||
//
|
||||
// The arithmetic deliberately matches the offline-sales import exactly — gross,
|
||||
// minus discount, with tax extracted from the resulting landing amount because
|
||||
// shelf prices here are MRP (tax already inside). One convention for both
|
||||
// channels, so the same basket rings up the same either way.
|
||||
func (r *orderRepository) priceOrderLines(tx *gorm.DB, data *models.Orders, defaultLocID int) error {
|
||||
if len(data.Items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// One catalogue read per outlet, not per line. Items usually share an
|
||||
// outlet, but a line may name its own.
|
||||
catalogues := make(map[int]map[int]offlineProduct)
|
||||
catalogueFor := func(locationID int) (map[int]offlineProduct, error) {
|
||||
if c, ok := catalogues[locationID]; ok {
|
||||
return c, nil
|
||||
}
|
||||
c, err := loadCatalogueProducts(tx, data.Tenantid, locationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
catalogues[locationID] = c
|
||||
return c, nil
|
||||
}
|
||||
|
||||
var lineTotal, taxTotal float64
|
||||
|
||||
for i := range data.Items {
|
||||
item := &data.Items[i]
|
||||
|
||||
itemLocID := item.Locationid
|
||||
if itemLocID == 0 {
|
||||
itemLocID = defaultLocID
|
||||
}
|
||||
|
||||
if item.Price <= 0 {
|
||||
catalogue, err := catalogueFor(itemLocID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// A miss can't normally happen — the stock check above already
|
||||
// proved the product is stocked here. If it somehow does, leave the
|
||||
// line as the client sent it rather than refusing the order: a
|
||||
// pricing lookup is not a reason to block a customer's checkout.
|
||||
if product, ok := catalogue[item.Productid]; ok {
|
||||
item.Price = product.Price
|
||||
if item.Taxpercentage <= 0 {
|
||||
item.Taxpercentage = product.Taxpercent
|
||||
}
|
||||
if item.Productname == "" {
|
||||
item.Productname = product.Productname
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
gross := item.Price * item.Orderqty
|
||||
discount := item.Discountamount
|
||||
if discount < 0 {
|
||||
discount = 0
|
||||
}
|
||||
if discount > gross {
|
||||
discount = gross
|
||||
}
|
||||
landing := gross - discount
|
||||
|
||||
// Only derive what the client didn't state, so a client that does its
|
||||
// own (possibly promotional) maths keeps its figures.
|
||||
if item.Productsumprice <= 0 {
|
||||
item.Productsumprice = gross
|
||||
}
|
||||
if item.Landingamount <= 0 {
|
||||
item.Landingamount = landing
|
||||
}
|
||||
if item.Taxamount <= 0 && item.Taxpercentage > 0 {
|
||||
item.Taxamount = landing - (landing / (1 + item.Taxpercentage/100))
|
||||
}
|
||||
|
||||
lineTotal += item.Landingamount
|
||||
taxTotal += item.Taxamount
|
||||
}
|
||||
|
||||
// Header totals are only derived when the client left them empty; an order
|
||||
// that states its own total (delivery charges, promotions applied basket-
|
||||
// wide) keeps it.
|
||||
if data.Orderamount <= 0 {
|
||||
data.Orderamount = float32(lineTotal)
|
||||
}
|
||||
if data.Ordervalue <= 0 {
|
||||
data.Ordervalue = float32(lineTotal)
|
||||
}
|
||||
if data.Taxamount <= 0 {
|
||||
data.Taxamount = float32(taxTotal)
|
||||
}
|
||||
if data.Itemcount <= 0 {
|
||||
data.Itemcount = len(data.Items)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *orderRepository) createOrderTx(tx *gorm.DB, data models.Orders) (models.Orders, error) {
|
||||
locID := data.Locationid
|
||||
if locID == 0 {
|
||||
@@ -1424,6 +1528,22 @@ func (r *orderRepository) createOrderTx(tx *gorm.DB, data models.Orders) (models
|
||||
return models.Orders{}, err
|
||||
}
|
||||
|
||||
// 🛠️ Step 1b: Price the lines the client left unpriced.
|
||||
//
|
||||
// Line prices arrive from the client, and a client that sends none books the
|
||||
// order at zero — which is exactly what happened to every catalogue-imported
|
||||
// product, whose per-store price was never set: real orders were written
|
||||
// with price 0 and orderamount 0, so a delivered sale recorded no revenue.
|
||||
//
|
||||
// Only lines the client left at or below zero are filled. A line that came
|
||||
// with a price keeps it, because variants, addons and promotions legitimately
|
||||
// charge something other than the shelf price and this is not the place to
|
||||
// second-guess them.
|
||||
if err := r.priceOrderLines(tx, &data, locID); err != nil {
|
||||
tx.Rollback()
|
||||
return models.Orders{}, err
|
||||
}
|
||||
|
||||
// 🛠️ Step 2: Create Order Header
|
||||
// Claimed inside tx so the row lock on the counter holds until commit:
|
||||
// concurrent orders queue for it instead of reading the same number, and a
|
||||
@@ -1632,8 +1752,21 @@ type offlineProduct struct {
|
||||
// way the line is refused — so a hand-edited productid cannot reach into a
|
||||
// catalogue the uploader has no claim on.
|
||||
func (r *orderRepository) loadOfflineProducts(tenantID, locationID int) (map[int]offlineProduct, error) {
|
||||
return loadCatalogueProducts(r.db, tenantID, locationID)
|
||||
}
|
||||
|
||||
// loadCatalogueProducts is the shared price/tax lookup: the merchant's own
|
||||
// selling price for every product stocked at one outlet, preferring the
|
||||
// per-store productlocations.price and falling back to the master
|
||||
// products.retailprice. Both order paths price from this one query so an online
|
||||
// order and a counter sale can never disagree about what a product costs.
|
||||
//
|
||||
// Takes its handle so a caller inside a transaction reads through that
|
||||
// transaction — createOrderTx has already locked these product rows, and
|
||||
// reading around the lock would defeat the point.
|
||||
func loadCatalogueProducts(db *gorm.DB, tenantID, locationID int) (map[int]offlineProduct, error) {
|
||||
rows := make([]offlineProduct, 0)
|
||||
err := r.db.Raw(`
|
||||
err := db.Raw(`
|
||||
SELECT a.productid,
|
||||
COALESCE(a.productname, '') AS productname,
|
||||
COALESCE(a.productunit, '') AS productunit,
|
||||
|
||||
@@ -465,7 +465,13 @@ func (r *productRepository) GetLocationProducts(tenantID, locationID, subcategor
|
||||
// into Locationproducts.Quantity, which is what made the console's stock
|
||||
// column look frozen after an order despite CreateOrder recording the
|
||||
// "out" ledger entry correctly.
|
||||
query := `SELECT a.*, b.productlocationid, b.status, b.price,
|
||||
// COALESCE so `price` means the same thing here as in GetProducts: the
|
||||
// effective selling price at this outlet, falling back to the master
|
||||
// retailprice when the store hasn't set its own. Returning a bare b.price
|
||||
// reported 0 for any product priced only at tenant level, which the store
|
||||
// catalogue then rendered as "—".
|
||||
query := `SELECT a.*, b.productlocationid, b.status,
|
||||
COALESCE(NULLIF(b.price, 0), a.retailprice, 0) AS price,
|
||||
COALESCE(SUM(CASE WHEN UPPER(c.stocktype) = 'IN' THEN c.quantity ELSE 0 END), 0) AS total_in,
|
||||
COALESCE(SUM(CASE WHEN UPPER(c.stocktype) = 'OUT' THEN c.quantity ELSE 0 END), 0) AS total_out,
|
||||
COALESCE(SUM(CASE WHEN UPPER(c.stocktype) = 'IN' THEN c.quantity ELSE 0 END) -
|
||||
@@ -842,7 +848,7 @@ func (r *productRepository) GetProducts(params models.ProductFilter) ([]models.P
|
||||
var products []models.Products
|
||||
|
||||
q := r.db.Table("products a").
|
||||
Joins("LEFT JOIN productlocations pl ON pl.productid = a.productid").
|
||||
Joins("LEFT JOIN productlocations pl ON pl.productid = a.productid AND pl.tenantid = a.tenantid").
|
||||
Joins("LEFT JOIN productdiscounts pd ON pd.productid = a.productid").
|
||||
Joins("LEFT JOIN productcategories c ON a.categoryid = c.categoryid").
|
||||
Where("a.categoryid = ?", params.CategoryID)
|
||||
@@ -875,9 +881,26 @@ func (r *productRepository) GetProducts(params models.ProductFilter) ([]models.P
|
||||
// never-decremented products.quantity column — same fix as
|
||||
// GetLocationProducts/GetProductByVariant, otherwise this endpoint would
|
||||
// keep showing stock that never reduces after an order.
|
||||
// price is the effective selling price at params.LocationID: the store's own
|
||||
// productlocations.price, falling back to the master products.retailprice
|
||||
// when that outlet hasn't set one. It has to be here — this endpoint feeds
|
||||
// the customer app's browse-by-subcategory view, and `a.*` only carries
|
||||
// retailprice, which the admin catalogue never writes. So a price the admin
|
||||
// set per store could never reach the app; every product priced as 0.
|
||||
//
|
||||
// Deliberately a correlated subquery rather than a read off the joined `pl`:
|
||||
// that join isn't outlet-scoped unless params.LocationID is set, so reading
|
||||
// pl.price directly would pick an arbitrary branch's price (and multiply the
|
||||
// rows) whenever the caller didn't scope to one. Same shape as the
|
||||
// productstock subqueries below, for the same reason.
|
||||
err := q.Select(`
|
||||
a.*,
|
||||
COALESCE(pd.discountvalue, 0) AS discountvalue,
|
||||
COALESCE(NULLIF((
|
||||
SELECT pl2.price FROM productlocations pl2
|
||||
WHERE pl2.productid = a.productid AND pl2.tenantid = a.tenantid AND pl2.locationid = ?
|
||||
LIMIT 1
|
||||
), 0), a.retailprice, 0) AS price,
|
||||
COALESCE((
|
||||
SELECT SUM(CASE WHEN LOWER(ps.stocktype) = 'in' THEN ps.quantity ELSE 0 END) -
|
||||
SUM(CASE WHEN LOWER(ps.stocktype) = 'out' THEN ps.quantity ELSE 0 END)
|
||||
@@ -890,7 +913,7 @@ func (r *productRepository) GetProducts(params models.ProductFilter) ([]models.P
|
||||
FROM productstocks ps
|
||||
WHERE ps.productid = a.productid AND ps.tenantid = a.tenantid AND ps.locationid = ?
|
||||
), 0) AS quantity
|
||||
`, params.LocationID, params.LocationID).Find(&products).Error
|
||||
`, params.LocationID, params.LocationID, params.LocationID).Find(&products).Error
|
||||
|
||||
return products, err
|
||||
}
|
||||
|
||||
@@ -293,6 +293,11 @@ func (s *productService) ImportCatalogueProduct(reqs []models.ImportCataloguePro
|
||||
}
|
||||
}
|
||||
|
||||
// Price carries the selling price onto the per-store row. Omitting it
|
||||
// left productlocations.price at 0 for every imported product, and that
|
||||
// column — not products.retailprice — is what the store catalogue, the
|
||||
// customer app and each order line read. The result was a catalogue
|
||||
// where nothing had a price and every order booked an amount of 0.
|
||||
locations = append(locations, models.Productlocations{
|
||||
Tenantid: req.Tenantid,
|
||||
Locationid: req.Locationid,
|
||||
@@ -300,6 +305,7 @@ func (s *productService) ImportCatalogueProduct(reqs []models.ImportCataloguePro
|
||||
Quantity: req.Quantity,
|
||||
Stocktype: req.Stocktype,
|
||||
Status: req.Status,
|
||||
Price: float32(req.Retailprice),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user