diff --git a/models/product.go b/models/product.go index 661937b..7cf4f8a 100644 --- a/models/product.go +++ b/models/product.go @@ -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"` diff --git a/repositories/orderRepository.go b/repositories/orderRepository.go index c668b74..052e4f8 100644 --- a/repositories/orderRepository.go +++ b/repositories/orderRepository.go @@ -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, diff --git a/repositories/productRepository.go b/repositories/productRepository.go index 57c29f8..d1de908 100644 --- a/repositories/productRepository.go +++ b/repositories/productRepository.go @@ -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 } diff --git a/services/productService.go b/services/productService.go index 3dec710..ce3d6d8 100644 --- a/services/productService.go +++ b/services/productService.go @@ -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), }) }