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

@@ -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,