product price

This commit is contained in:
2026-08-04 10:58:26 +05:30
parent 8aa4d86eb6
commit bddd8fa265
4 changed files with 176 additions and 6 deletions

View File

@@ -77,8 +77,16 @@ type Products struct {
Productcombo int `json:"productcombo" gorm:"default:0"` Productcombo int `json:"productcombo" gorm:"default:0"`
Variants int `json:"variants" gorm:"default:0"` Variants int `json:"variants" gorm:"default:0"`
Quantity int `json:"quantity"` Quantity int `json:"quantity"`
Retailprice float64 `json:"retailprice,omitempty"` // Price is the EFFECTIVE selling price at the location a query was scoped
Diffprice float64 `json:"diffprice,omitempty"` // 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"` Diffpercent float64 `json:"diffpercent,omitempty"`
Othercost float64 `json:"othercost,omitempty"` Othercost float64 `json:"othercost,omitempty"`
Approve int `json:"approve"` Approve int `json:"approve"`

View File

@@ -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 // 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 // include its own work — a duplicate-bill guard, an advisory lock — in the same
// transaction as the order that work protects. // 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) { func (r *orderRepository) createOrderTx(tx *gorm.DB, data models.Orders) (models.Orders, error) {
locID := data.Locationid locID := data.Locationid
if locID == 0 { if locID == 0 {
@@ -1424,6 +1528,22 @@ func (r *orderRepository) createOrderTx(tx *gorm.DB, data models.Orders) (models
return models.Orders{}, err 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 // 🛠️ Step 2: Create Order Header
// Claimed inside tx so the row lock on the counter holds until commit: // 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 // 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 // way the line is refused — so a hand-edited productid cannot reach into a
// catalogue the uploader has no claim on. // catalogue the uploader has no claim on.
func (r *orderRepository) loadOfflineProducts(tenantID, locationID int) (map[int]offlineProduct, error) { 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) rows := make([]offlineProduct, 0)
err := r.db.Raw(` err := db.Raw(`
SELECT a.productid, SELECT a.productid,
COALESCE(a.productname, '') AS productname, COALESCE(a.productname, '') AS productname,
COALESCE(a.productunit, '') AS productunit, COALESCE(a.productunit, '') AS productunit,

View File

@@ -465,7 +465,13 @@ func (r *productRepository) GetLocationProducts(tenantID, locationID, subcategor
// into Locationproducts.Quantity, which is what made the console's stock // into Locationproducts.Quantity, which is what made the console's stock
// column look frozen after an order despite CreateOrder recording the // column look frozen after an order despite CreateOrder recording the
// "out" ledger entry correctly. // "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) = '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) = '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) - 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 var products []models.Products
q := r.db.Table("products a"). 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 productdiscounts pd ON pd.productid = a.productid").
Joins("LEFT JOIN productcategories c ON a.categoryid = c.categoryid"). Joins("LEFT JOIN productcategories c ON a.categoryid = c.categoryid").
Where("a.categoryid = ?", params.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 // never-decremented products.quantity column — same fix as
// GetLocationProducts/GetProductByVariant, otherwise this endpoint would // GetLocationProducts/GetProductByVariant, otherwise this endpoint would
// keep showing stock that never reduces after an order. // 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(` err := q.Select(`
a.*, a.*,
COALESCE(pd.discountvalue, 0) AS discountvalue, 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(( COALESCE((
SELECT SUM(CASE WHEN LOWER(ps.stocktype) = 'in' THEN ps.quantity ELSE 0 END) - 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) 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 FROM productstocks ps
WHERE ps.productid = a.productid AND ps.tenantid = a.tenantid AND ps.locationid = ? WHERE ps.productid = a.productid AND ps.tenantid = a.tenantid AND ps.locationid = ?
), 0) AS quantity ), 0) AS quantity
`, params.LocationID, params.LocationID).Find(&products).Error `, params.LocationID, params.LocationID, params.LocationID).Find(&products).Error
return products, err return products, err
} }

View File

@@ -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{ locations = append(locations, models.Productlocations{
Tenantid: req.Tenantid, Tenantid: req.Tenantid,
Locationid: req.Locationid, Locationid: req.Locationid,
@@ -300,6 +305,7 @@ func (s *productService) ImportCatalogueProduct(reqs []models.ImportCataloguePro
Quantity: req.Quantity, Quantity: req.Quantity,
Stocktype: req.Stocktype, Stocktype: req.Stocktype,
Status: req.Status, Status: req.Status,
Price: float32(req.Retailprice),
}) })
} }