Compare commits
2 Commits
3b60a90009
...
a11c4843ca
| Author | SHA1 | Date | |
|---|---|---|---|
| a11c4843ca | |||
| c94ddd34c7 |
@@ -1091,40 +1091,35 @@ func (r *orderRepository) UpdateOrder(order *models.Orders) error {
|
|||||||
return tx.Commit().Error
|
return tx.Commit().Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *orderRepository) getSequenceno(tid int, prefix string) string {
|
// nextSequenceNo claims the next order (or invoice) number for a tenant and
|
||||||
type SeqResult struct {
|
// returns it formatted as tenantid-subprefix+seqno (e.g. 916-2024115209).
|
||||||
Orderseqno string
|
//
|
||||||
}
|
// It runs inside the caller's transaction and both reads and increments the
|
||||||
|
// counter in a single UPDATE ... RETURNING, which is what makes the number
|
||||||
var q1 string
|
// unique. The previous implementation split this into getSequenceno (read
|
||||||
// Formats the ID as tenantid-subprefix+seqno (e.g., 908-20245189)
|
// MAX+1) and updateSeqno (increment), both on r.db rather than the order's
|
||||||
switch prefix {
|
// transaction, so two concurrent orders read the same value before either
|
||||||
case "ORD":
|
// wrote, and a rolled-back order still consumed a number.
|
||||||
q1 = `SELECT CONCAT(tenantid, '-',
|
//
|
||||||
CASE WHEN subprefix IS NULL OR CAST(subprefix AS TEXT) IN ('0', '0.0', '') THEN '' ELSE CAST(subprefix AS TEXT) END,
|
// Three further defects that produced duplicate ids in production:
|
||||||
COALESCE(MAX(orderseqno) + 1, 1)) AS orderseqno
|
//
|
||||||
FROM ordersequences WHERE tenantid = ?
|
// - A NULL orderseqno made COALESCE(MAX(orderseqno) + 1, 1) evaluate
|
||||||
GROUP BY tenantid, subprefix`
|
// NULL + 1 = NULL, falling through to a hardcoded "<tenantid>-1"; the
|
||||||
case "INV":
|
// increment then computed NULL + 1 = NULL as well, so the counter could
|
||||||
q1 = `SELECT CONCAT(tenantid, '-',
|
// never leave NULL. Every order such a tenant ever placed was numbered
|
||||||
CASE WHEN subprefix IS NULL OR CAST(subprefix AS TEXT) IN ('0', '0.0', '') THEN '' ELSE CAST(subprefix AS TEXT) END,
|
// "<tenantid>-1" — 108 orders share "1135-1" today. A NULL is now seeded
|
||||||
COALESCE(MAX(invoiceseqno) + 1, 1)) AS orderseqno
|
// from the tenant's existing order count, which is at least as high as any
|
||||||
FROM ordersequences WHERE tenantid = ?
|
// number already handed out, so recovery never reissues a used id.
|
||||||
GROUP BY tenantid, subprefix`
|
//
|
||||||
}
|
// - Tenants with more than one ordersequences row (tenant 1135 has ~25) hit
|
||||||
|
// a GROUP BY that returned several rows, of which Scan silently kept the
|
||||||
var result SeqResult
|
// first, while the increment updated every row. The counter is now pinned
|
||||||
r.db.Raw(q1, tid).Scan(&result)
|
// to the tenant's lowest sequenceid, so reads and writes address the same
|
||||||
|
// row whatever duplicates exist.
|
||||||
// Fallback if no row exists in the database
|
//
|
||||||
if result.Orderseqno == "" {
|
// - A tenant with no row at all fell back to "<tenantid>-1" forever, since
|
||||||
return fmt.Sprintf("%d-1", tid)
|
// nothing created one. The row is now created on first use.
|
||||||
}
|
func nextSequenceNo(tx *gorm.DB, tid int, prefix string) (string, error) {
|
||||||
|
|
||||||
return result.Orderseqno
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *orderRepository) updateSeqno(tid int, prefix string) error {
|
|
||||||
var field string
|
var field string
|
||||||
switch prefix {
|
switch prefix {
|
||||||
case "ORD":
|
case "ORD":
|
||||||
@@ -1132,26 +1127,42 @@ func (r *orderRepository) updateSeqno(tid int, prefix string) error {
|
|||||||
case "INV":
|
case "INV":
|
||||||
field = "invoiceseqno"
|
field = "invoiceseqno"
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("invalid prefix: %s", prefix)
|
return "", fmt.Errorf("invalid prefix: %s", prefix)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🛠️ Improved: Check if row exists, if not, create it
|
// field is not user input — it comes from the switch above.
|
||||||
var count int64
|
formatted := fmt.Sprintf(`CONCAT(tenantid, '-',
|
||||||
r.db.Table("ordersequences").Where("tenantid = ?", tid).Count(&count)
|
CASE WHEN subprefix IS NULL OR CAST(subprefix AS TEXT) IN ('0', '0.0', '')
|
||||||
|
THEN '' ELSE CAST(subprefix AS TEXT) END,
|
||||||
|
%s)`, field)
|
||||||
|
|
||||||
if count == 0 {
|
var seq string
|
||||||
newSeq := map[string]interface{}{
|
err := tx.Raw(fmt.Sprintf(`
|
||||||
"tenantid": tid,
|
UPDATE ordersequences
|
||||||
field: 1,
|
SET %s = COALESCE(%s, (SELECT COUNT(*) FROM orders WHERE tenantid = ?)) + 1,
|
||||||
"subprefix": nil, // Use NULL so CONCAT ignores it or set default here
|
updated = NOW()
|
||||||
}
|
WHERE sequenceid = (SELECT MIN(sequenceid) FROM ordersequences WHERE tenantid = ?)
|
||||||
return r.db.Table("ordersequences").Create(&newSeq).Error
|
RETURNING %s`, field, field, formatted), tid, tid).Scan(&seq).Error
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if seq != "" {
|
||||||
|
return seq, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// If it exists, perform the update
|
// No counter row for this tenant yet — create one, seeded past whatever
|
||||||
return r.db.Table("ordersequences").
|
// their existing orders already used.
|
||||||
Where("tenantid = ?", tid).
|
err = tx.Raw(fmt.Sprintf(`
|
||||||
Update(field, gorm.Expr(fmt.Sprintf("%s + 1", field))).Error
|
INSERT INTO ordersequences (tenantid, %s, created, updated)
|
||||||
|
VALUES (?, (SELECT COUNT(*) FROM orders WHERE tenantid = ?) + 1, NOW(), NOW())
|
||||||
|
RETURNING %s`, field, formatted), tid, tid).Scan(&seq).Error
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if seq == "" {
|
||||||
|
return "", fmt.Errorf("could not allocate %s sequence for tenant %d", prefix, tid)
|
||||||
|
}
|
||||||
|
return seq, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *orderRepository) CreateOrder(data models.Orders) (models.Orders, error) {
|
func (r *orderRepository) CreateOrder(data models.Orders) (models.Orders, error) {
|
||||||
@@ -1244,7 +1255,15 @@ func (r *orderRepository) CreateOrder(data models.Orders) (models.Orders, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 🛠️ Step 2: Create Order Header
|
// 🛠️ Step 2: Create Order Header
|
||||||
data.Orderid = r.getSequenceno(data.Tenantid, "ORD")
|
// 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
|
||||||
|
// rollback releases it rather than burning it.
|
||||||
|
orderid, err := nextSequenceNo(tx, data.Tenantid, "ORD")
|
||||||
|
if err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return models.Orders{}, fmt.Errorf("failed to allocate order number: %w", err)
|
||||||
|
}
|
||||||
|
data.Orderid = orderid
|
||||||
|
|
||||||
if err := tx.Create(&data).Error; err != nil {
|
if err := tx.Create(&data).Error; err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
@@ -1290,10 +1309,6 @@ func (r *orderRepository) CreateOrder(data models.Orders) (models.Orders, error)
|
|||||||
syncProductLocationStatus(tx, data.Tenantid, itemLocID, item.Productid)
|
syncProductLocationStatus(tx, data.Tenantid, itemLocID, item.Productid)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := r.updateSeqno(data.Tenantid, "ORD"); err != nil {
|
|
||||||
log.Println("updateSeqno error:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit().Error; err != nil {
|
if err := tx.Commit().Error; err != nil {
|
||||||
return models.Orders{}, err
|
return models.Orders{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,12 +88,29 @@ func (r *productRepository) GetProductSubCategory(categoryID, tenantID int) ([]m
|
|||||||
func (r *productRepository) GetProductCount(tenantid, categoryid, subcategory int, approve string) ([]models.Productcount, error) {
|
func (r *productRepository) GetProductCount(tenantid, categoryid, subcategory int, approve string) ([]models.Productcount, error) {
|
||||||
var data []models.Productcount
|
var data []models.Productcount
|
||||||
|
|
||||||
|
// available/outofstock are counted from the ledger, not from
|
||||||
|
// products.productstatus. That column is a lifecycle field ("Active" /
|
||||||
|
// "Inactive") that a bug in the stock-receipt path used to overwrite with
|
||||||
|
// availability values, so counting it returned near-nonsense: of 6245
|
||||||
|
// products it matched 'available' on 136 and 'outofstock' on 12, with the
|
||||||
|
// rest — the real answer — invisible under "Active".
|
||||||
|
//
|
||||||
|
// A product counts as available when it holds positive stock at any one of
|
||||||
|
// the tenant's outlets, which is the only sensible tenant-wide reading of a
|
||||||
|
// quantity that is really per-outlet. total = available + outofstock.
|
||||||
baseQuery := `
|
baseQuery := `
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) AS total,
|
COUNT(*) AS total,
|
||||||
SUM(CASE WHEN a.productstatus = 'available' THEN 1 ELSE 0 END) AS available,
|
SUM(CASE WHEN COALESCE(s.balance, 0) > 0 THEN 1 ELSE 0 END) AS available,
|
||||||
SUM(CASE WHEN a.productstatus = 'outofstock' THEN 1 ELSE 0 END) AS outofstock
|
SUM(CASE WHEN COALESCE(s.balance, 0) <= 0 THEN 1 ELSE 0 END) AS outofstock
|
||||||
FROM products a
|
FROM products a
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT productid, tenantid,
|
||||||
|
SUM(CASE WHEN LOWER(stocktype) = 'in' THEN quantity ELSE 0 END) -
|
||||||
|
SUM(CASE WHEN LOWER(stocktype) = 'out' THEN quantity ELSE 0 END) AS balance
|
||||||
|
FROM productstocks
|
||||||
|
GROUP BY productid, tenantid
|
||||||
|
) s ON s.productid = a.productid AND s.tenantid = a.tenantid
|
||||||
WHERE 1 = 1
|
WHERE 1 = 1
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|||||||
@@ -76,17 +76,9 @@ func (s *productService) CreateProductStock(stocks []models.Productstock) error
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
idMap := make(map[int]struct{})
|
|
||||||
var productIDs []int
|
|
||||||
locMap := make(map[models.ProductLocationRef]struct{})
|
locMap := make(map[models.ProductLocationRef]struct{})
|
||||||
var locRefs []models.ProductLocationRef
|
var locRefs []models.ProductLocationRef
|
||||||
for _, stk := range stocks {
|
for _, stk := range stocks {
|
||||||
if stk.Productid > 0 {
|
|
||||||
if _, exists := idMap[stk.Productid]; !exists {
|
|
||||||
idMap[stk.Productid] = struct{}{}
|
|
||||||
productIDs = append(productIDs, stk.Productid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Every entry gets synced, "in" and "out" alike: the status is now
|
// Every entry gets synced, "in" and "out" alike: the status is now
|
||||||
// derived from the resulting balance rather than assumed from the
|
// derived from the resulting balance rather than assumed from the
|
||||||
// direction of the movement, so an "out" that empties a location
|
// direction of the movement, so an "out" that empties a location
|
||||||
@@ -101,12 +93,16 @@ func (s *productService) CreateProductStock(stocks []models.Productstock) error
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(productIDs) > 0 {
|
// products.productstatus is deliberately NOT touched here. It is a
|
||||||
if err := s.repo.UpdateProductStatus(productIDs, "available"); err != nil {
|
// per-product lifecycle field holding "Active"/"Inactive", and receiving
|
||||||
return err
|
// stock used to overwrite it with "available" — an availability value in a
|
||||||
}
|
// lifecycle column, which is how 136 products ended up reading "available"
|
||||||
}
|
// and 12 "outofstock" with their real lifecycle state destroyed.
|
||||||
|
//
|
||||||
|
// Availability is a per-outlet fact and belongs to productlocations.status,
|
||||||
|
// which SyncProductLocationStatus derives from the ledger below. A single
|
||||||
|
// column on products cannot express it anyway: the same product can be
|
||||||
|
// stocked at one outlet and empty at another.
|
||||||
if len(locRefs) > 0 {
|
if len(locRefs) > 0 {
|
||||||
if err := s.repo.SyncProductLocationStatus(locRefs); err != nil {
|
if err := s.repo.SyncProductLocationStatus(locRefs); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
Reference in New Issue
Block a user