The offline-sales import required one workbook per outlet and a store
picked in the UI. A merchant running several branches had to download,
fill and upload a file per branch, and the picker defaulted to the
tenant's first outlet — so an admin who never touched it silently
credited the wrong store, which no validation could catch because the
file and the selection agreed with each other.
One workbook now covers every branch. getsaletemplate takes locationid=0
(the default) to span the tenant, stamping tenantid, locationid and the
store name onto every row, and that row's locationid is what decides
which branch a sale is deducted from. The INNER JOIN on tenantlocations
confines it to outlets the tenant owns, so a template can never disclose
another merchant's catalogue.
uploadofflinesales accordingly takes locationid on each bill. The
locationid on the request itself becomes a scope constraint rather than
a destination: left at 0 the bills go where their rows say, and set to a
branch it pins the upload there and refuses anything else. That is what
holds a store user to their own store — the pin comes from their session,
so editing the locationid column in the spreadsheet changes nothing.
Every branch referenced is checked against the tenant regardless.
Branch context and catalogue are resolved once per branch and reused; a
workbook covering six outlets would otherwise re-run both queries for
every bill in it.
Duplicate detection is now per branch. Bill numbers only have to be
unique within a store, since counter books at different outlets
routinely restart numbering at 1, and treating a shared number as a
repeat would have silently dropped a real sale.
Verified against tenant 1087, whose two branches both stock product
6998 at 100 units: a single upload of two bills moved 1097 to 97 and
1135 to 95 independently; the same bill number at both branches imported
as two separate orders; an upload pinned to 1097 imported its own bill
and refused the 1135 one; a row naming another tenant's outlet was
refused; and re-uploading the file deducted nothing. All five test
orders were cancelled afterwards and both branches confirmed back at 100.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Order ids were duplicating in production: 160 distinct (tenant, orderid) pairs
are shared by more than one order, worst of them "1135-1" on 108 orders, and
every order tenant 1147 has ever placed is numbered "1147-1".
getSequenceno read MAX(seqno)+1 and updateSeqno incremented, both against
r.db rather than the order's transaction and separated by the whole order
insert. Two concurrent orders therefore read the same number before either
wrote, and an order that rolled back still consumed one. Three further
defects made it worse:
- A NULL orderseqno made COALESCE(MAX(orderseqno) + 1, 1) evaluate
NULL + 1 = NULL and fall through to a hardcoded "<tenantid>-1". The
increment then computed NULL + 1 = NULL too, so the counter could never
leave NULL and every subsequent order reused that same id.
- Tenants with several ordersequences rows (tenant 1135 has ~25) hit a
GROUP BY returning multiple rows, of which Scan kept the first
arbitrarily, while the increment updated all of them.
- A tenant with no row at all fell back to "<tenantid>-1" indefinitely,
because nothing ever created one.
nextSequenceNo replaces both functions with a single UPDATE ... RETURNING run
inside the caller's transaction, so the counter row stays locked until the
order commits and concurrent orders queue rather than collide. A NULL seeds
from the tenant's existing order count — at least as high as any number
already issued, so recovery cannot reissue a used id — the counter is pinned
to the tenant's lowest sequenceid so reads and writes address one row, and a
missing row is created on first use.
Verified against production data in rolled-back transactions: tenant 1147
(NULL) now yields 1147-9, 1147-10, ...; tenant 1135 (NULL plus duplicate rows)
1135-356 onward; tenant 916 keeps its 916-2024115209 subprefix format; an
unknown tenant creates its row and starts at 1. Eight concurrent allocations
produced eight distinct ids. Two real orders through the API returned 1147-9
and 1147-10, then were cancelled with stock restoring to its baseline.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Stock shown in the console did not match the productstocks ledger, and two
product endpoints were failing outright. Every cause was on the read side or
in how the availability flag was maintained; the ledger writes themselves
(CreateOrder's "out" entry, cancellation's "in" entry) were already correct.
Read fixes, repositories/productRepository.go:
- GetProductStocks returned SQLSTATE 42803 on every call: bare a.tenantid /
a.stocktype / a.status under GROUP BY a.productid. The per-ledger-row
columns are now aggregated and the grouping covers the identity columns.
- FetchFilteredProducts filtered on an alias `e` that no query defines, so
every /getallproducts call carrying a locationid failed with SQLSTATE 42P01
instead of returning products.
- FetchFilteredProducts joined productlocations on productid alone and joined
a (productid, locationid)-grouped stock subquery on productid alone, so a
product carried by three outlets came back three times, each row showing
another outlet's quantity and status. Both are now tenant-scoped subqueries
collapsed to one row per product and scoped to the outlet when one is given.
- GetProductStocks and FetchFilteredProducts compared stocktype = 'in'
case-sensitively. Production holds 'in' and 'IN' both, so uppercase receipts
were silently dropped from the balance: one outlet reported 0 for a product
holding 50, another reported 0 for twelve products holding 200-840.
- GetStockStatement summed opening over stockdate <= CURRENT_DATE, making it
arithmetically identical to closing. The Inventory ledger showed the same
number in both columns on every row, which reads as stock never moving.
Availability flag:
productlocations.status was maintained by two different rules — the order path
derived it from the balance, the receiving path set 'available' on any "in"
entry regardless of the resulting balance. A partial restock that left the
balance at or below zero marked a product sellable, and a flag set by an old
order never cleared for stock that arrived by a route the API did not own.
Both paths now derive the flag from the live balance through one rule:
SyncProductLocationStatus (receiving side) and syncProductLocationStatus
(order side, inside the caller's transaction). ReactivateProductLocations is
replaced by the former; the service no longer filters refs by stocktype, since
the direction of the movement is no longer what decides the flag. A row that
has already drifted now repairs itself on its next ledger entry.
Verified against the live database: all four stock endpoints return matching
balances, /getallproducts no longer duplicates rows, and the flag sync was
exercised in both directions inside a rolled-back transaction.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.