products.productstatus is a per-product lifecycle field holding
"Active"/"Inactive". CreateProductStock overwrote it with "available" on every
stock receipt — an availability value written into a lifecycle column — which
destroyed the real lifecycle state of the rows it touched. 136 products now
read "available" and 12 "outofstock" with no way to recover what they were.
A single column on products cannot express availability anyway: the same
product can be stocked at one outlet and empty at another. That fact belongs
to productlocations.status, which SyncProductLocationStatus already derives
from the ledger, so the receipt path now updates only that and leaves
productstatus alone. UpdateProductStatus remains available as an explicit
admin operation; it is simply no longer called as a side effect of stock
movement.
GetProductCount counted available/outofstock off the same corrupted column and
returned near-nonsense as a result: across 6245 products it matched
'available' on 136 and 'outofstock' on 12, leaving 6097 — the real answer —
uncounted under "Active". It now derives both from the ledger, counting a
product available when it holds positive stock at any of the tenant's outlets,
so total = available + outofstock (6245 = 22 + 6223).
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.
The mobile app needs an "All" tile to browse every product regardless of
category. Rather than inserting a real app_category row (which would break
once any category-scoped product filter treats it as an actual, empty
category), the service now prepends a synthetic entry with categoryid=0 —
reusing the "0 = no category filter" convention GetAllProducts already
implements in FetchFilteredProducts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The store/branch QR code the app scans is just {tenantid, locationid} JSON, but the
onboarding response previously discarded the DB-assigned locationid, so the frontend
had no way to render a store's QR right after onboarding without a separate lookup.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two gaps found while auditing the order/stock flow:
- Receiving stock via an approved stock request only updated
products.productstatus (a global per-product flag). A location
flagged outofstock by CreateOrder never got reactivated, since
nothing touched productlocations.status on the way back in.
CreateProductStock now reactivates the specific
(tenant, location, product) row to "available" for every "in"
entry, the counterpart to how it gets flagged out.
- getproductbyvariant returned no stock info at all, so the app could
only find out a product was unavailable from the 409 at order time.
It now accepts an optional locationid and, when passed, returns
live productstock (same SUM(in)-SUM(out) formula the order check
uses) and locationstatus per product. Omitting locationid keeps the
old response shape.
Added MOBILE_ORDER_VERIFICATION.md as a handoff doc for the mobile
team covering the expected request/response shapes and how to verify
their integration.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Every order created after orderheaderid 146119 had zero rows in
orderdetails — the header saved fine but items never made it in,
which also meant the stock pre-validation loop (it iterates over
data.Items) never ran, so an order could go through without ever
checking stock. Root cause: whichever client is sending these sends
items as a sibling of "orders" rather than nested inside it, a shape
neither existing parse strategy captures, so encoding/json silently
dropped it.
Add a third parse fallback for that sibling-items shape, and reject
any order with zero items outright instead of letting it through as
a phantom header-only row.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CreateTenantLocation hardcoded the new location and its auto-spawned
manager login to InActive regardless of what the caller sent (the
frontend already sends Active). AppLogin checks account status before
checking whether a password is set, so a new store's login could
never reach the password-setup screen — permanently stuck on
"Inactive Account. Contact admin."
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CreateTenantUser copied the tenant form's fields onto the new
app_users row via copier.Copy, but the onboarding form never sends a
configid, so it defaulted to 0. AppLogin's GetUserByAuthname always
queries configid=1 for the web login, so any tenant onboarded through
this path was permanently unable to log in by email ("Email not
found") no matter what was typed. Set it explicitly, same as roleid.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
PUT /users/update doubles as the password-setup/reset call (userid +
password only) for the new frontend create-password flow, and had no
validation on that field at all.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
issuperadmin on app_users (schema change already applied to the DB)
is now returned by AppLogin/TenantWebLogin's login response, gated
server-side rather than via a client-supplied roleid. Fixes the login
enrichment query (GetTenantUserById) to LEFT JOIN app_location instead
of INNER JOIN — a tenantless super-admin row was previously silently
dropped, so the login "succeeded" but returned an empty struct.
Also exposes createtenantuser on the web route group (was mob-only).
No repository changes were needed for location auto-provisioning:
GORM was already inserting the nested tenantlocations association on
tenant creation, it just wasn't reachable from the web.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
subcategoryid is no longer required to import a catalogue product —
it's a display/grouping hint elsewhere (the codebase already has an
"Uncategorized" fallback for subcategoryid=0), so requiring it was
pure friction with no correctness payoff.
Fixes the category picker at the root: categoryid 2, which tenant
1135's real products actually use, has no row in productcategories at
all (not a filter bug — the master data is genuinely missing it).
Rather than inventing category master data, adds
GET /products/gettenantcategories, which lists categories a tenant's
own products actually use (falling back to a synthesized label when
the master table has no name), so the import category picker always
offers something real instead of an incomplete global list.
Also relaxes the subcategory lookup's tenant filter to include
unowned/global rows (tenantid NULL or 0), not just exact tenant
matches — categoryid 2's real subcategories carry no tenant at all,
so the strict filter was hiding them even when a tenant wanted one.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds a separate CatalogueDB (pgvector) connection alongside the main
nearledb, plus a new catalogue module (repository/service/controller/
routes) to browse it by brand, category, and keyword, with brand
optional so the whole ~237-product catalogue can be browsed unfiltered.
Adds the actual bridge: importing a catalogue product snapshots it into
the tenant's own products table (keyed on brand+catalogueid, since a
catalogue row's bare id is only unique within its own brand table),
then links it via the existing productlocations upsert. Re-importing
tops up stock and refreshes price instead of duplicating. Also adds an
imported-refs endpoint so the frontend can badge already-imported items
without diffing full product lists, and wires the new AWS S3 image
store used to resolve catalogue product photos.
Bumps Go/Docker to 1.24 for the AWS SDK dependency this needs.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>