Opening a Count & Locking Storages
Business view
Starting a stock count is a two-step decision: what to count, and what to freeze while you count it.
You either pick a single storage (a specific room, cabinet, or branch store), or you leave it empty to count — and lock — every storage in the clinic at once. The form warns you before you do the second thing, because it has a real consequence: while an all-storages count is open, no purchase, transfer, usage, sale, refund, disposal, or adjustment can complete anywhere in the clinic until that count is confirmed or cancelled. A single-storage count only blocks that one storage; everything else keeps moving normally.
This trade-off exists because a count is only meaningful if the numbers hold still while you're counting them. If someone could sell a box of gloves out of a storage mid-count, the "expected amount" the count started with would already be wrong by the time you finish. Locking the storage for the duration is what makes the count trustworthy.
The moment you open a count, the system takes a snapshot: it looks at everything currently sitting in the storage(s) you're counting and writes down, per item, what it expects to find. That snapshot never moves again on its own — even if something elsewhere in the system changes item prices or averages, the count's expected figures are frozen at the values they were computed with.
Technical view
Creating the count
addNewStockCount (packages/server/src/resolvers/mutations/actions/stockCounts/addNewStockCount.js) runs inside a single Prisma transaction:
- Assigns the next sequential
numberper company (packages/server/src/resolvers/mutations/actions/stockCounts/addNewStockCount.js:30-39) — this is the human-facing "#1,#2…" you see in the UI, distinct from the UUIDid. - Calls
claimStoragesForStockCount(below) to lock the target storage(s). - Reads every
InventoryLocationin those storages withamount > 0(addNewStockCount.js:53-66) — a location with nothing on it has nothing to count, so it's skipped entirely rather than creating a zero-line. - For each location, computes the expected amount in items, in boxes, and in value (
stockCountAmounts,stockCountUtils.js:42-46), and creates oneStockCountItemper location (addNewStockCount.js:70-98). - Rolls the per-line totals up into the
StockCountrow itself (totalItems,expectedAmount,expectedAmountBox,expectedValue) so a list screen never has to aggregate lines to show a count's size (addNewStockCount.js:101-110).
Each line's code is "{count number}-{index, zero-padded}" (e.g. 12-003) — short enough to read off a printed sheet by hand, and it's what the Excel round-trip (see Counting & Recording) matches rows against, because a code column survives copy-paste better than a UUID.
Locking storages
claimStoragesForStockCount (packages/server/src/resolvers/mutations/actions/stockCounts/stockCountUtils.js:10-30):
- If a specific storage was given, it locks just that one. If not, it locks every storage belonging to the company.
- The lock is a pointer, not a boolean:
InventoryStorage.lockedByStockCountId(packages/prisma/schema.prisma:4554-4555) is set to the count's id. This means a blocked action can name which count is holding the storage in its error message, rather than just refusing. - The claim uses a conditional
updateMany—where: { lockedByStockCountId: null }— and then checks that the number of rows updated equals the number of storages requested (stockCountUtils.js:20-27). If another count grabbed one of the same storages a moment earlier, the counts don't match and the whole transaction throwsA Stock Count Is Already Open For One Of These Storages. This is how two counts can't both win a race to lock the same storage — the transaction's row-count check is the concurrency guard, not an application-level lock.
What the lock actually blocks
The lock is enforced in exactly one place, shared by every inventory-moving mutation: assertStoragesAreNotBeingCounted (packages/server/src/resolvers/mutations/actions/inventory/inventoryUtils.js:29-51), called from handleCompleteInventoryTransactions (inventoryUtils.js:70) — the function every inventory order type (PURCHASE, USAGE, TRANSFER, DISTRIBUTION, RETURN, SALE, REFUND, DISPOSAL, ADJUSTMENT, PROFIT) routes through to actually move stock.
It does two checks:
- Is any storage named in this transaction (
fromStorage/toStorage) currently pointed at by a lock? If so:"{storage name} Is Being Counted In Stock Count #{number}[Client Error]". - Is there an open, all-storages count for this company (
inventoryStorageId: null)? This second check exists because a storage created after an all-storages count opened would carry no lock pointer of its own — the all-storages count's effect can't be fully captured by per-storage pointers alone, so it's checked separately. If found:"Every Storage Is Being Counted In Stock Count #{number}[Client Error]".
Both are plain thrown Errors with a [Client Error] suffix, which is this codebase's convention for "show this message verbatim to the user" rather than a generic failure.
Releasing the lock
Every way a count can end — confirm, cancel, or delete — calls releaseStoragesForStockCount (stockCountUtils.js:36-37) first, which clears lockedByStockCountId on every storage pointing at that count. Confirming specifically releases the lock before booking the adjustment order (confirmStockCount.js:47-48), with a comment noting why: the adjustment order itself would otherwise be refused by the count's own lock.
Validation
addNewStockCount checks that a given inventoryStorage belongs to the caller's company before proceeding (addNewStockCount.js:18-27) — cross-tenant storage ids are rejected with Storage Not Found[Client Error]. Input shape (generatedID, optional inventoryStorage/branch/details, isMaster) is defined in packages/server/src/inputs.graphql and schema.graphql:addNewStockCount.