Compare commits

...

2 Commits

Author SHA1 Message Date
c2f7d2481c stocks change 2026-07-27 18:54:40 +05:30
f57127444f Lock productlocations rows before checking stock in CreateOrder
The order-placement stock check (read available qty, then insert an "out"
deduction) had no row lock, so two concurrent orders for the same product
could both pass the availability check before either committed its
deduction, overselling the item. Locks each ordered product's
productlocations row with SELECT ... FOR UPDATE up front, in a fixed
(productid, locationid) order across all items so overlapping concurrent
orders contend for locks in the same sequence instead of deadlocking.
2026-07-27 18:48:10 +05:30
3 changed files with 60 additions and 7 deletions

View File

@@ -124,6 +124,11 @@ type Locationproducts struct {
Productcombo int `json:"productcombo" gorm:"default:0"`
Variants int `json:"variants" gorm:"default:0"`
Quantity int `json:"quantity"`
// Price is the per-store selling price from productlocations.price — the one
// CreateProductLocation upserts. Read-only here: it comes from the joined
// productlocations row, not from products. Without it a store could set a
// price and never read it back, so the UI always showed the master price.
Price float64 `json:"price" gorm:"->"`
Retailprice float64 `json:"retailprice,omitempty"`
Diffprice float64 `json:"diffprice,omitempty"`
Diffpercent float64 `json:"diffpercent,omitempty"`

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"log"
"nearle/models"
"sort"
"strings"
"time"
@@ -1130,6 +1131,49 @@ func (r *orderRepository) CreateOrder(data models.Orders) (models.Orders, error)
locID = data.Applocationid
}
// 🛠️ Step 0: Lock every (tenantid, locationid, productid) row this order
// touches before checking availability. Without this, two concurrent
// orders for the same product can both read "stock available" before
// either commits its deduction, oversell the item, and drive stock
// negative. Locking productlocations — the row the stock computation is
// already keyed against — serializes conflicting orders instead.
//
// Locks are acquired in a fixed (productid, locationid) order so that
// two orders sharing overlapping products always contend for them in
// the same sequence, avoiding a lock-ordering deadlock between the two
// transactions (as opposed to just making each individually block).
type lockTarget struct {
productid int
locationid int
}
seen := make(map[lockTarget]bool)
locks := make([]lockTarget, 0, len(data.Items))
for _, item := range data.Items {
itemLocID := item.Locationid
if itemLocID == 0 {
itemLocID = locID
}
lt := lockTarget{productid: item.Productid, locationid: itemLocID}
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
lockQuery := `SELECT productlocationid FROM productlocations WHERE tenantid = ? AND locationid = ? AND productid = ? FOR UPDATE`
if err := tx.Raw(lockQuery, data.Tenantid, lt.locationid, lt.productid).Scan(&locked).Error; err != nil {
tx.Rollback()
return models.Orders{}, fmt.Errorf("failed to lock stock for product %d: %w", lt.productid, err)
}
}
// 🛠️ Step 1: Pre-validate stock availability for all items before placing order
for _, item := range data.Items {
itemLocID := item.Locationid

View File

@@ -405,7 +405,11 @@ func (r *productRepository) GetLocationProducts(tenantID, locationID, subcategor
params := []interface{}{tenantID, locationID}
query := `SELECT a.*, b.productlocationid, b.status,
// b.price is the per-store selling price. It has to be selected explicitly:
// a.* only covers products (whose price column is retailprice, the master
// price), so without this the catalogue could never read back a price set
// for this outlet via CreateProductLocation.
query := `SELECT a.*, b.productlocationid, b.status, b.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) -
@@ -427,7 +431,7 @@ func (r *productRepository) GetLocationProducts(tenantID, locationID, subcategor
query += ` GROUP BY a.productid, a.productname, a.productimage, a.categoryid, a.subcategoryid,
a.productunit, a.productcost, a.taxpercent, a.taxamount, a.retailprice,
b.tenantid, b.locationid, b.productlocationid, b.status
b.tenantid, b.locationid, b.productlocationid, b.status, b.price
ORDER BY a.productid DESC LIMIT ? OFFSET ?`
params = append(params, pagesize, offset)