package repositories import ( "fmt" "math" "sort" "time" "nearle/models" "gorm.io/gorm" ) // Shared stock machinery. // // Extracted from createOrderTx so that an order placed in the app and a bill // rung up at a counter deduct stock through exactly the same code. Two // implementations of the rule that stops overselling would drift, and the first // anyone would know about it is a shelf that is empty in the database and full // in the shop, or the reverse. // // None of these commit or roll back — the caller owns the transaction boundary, // because what should happen to the rest of the work on failure is the caller's // business, not the ledger's. // stockLine is the minimum the ledger needs to know about one sold line. // // Deliberately not models.OrderDetail: the POS ingest writes its own tables and // has no OrderDetail to hand, and coupling the ledger to one caller's row type // is what forced the duplication this file removes. type stockLine struct { Productid int Locationid int Productname string // Units sold. Fractional because a counter sells 1.5 kg of onions; the // ledger itself is integer-only, and roundStockQty explains the gap. Quantity float64 } // roundStockQty turns a sold quantity into a ledger quantity. // // productstocks.quantity is an integer column, so fractional sales cannot be // represented exactly. Rounding *up* is the conservative direction: 1.5 kg // deducts 2, so the recorded stock is never higher than what is physically on // the shelf. Truncating instead would under-deduct on every fractional sale and // let the shop oversell a little more each time. // // This is a workaround, not a fix. A shop that sells much by weight needs the // column to be numeric. func roundStockQty(quantity float64) int { if quantity <= 0 { return 1 } return int(math.Ceil(quantity - 1e-9)) } // lockStockRows takes a row lock on every (tenant, location, product) the sale // touches, before anything reads availability. // // Without it two concurrent sales of the same product can both read "in stock" // before either commits its deduction, oversell the item and drive the balance // negative. Locking productlocations — the row the stock computation is already // keyed against — serialises conflicting sales instead. // // Locks are taken in a fixed (productid, locationid) order so two sales sharing // products always contend in the same sequence. Without that ordering they // deadlock against each other rather than merely blocking. func lockStockRows(tx *gorm.DB, tenantID int, lines []stockLine) error { type lockTarget struct { productid int locationid int } seen := make(map[lockTarget]bool, len(lines)) locks := make([]lockTarget, 0, len(lines)) for _, line := range lines { lt := lockTarget{productid: line.Productid, locationid: line.Locationid} if !seen[lt] { seen[lt] = true locks = append(locks, lt) } } sort.Slice(locks, func(a, b int) bool { if locks[a].productid != locks[b].productid { return locks[a].productid < locks[b].productid } return locks[a].locationid < locks[b].locationid }) for _, lt := range locks { var locked int const q = `SELECT productlocationid FROM productlocations WHERE tenantid = ? AND locationid = ? AND productid = ? FOR UPDATE` if err := tx.Raw(q, tenantID, lt.locationid, lt.productid).Scan(&locked).Error; err != nil { return fmt.Errorf("failed to lock stock for product %d: %w", lt.productid, err) } } return nil } // availableStock is the ledger balance for one product at one location. func availableStock(tx *gorm.DB, tenantID, locationID, productID int) (int, error) { var available int const q = ` 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(q, productID, tenantID, locationID).Scan(&available).Error; err != nil { return 0, fmt.Errorf("failed to verify stock for product %d: %w", productID, err) } return available, nil } // assertStockAvailable refuses the whole sale if any line cannot be met. // // Checked for every line before any is written, so a sale never lands // half-deducted. Call it only with the locks from lockStockRows already held — // otherwise the balance it reads can change before the deduction is written. func assertStockAvailable(tx *gorm.DB, tenantID int, lines []stockLine, qtyOf func(stockLine) int) error { for _, line := range lines { available, err := availableStock(tx, tenantID, line.Locationid, line.Productid) if err != nil { return err } requested := qtyOf(line) if available < requested { name := line.Productname if name == "" { name = fmt.Sprintf("ID %d", line.Productid) } return fmt.Errorf( "insufficient stock for product '%s': requested %d, available %d", name, requested, available, ) } } return nil } // recordStockOut writes the ledger entry for one sold line and re-derives the // location's availability flag from the balance it just produced. func recordStockOut(tx *gorm.DB, tenantID int, line stockLine, quantity int) error { stock := models.Productstock{ Tenantid: tenantID, Stockdate: time.Now(), Locationid: line.Locationid, Productid: line.Productid, Quantity: quantity, Stocktype: "out", Status: "Active", } if err := tx.Table("productstocks").Create(&stock).Error; err != nil { return err } syncProductLocationStatus(tx, tenantID, line.Locationid, line.Productid) return nil } // legacyOrderQty is how createOrderTx has always turned an order quantity into // a ledger quantity: truncate, then floor at 1. // // Preserved exactly rather than corrected, because changing it would silently // alter stock deduction for every app order in production. It under-deducts a // fractional line — 1.5 becomes 1 — which is why the POS path uses // roundStockQty instead. Worth reconciling once someone owns the decision. func legacyOrderQty(quantity float64) int { q := int(quantity) if q <= 0 { q = 1 } return q }