211 lines
7.1 KiB
Go
211 lines
7.1 KiB
Go
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
|
|
}
|
|
|
|
// assertOutletNamed refuses an order that does not say which shop it is for.
|
|
//
|
|
// Stock is held per outlet — availableStock filters `locationid = ?` — so a
|
|
// line with no outlet matches no ledger row and reads as zero available. Every
|
|
// such order was then refused as "insufficient stock ... available 0", naming
|
|
// the shelf as the problem when the shelf was full and the request was what was
|
|
// incomplete. A merchant reading that goes and checks their stock, finds it,
|
|
// and has nowhere else to look.
|
|
//
|
|
// Per line rather than per order: a line may carry its own outlet, and only
|
|
// falls back to the header's when it does not.
|
|
func assertOutletNamed(lines []stockLine) error {
|
|
for _, line := range lines {
|
|
if line.Locationid != 0 {
|
|
continue
|
|
}
|
|
name := line.Productname
|
|
if name == "" {
|
|
name = fmt.Sprintf("ID %d", line.Productid)
|
|
}
|
|
return fmt.Errorf(
|
|
"this order names no outlet, so there is no shelf to sell '%s' from: "+
|
|
"send locationid on the order, or on each item", name,
|
|
)
|
|
}
|
|
return nil
|
|
}
|