diff --git a/repositories/orderRepository.go b/repositories/orderRepository.go index 8f6c5bc..4490fe2 100644 --- a/repositories/orderRepository.go +++ b/repositories/orderRepository.go @@ -1007,6 +1007,36 @@ func (r *orderRepository) GetOrderDetails(orderHeaderID int) ([]models.OrderDeta return details, nil } +// syncProductLocationStatus re-derives productlocations.status for one +// product at one outlet from the live productstocks balance — "available" +// above zero, "outofstock" at or below it. It runs inside the caller's +// transaction so the flag is committed together with the ledger entry that +// moved it, and it is the same rule productRepository.SyncProductLocationStatus +// applies on the receiving side; both paths agreeing is what stops the flag +// drifting away from the ledger over time. +// +// Status errors are deliberately swallowed: an order must not fail because a +// display flag could not be refreshed, and the next ledger entry re-derives it. +func syncProductLocationStatus(tx *gorm.DB, tenantid, locationid, productid int) { + if tenantid <= 0 || locationid <= 0 || productid <= 0 { + return + } + if err := tx.Exec(` + UPDATE productlocations + SET status = CASE WHEN ( + SELECT COALESCE( + SUM(CASE WHEN LOWER(stocktype) = 'in' THEN quantity ELSE 0 END) - + SUM(CASE WHEN LOWER(stocktype) = 'out' THEN quantity ELSE 0 END), 0) + FROM productstocks + WHERE productid = ? AND tenantid = ? AND locationid = ? + ) > 0 THEN 'available' ELSE 'outofstock' END + WHERE tenantid = ? AND locationid = ? AND productid = ?`, + productid, tenantid, locationid, + tenantid, locationid, productid).Error; err != nil { + log.Println("syncProductLocationStatus:", err) + } +} + func (r *orderRepository) UpdateOrder(order *models.Orders) error { tx := r.db.Begin() @@ -1043,10 +1073,11 @@ func (r *orderRepository) UpdateOrder(order *models.Orders) error { return err } - // Update productlocation status back to available if stock is restored - tx.Table("productlocations"). - Where("productid = ? AND tenantid = ? AND locationid = ?", item.Productid, existingOrder.Tenantid, itemLocID). - Update("status", "available") + // Re-derive availability from the restored balance rather + // than assuming "available": cancelling one line of a + // heavily oversold product can leave it still at or below + // zero, in which case it must stay flagged outofstock. + syncProductLocationStatus(tx, existingOrder.Tenantid, itemLocID, item.Productid) } } } @@ -1254,24 +1285,9 @@ func (r *orderRepository) CreateOrder(data models.Orders) (models.Orders, error) return models.Orders{}, err } - // Update productlocation status to 'outofstock' if remaining stock drops to 0 or below - var remainingStock int - remStockQuery := ` - SELECT COALESCE( - SUM(CASE WHEN LOWER(stocktype) = 'in' THEN quantity ELSE 0 END) - - SUM(CASE WHEN LOWER(stocktype) = 'out' THEN quantity ELSE 0 END), - 0 - ) - FROM productstocks - WHERE productid = ? AND tenantid = ? AND locationid = ? - ` - if err := tx.Raw(remStockQuery, item.Productid, data.Tenantid, itemLocID).Scan(&remainingStock).Error; err == nil { - if remainingStock <= 0 { - tx.Table("productlocations"). - Where("productid = ? AND tenantid = ? AND locationid = ?", item.Productid, data.Tenantid, itemLocID). - Update("status", "outofstock") - } - } + // Re-derive the location's availability flag from the ledger balance + // this "out" entry just produced. + syncProductLocationStatus(tx, data.Tenantid, itemLocID, item.Productid) } if err := r.updateSeqno(data.Tenantid, "ORD"); err != nil { diff --git a/repositories/productRepository.go b/repositories/productRepository.go index cabf4a2..63201b3 100644 --- a/repositories/productRepository.go +++ b/repositories/productRepository.go @@ -22,7 +22,7 @@ type ProductRepository interface { GetProductStocks(tenantID, locationID string) ([]models.Productstocks, error) CreateProductStock(stocks []models.Productstock) error UpdateProductStatus(productIDs []int, status string) error - ReactivateProductLocations(refs []models.ProductLocationRef) error + SyncProductLocationStatus(refs []models.ProductLocationRef) error CreateProduct(product models.Products) error UpdateProduct(product models.Products) error DeleteProduct(productID int) error @@ -222,16 +222,27 @@ func (r *productRepository) GetProductStocks(tenantID, locationID string) ([]mod var params []interface{} var conditions []string + // One row per product+location holding the live balance, so every + // per-ledger-row column has to be aggregated: this rolls up many + // productstocks rows and Postgres rejects a bare a.tenantid/a.stocktype + // under GROUP BY a.productid (that alone made this endpoint return a + // 42803 error instead of any stock at all). + // + // stocktype is matched case-insensitively because the ledger holds a mix + // of 'in' and 'IN' in production — a bare = 'in' silently dropped every + // uppercase receipt, which understated stock rather than erroring. query := ` SELECT - a.productid, a.tenantid, MAX(a.stockdate) AS stockdate, a.locationid, a.stocktype, a.maxquantity, a.minquantity, a.status, + a.productid, a.tenantid, a.locationid, MAX(a.stockdate) AS stockdate, + MAX(a.stocktype) AS stocktype, MAX(a.maxquantity) AS maxquantity, + MAX(a.minquantity) AS minquantity, MAX(a.status) AS status, b.applocationid, b.categoryid, b.subcategoryid, b.catalogueid, b.addonid, b.discountid, b.pricingid, b.productname, b.productimage, b.productdesc, b.productsku, b.brandid, b.productbrand, b.productunit, b.unitvalue, b.toppicks, b.productcost, b.taxamount, b.taxpercent, b.producttax, b.productstock, b.productcombo, b.variants, b.retailprice, b.diffprice, b.diffpercent, b.othercost, b.approve, b.productstatus, b.created, b.updated, c.subcatname AS subcategoryname, - SUM(CASE WHEN a.stocktype = 'in' THEN a.quantity ELSE 0 END) - - SUM(CASE WHEN a.stocktype = 'out' THEN a.quantity ELSE 0 END) AS quantity + SUM(CASE WHEN LOWER(a.stocktype) = 'in' THEN a.quantity ELSE 0 END) - + SUM(CASE WHEN LOWER(a.stocktype) = 'out' THEN a.quantity ELSE 0 END) AS quantity FROM productstocks a JOIN products b ON a.productid = b.productid INNER JOIN productsubcategories c ON c.subcatid = b.subcategoryid @@ -251,7 +262,8 @@ func (r *productRepository) GetProductStocks(tenantID, locationID string) ([]mod query += " WHERE " + strings.Join(conditions, " AND ") } - query += " GROUP BY a.productid" + // b.* / c.* ride along on the primary keys' functional dependency. + query += " GROUP BY a.productid, a.tenantid, a.locationid, b.productid, c.subcatid" if err := r.db.Raw(query, params...).Scan(&stocks).Error; err != nil { return nil, err @@ -264,17 +276,32 @@ func (r *productRepository) CreateProductStock(stocks []models.Productstock) err return r.db.Table("productstocks").Create(&stocks).Error } -// ReactivateProductLocations flips productlocations.status back to -// "available" for each ref — the counterpart to CreateOrder flagging a -// location "outofstock" when its stock hits zero. Without this, a store -// that runs out and then restocks via an approved stock request stays -// flagged outofstock forever, since receiving stock only ever added to the -// productstocks ledger and never touched this per-location flag. -func (r *productRepository) ReactivateProductLocations(refs []models.ProductLocationRef) error { +// SyncProductLocationStatus recomputes productlocations.status for each ref +// from the productstocks ledger: "available" when the live SUM(in)-SUM(out) +// balance is positive, "outofstock" when it is not. +// +// It replaces an earlier version that flipped the flag to "available" +// unconditionally on any receipt. That left the flag drifting from reality in +// both directions — a receipt that only partly covered a negative balance +// marked the product sellable when it wasn't, and stock that arrived by any +// route the API didn't own (a direct insert, an import) never cleared an +// "outofstock" set by a much earlier order. Deriving the flag instead of +// assuming it means every write path converges on the same answer, and a row +// that has already drifted repairs itself on the next ledger entry. +func (r *productRepository) SyncProductLocationStatus(refs []models.ProductLocationRef) error { for _, ref := range refs { - if err := r.db.Table("productlocations"). - Where("tenantid = ? AND locationid = ? AND productid = ?", ref.Tenantid, ref.Locationid, ref.Productid). - Update("status", "available").Error; err != nil { + if err := r.db.Exec(` + UPDATE productlocations + SET status = CASE WHEN ( + SELECT COALESCE( + SUM(CASE WHEN LOWER(stocktype) = 'in' THEN quantity ELSE 0 END) - + SUM(CASE WHEN LOWER(stocktype) = 'out' THEN quantity ELSE 0 END), 0) + FROM productstocks + WHERE productid = ? AND tenantid = ? AND locationid = ? + ) > 0 THEN 'available' ELSE 'outofstock' END + WHERE tenantid = ? AND locationid = ? AND productid = ?`, + ref.Productid, ref.Tenantid, ref.Locationid, + ref.Tenantid, ref.Locationid, ref.Productid).Error; err != nil { return err } } @@ -345,9 +372,14 @@ func (r *productRepository) GetStockStatement(tenantID, locationID, subcategoryI params := []interface{}{tenantID, locationID} + // opening is the balance carried in from *before* today, so it stops at + // stockdate < CURRENT_DATE. It used to include today (<=), which made it + // arithmetically identical to closing — the Inventory ledger then showed + // the same number in both columns and looked like stock never moved, even + // on days with sales. query := `SELECT a.productid,a.productname,a.productimage,a.categoryid,a.subcategoryid,a.productunit,a.unitvalue,a.productcost,a.taxpercent,a.taxamount,a.retailprice,b.tenantid,b.locationid, - COALESCE( SUM(CASE WHEN UPPER(c.stocktype) = 'IN' AND c.stockdate::date <= CURRENT_DATE THEN c.quantity ELSE 0 END) - - SUM(CASE WHEN UPPER(c.stocktype) = 'OUT' AND c.stockdate::date <= CURRENT_DATE THEN c.quantity ELSE 0 END),0 ) + COALESCE( SUM(CASE WHEN UPPER(c.stocktype) = 'IN' AND c.stockdate::date < CURRENT_DATE THEN c.quantity ELSE 0 END) - + SUM(CASE WHEN UPPER(c.stocktype) = 'OUT' AND c.stockdate::date < CURRENT_DATE THEN c.quantity ELSE 0 END),0 ) AS opening, COALESCE(SUM(CASE WHEN UPPER(c.stocktype) = 'IN' AND c.stockdate::date = CURRENT_DATE THEN c.quantity ELSE 0 END), 0) AS credit, COALESCE(SUM(CASE WHEN UPPER(c.stocktype) = 'OUT' AND c.stockdate::date = CURRENT_DATE THEN c.quantity ELSE 0 END), 0) AS debit, @@ -520,30 +552,55 @@ func (r *productRepository) FetchFilteredProducts( // Build product query var products []models.Products + // Both derived tables collapse to one row per product before joining, and + // both are scoped by tenant + (optionally) location. Three things were + // wrong here and each one showed up as bad stock in the console: + // • productlocations was joined on productid alone, so a product stocked + // in three outlets came back three times, each row carrying another + // outlet's status. + // • the stock subquery grouped by (productid, locationid) but joined on + // productid only, so a product's quantity was whichever outlet's row + // the planner happened to pair it with — not this outlet's. + // • stocktype was compared case-sensitively against 'in'/'out' while the + // ledger stores a mix of 'in' and 'IN', so uppercase receipts were + // dropped from the balance. + // locationID 0 means "not scoped to an outlet": stock is then the tenant's + // total across outlets, which is what an unscoped listing should show. query := r.db. Table("products a"). Select(` - a.*, + a.*, b.status, - c.categoryname, + b.locationid, + c.categoryname, d.subcatname AS subcategoryname, - ps.locationid, + COALESCE(ps.quantity, 0) AS productstock, COALESCE(ps.quantity, 0) AS quantity `). - Joins("LEFT JOIN productlocations b ON a.productid = b.productid"). + Joins(` + LEFT JOIN ( + SELECT productid, tenantid, + MAX(locationid) AS locationid, + MAX(status) AS status + FROM productlocations + WHERE (? = 0 OR locationid = ?) + GROUP BY productid, tenantid + ) b ON b.productid = a.productid AND b.tenantid = a.tenantid + `, locationID, locationID). Joins("LEFT JOIN productcategories c ON a.categoryid = c.categoryid"). Joins("LEFT JOIN productsubcategories d ON a.subcategoryid = d.subcatid"). Joins(` LEFT JOIN ( - SELECT + SELECT productid, - locationid, - SUM(CASE WHEN stocktype = 'in' THEN quantity ELSE 0 END) - - SUM(CASE WHEN stocktype = 'out' THEN quantity ELSE 0 END) AS quantity + tenantid, + SUM(CASE WHEN LOWER(stocktype) = 'in' THEN quantity ELSE 0 END) - + SUM(CASE WHEN LOWER(stocktype) = 'out' THEN quantity ELSE 0 END) AS quantity FROM productstocks - GROUP BY productid, locationid - ) ps ON ps.productid = a.productid - `). + WHERE (? = 0 OR locationid = ?) + GROUP BY productid, tenantid + ) ps ON ps.productid = a.productid AND ps.tenantid = a.tenantid + `, locationID, locationID). Where("a.tenantid = ?", tenantID). Order("a.productid DESC") @@ -560,7 +617,13 @@ func (r *productRepository) FetchFilteredProducts( query = query.Where("a.productstatus = ?", productStatus) } if locationID != 0 { - query = query.Where("e.locationid = ?", locationID) + // The outlet scope is already applied inside the productlocations and + // productstocks subqueries above; this only narrows the result to + // products actually carried by that outlet. It used to reference an + // alias `e` that no query in this file defines, so every call that + // passed a locationid failed outright with "missing FROM-clause entry + // for table e" instead of returning products. + query = query.Where("b.locationid = ?", locationID) } if approve != "" { query = query.Where("a.approve = ?", approve) diff --git a/services/productService.go b/services/productService.go index d4419a0..a74f0b1 100644 --- a/services/productService.go +++ b/services/productService.go @@ -4,7 +4,6 @@ import ( "fmt" "nearle/models" "nearle/repositories" - "strings" "time" ) @@ -88,9 +87,12 @@ func (s *productService) CreateProductStock(stocks []models.Productstock) error productIDs = append(productIDs, stk.Productid) } } - // Only "in" entries mean stock actually arrived — an "out" entry - // (a sale) should never flip a location back to available. - if stk.Productid > 0 && stk.Locationid > 0 && stk.Tenantid > 0 && strings.EqualFold(stk.Stocktype, "in") { + // Every entry gets synced, "in" and "out" alike: the status is now + // derived from the resulting balance rather than assumed from the + // direction of the movement, so an "out" that empties a location + // flags it outofstock and a partial "in" that leaves the balance at + // or below zero correctly does not mark it sellable. + if stk.Productid > 0 && stk.Locationid > 0 && stk.Tenantid > 0 { ref := models.ProductLocationRef{Tenantid: stk.Tenantid, Locationid: stk.Locationid, Productid: stk.Productid} if _, exists := locMap[ref]; !exists { locMap[ref] = struct{}{} @@ -106,7 +108,7 @@ func (s *productService) CreateProductStock(stocks []models.Productstock) error } if len(locRefs) > 0 { - if err := s.repo.ReactivateProductLocations(locRefs); err != nil { + if err := s.repo.SyncProductLocationStatus(locRefs); err != nil { return err } }