measured at 3bfe3b23
Created: 2026-07-28 | Last Updated: 2026-07-28
Sixth filled instance of
module-scorecard-template.md(after PO, Receivings, Adjustments, Transfers, Distribution) — the last member of the inventory-movement family. Read-only analysis; recommendations only. Evidence isfile:line/ endpoint againstD:\BCPOSGit\qiiubon branchmainat commit3bfe3b23. Gathering source-of-record:artifact-stock-counts-gathering-{repo,rmhrms,wids365}.md+artifact-stock-counts-market-{pos,retail,erp}.md. Scored with all four source families (RMS/RMH + WIDS365 + modern market + QIIUB-original).
Read the headline honestly — two caveats. (1) Stock Counts is a backend-only module: 4 API endpoints, zero portal/admin UI (§5/§7). Its only presence in any user-facing surface is a greyed-out "Coming soon" tile. A merchant cannot perform a physical count through any QIIUB interface today. (2) The 39% is the lowest in the movement family, and unlike its five siblings T1 is not 100% — the count document cannot be revised or abandoned once created (§3). The posting engine at the end of the flow is genuinely solid; almost everything a merchant would touch on the way there is absent.
Léelo así. Stock Counts es el conteo físico: se abre un conteo en una ubicación, se registran las cantidades contadas, y al cerrarlo QIIUB calcula la diferencia contra el inventario del sistema y la postea automáticamente como un ajuste. Ese motor de cierre funciona bien y es transaccional. El problema es todo lo demás: no hay pantalla (solo un tile "Coming soon"), el conteo se envía completo de una sola vez y no se puede corregir ni cancelar, la merma se contabiliza a costo cero (así que no se puede saber cuánto dinero se perdió), y no se registra quién contó.
| Field | Value |
|---|---|
| Module name | Stock Counts (Physical / Cycle Inventory Counts) |
| File | stock-counts-scorecard.md (docs/modules/stock-counts-scorecard.md) |
| Author / session | Claude (Opus) — module #6 |
| Created | 2026-07-28 |
| Last-Updated | 2026-07-28 |
| Repo commit (SHA) analyzed | 3bfe3b23 |
| Governing ADRs | None dedicated. Incidental only: 0081 (Inventory Hub IA — specifies the unbuilt counts UI and mis-states the endpoint), 0082 (PublicId/TypeID — scnt/scln), 0020 (LocationProduct not StockLevel), 0065 (product status filter), 0062 (user attribution). ADR-0027 governs ShiftCount, a different module. Evidence only; this scorecard creates none |
| Governing design docs | docs/design/inventory-supply-chain.md §496; docs/design/offline-function-matrix.md:80; docs/design/21-pos-client.md:79 (the latter two contradicted by code — §8) |
| Status of this analysis | Adversarially-verified — Fable pass (findings + resolutions in artifact-stock-counts-fable-review.md); live-confirmed against the running app 2026-07-28 (§5c) |
Last-Updated= the measurement date, pinned tocommit:— bumped only on a re-score (.claude/rules/docs.mdcarve-out).
InventoryMovement typed Count, upserts LocationProduct.QuantityOnHand, and auto-creates a already-Completed InventoryAdjustment carrying the CYCLE-COUNT reason — all in one transaction. It is the systematic counterpart to Adjustments: where an adjustment is a deliberate reason-coded correction, a count is a discovery of drift.core (shrinkage is the retail loss channel; counting is the only way to find it). Rx — core (pharmacies count controlled substances on a schedule; the audit trail is a compliance artifact). Build — used (materials counting matters less than job costing).SystemAdmin / Admin only — all four endpoints are Roles("SystemAdmin","Admin") (StartStockCountEndpoint.cs:68, ListStockCountsEndpoint.cs:67, GetStockCountEndpoint.cs:58, FinalizeStockCountEndpoint.cs:51). Notably a Manager cannot perform a count, though counting is archetypally floor/manager work. Surface: API only — no portal, no admin, no POS (§5).ShiftCount is not a stock count — it is the per-payment-method cash count at shift close-out (ShiftCount.cs:7-9, money decimal(18,2), FKs to Shift/PaymentMethod, CountType = EndOfShift|TerminalHandoff|ManagerCount, governed by ADR-0027). Same word, different domain; it belongs to Shifts / Cash Management and is not even a dependency. Also elsewhere: the ReasonCode catalog (§2), the InventoryAdjustment document type and its manual Draft→Completed machine (Adjustments), the InventoryMovement ledger + LocationProduct stock row (Inventory core), and the aggregate shrinkage report (Reports).| Module | Direction | What crosses the boundary | Scoring treatment |
|---|---|---|---|
| Adjustments | downstream | Finalize constructs an InventoryAdjustment (born Completed) as the correction artifact |
dependency (SC-D01) — the document type is theirs; the count→variance→correction capability is scored here (mirror of ADJ-D02) |
| Reason Codes (cross-cutting reference data) | shared-infra | The CYCLE-COUNT reason the auto-adjustment carries |
dependency (SC-D02); the lazy auto-create of that row on first finalize is scored here (SC-F006) |
| Inventory core | shared-infra | InventoryMovement ledger + LocationProduct.QuantityOnHand that finalize writes, and the Movements Log UI that surfaces Count movements |
dependency (SC-D03, SC-D04) |
| Reports | downstream | Aggregate shrinkage / variance-by-period reporting | dependency (SC-D05) — contestable, see §9 |
| Document sequencing | shared-infra | IDocumentSequenceService mints CNT-###### and the second ADJ-###### |
dependency (SC-D06); the use (documents carry a number) is scored SC-F007 |
| Tax | shared-infra | ILocationProductTaxResolver assigns TaxGroupId on a newly-created LocationProduct |
dependency (SC-D07) |
| Sync / POS | shared-infra | Offline transport — exists but is not wired for counts | dependency (SC-D08); the in-module gap (no offline count capture) is scored SC-F039 |
| Employees | upstream | The Employee identity the reserved EmployeeId would point at |
dependency (SC-D09) |
| Platform (ADR-0062) | shared-infra | CreatedByUserId / UpdatedByUserId base columns |
dependency (SC-D10) |
| Locations / Products | upstream | The Location scope and the ProductVariant being counted |
referenced, not re-specified (§4) |
Boundary statement. Stock Counts owns the StockCount + StockCountItem tables, all 4 endpoints under Api/Features/StockCounts/, the InProgress → Completed transition, the expected-on-hand snapshot and variance model, and — decisively — the semantic capability that a counted difference becomes a posted correction. It does not own the InventoryAdjustment document type it instantiates, the ReasonCode catalog it writes one row into, the InventoryMovement/LocationProduct tables it mutates, or the aggregate reporting over the result.
Boundary-leak checklist.
Implemented), listed as Dependency ADJ-D02 in adjustments-scorecard.md:139. The reciprocal claim was independently re-verified point-by-point against the code and is correct in all six of its statements — finalize creates an InventoryAdjustment (FinalizeStockCountEndpoint.cs:147-157, :232), born Completed (:153), reason CYCLE-COUNT (:127, :133), movements typed Count not Adjustment (:183 — the repo's only writer of MovementType.Count), QoH upserted (:212, :226), one transaction (:73→:239), and the cited range 118-237 is accurate. No contradiction to report.Missing (SC-F017), not ceded to Reports. The defect is that this module never snapshots UnitCost onto the lines and movements it authors (grep UnitCost src/QIIUB.Api/Features/StockCounts/ → zero hits), while the manual Adjustments path does (CreateAdjustmentEndpoint.cs:186). Labelling it a Reports dependency would let a real in-module defect hide behind a neighbor — anti-gaming guard (c).CYCLE-COUNT reason auto-create. Scored here (SC-F006 notes). Reason Codes owns the catalog CRUD (SC-D02); the decision to lazily mint that row on first finalize is this module's behavior.ShiftCount / CountType / ADR-0027 belong to Shifts — excluded entirely, not a dependency (§1).Single-owner rule. Verified: none of the 40 scored features here is also scored in PO / Receivings / Transfers / Distribution / Adjustments. Conversely, Adjustments' ADJ-D02 and its parked line "Auto-reconcile concurrent movement during count (RMH refinement)" (adjustments-scorecard.md:328) both land here — the first as Implemented (SC-F006), the second as Missing (SC-F022).
Status legend: Implemented / Partial / Missing / Dependency (excluded). Source: RMS / RMH / WIDS365 / market / QIIUB-original.
Comparison map, not a backlog. Universe = RMS/RMH (whose physical-inventory feature is one of their richest areas) + WIDS365 + modern inventory tooling (Square/Clover/Lightspeed/Shopify+Stocky/Cin7/NetSuite/Odoo) + QIIUB-original. A
Missingmeans "that world has it and QIIUB doesn't (yet)," not a commitment.About the reference systems. RMS (Microsoft Dynamics Retail Management System) is the older base; RMH (Retail Management Hero) is its current, actively-developed successor — built on the RMS database to keep upgrades smooth, and extending it with additional tables plus visual and integration extensions. RMH is one of the products BCPOS resells. (RMS's Headquarters / HQ is called Central in RMH.) They are used here as a completeness yardstick, not a competitive ranking: where QIIUB does something differently, that reflects its cloud / single-database architecture, not a judgment that RMS/RMH is inferior. QIIUB is a newer evolution alongside them.
WIDS365 has no physical-count feature at all — a well-evidenced absence, and a wider subset margin than in the previous five modules. Sweeps of
sys.tables(183 + 29),sys.procedures, and its 20-rowReportcatalog return no count entity, no variance report, and no store-facing inventory screen; counting is done through the genericAdjustmentHeader/AdjustmentDetaildocument, whereDetail.Quantityis a signed delta so the operator computes variance by hand. ItsAnalyticsItemStockCountdashboard tile is a name collision —SUM(ItemDynamic.Quantity), an on-hand aggregate — and is marked N/A (SC-N04), not credited. What WIDS365 does contribute to the yardstick is generic document infrastructure QIIUB lacks: structured bulk ingest (web_AdjustmentDetailUpsertByJson— MERGE-idempotent, resolves by ItemID or ItemCode, per-row named errors, fed by clipboard paste and.txt/.csv/.tsvdrop) and temporal audit on header and detail.Market shape (from the 3-segment scan: POS / retail-ecom / ERP). Counting splits into a table-stakes tier (open a count, capture quantities, auto-variance, post to on-hand, scan/mobile capture, scope filters, count sheet, CSV round-trip, who-counted, valued variance, uncounted→zero-or-leave) and an ERP tier (count schedules, ABC-class selection, tolerance/approval routing, transaction freeze, GL posting, serial/lot/expiry counting, bin-level counting). QIIUB has the posting engine and essentially none of the table-stakes operational tier — which is why T2 reads 10% rather than the family-typical 22–46%.
Three calibrations from the scan materially shaped the tiering, and each is worth stating because they cut against the intuitive reading:
- Nothing is universal across the POS tier: Clover ships no stocktake at all in its core product (no count/audit/adjustment resource in its developer docs; its own blog points merchants to third-party apps, and the "Inventory Audits" feature belongs to Clover Sport, a different product line). The honest POS benchmark is Square ∩ Lightspeed, with Clover as the floor — so QIIUB having a count engine at all already clears the floor.
- No product freezes or snapshots expected quantity at count start except NetSuite. Shopify/Stocky, Cin7 and Odoo all compare against live on-hand; Cin7's answer to mid-count movement is to lock the location and stop trading, which is not available to a POS. QIIUB's persisted
SystemQuantitybaseline (SC-F003) is therefore ahead of the market norm, not catching up to it — a point in its favor that the 39% does not convey (though see SC-F003 for why it is a sound design choice rather than a delivered advantage).- Blind counting, tolerance thresholds and scheduled counts are documented by almost nobody below the ERP tier. Blind in particular is absent from every product scanned and from RMS/RMH (which achieve it only by omitting the expected-quantity column from the printed sheet), so it is scored as QIIUB-original whitespace (SC-F021, T3) rather than a market parity gap.
One dated market note, recorded as a point-in-time fact rather than a prescription: Shopify is retiring Stocky on 2026-08-31, after which the go-forward Shopify baseline is thinner (a CSV round-trip plus a ≤1,000-item "Quick count"). A future re-score should not benchmark against Stocky's richer feature set.
| ID | Name | Description | Source | Status | Evidence | Notes |
|---|---|---|---|---|---|---|
| SC-F001 | Open a count (scoped to a location) | Create a count document against exactly one location | RMS+RMH+market | Implemented | StartStockCountEndpoint.cs:67, :115-123; StockCountConfiguration.cs:18-22 |
Location required and resolved from PublicId; 400 if unknown (:89-93). Per-location partial counts are possible |
| SC-F002 | Capture counted quantity per item | Record what the counter physically found, per product variant | RMS+RMH+WIDS365+market | Implemented | StartStockCountEndpoint.cs:125-137; StockCountItem.cs:16 |
decimal(18,4). Submitted in the opening POST, not entered over time — see SC-F011 |
| SC-F003 | Snapshot expected on-hand | Persist the system quantity each counted line is measured against | RMS+RMH+market | Implemented | StockCountItem.cs:15; StartStockCountEndpoint.cs:111-113, :128; test AdjustmentAndCountTests.cs:406 |
Real decimal(18,4) column, written once from LocationProduct.QuantityOnHand. A variant with no stock row snapshots as 0 (:128), so a never-stocked item can be counted. Rarer than it looks: of the products scanned only NetSuite also snapshots at count start — Shopify/Stocky, Cin7 and Odoo all compare against live on-hand. Read that as a sound design choice, not a delivered advantage: because the whole count arrives in one POST (SC-F011), the baseline and the counted figure are captured at the same instant, so the snapshot's value is limited to the create→finalize window — real, but narrower than the count-open-to-count-close window RMS/RMH protect. And the window is left unguarded (SC-F022), so the baseline is a good foundation with nothing built on it |
| SC-F004 | Automatic variance calculation | Compute counted − expected per line | RMS+RMH+market | Implemented | StartStockCountEndpoint.cs:135; StockCountItemConfiguration.cs:13 |
Stored, not derived. Finalize re-reads the stored value rather than recomputing (FinalizeStockCountEndpoint.cs:111-112) — the drift consequence is SC-F022 |
| SC-F005 | Post the count to stock + ledger | Finalize moves on-hand and writes an append-only movement per variance line | RMS+RMH+WIDS365+market | Implemented | FinalizeStockCountEndpoint.cs:178-188 (movement), :210-213/:220-228 (QoH upsert), :73→:239 (one transaction) |
The module's core purpose, and its strongest part: all-or-nothing transaction with the rationale documented at :66-72, MovementType.Count (:183) distinct from manual Adjustment, .Local-first upsert fixing SQL-2601 (:199-208), and QoH RowVersion retry (:233) |
| SC-F006 | Variance becomes a posted correction | The difference auto-creates a Completed reason-coded adjustment document |
RMS+RMH+market | Implemented | FinalizeStockCountEndpoint.cs:147-157, :232; reason find-or-create :126-143; test AdjustmentAndCountTests.cs:435 |
RMS/RMH's Calculate → Commit. Born Completed, skipping Draft — no review step (see SC-F030). Lazily mints the CYCLE-COUNT reason row on first use. This is the capability Adjustments cedes as ADJ-D02 |
| SC-F007 | Document numbering | Human CNT-###### per merchant |
RMS+RMH | Implemented | StartStockCountEndpoint.cs:107; S12_DocumentSequencesSeed.cs:29; unique UQ_StockCount_MerchantId_DocumentNumber (StockCountConfiguration.cs:25-27) |
A second number (ADJ-######) is minted for the auto-adjustment (FinalizeStockCountEndpoint.cs:145). Sequence service is shared-infra (SC-D06) |
| SC-F008 | View count detail with variance | Header + lines showing system / counted / variance and product identity | RMS+RMH+market | Implemented | GetStockCountEndpoint.cs:57, :69-94 |
Deliberately opts out of the productActiveStatus filter (:70, rationale :66-68) so discontinued SKUs — a primary write-off case — still display |
| SC-F009 | List / filter / sort / search counts | Find a count among many | RMS+RMH+market | Partial | ListStockCountsEndpoint.cs:66, filters :77-80, 6 sorts :86-95 |
What works: paging, filter by location + status, 6 sort keys. Missing: no free-text search on document number, no date-range filter, no aggregates (total variance / lines counted), no created-by surfaced, and SortBy has no allowlist so an unknown value silently falls through to the default (:94) — all of which the Adjustments list has |
| SC-F010 | Status lifecycle | The count's states and the guarded transitions between them | RMS+RMH+WIDS365+market | Partial | StockCountStatus.cs; CAS flip FinalizeStockCountEndpoint.cs:93-105; test AdjustmentAndCountTests.cs:586 |
What works: InProgress → Completed is a genuine compare-and-swap (Status == InProgress is the predicate), so two racing finalizes cannot both post; 409 on repeat; a deliberate 404/409 split (:75-89). Missing: Cancelled = 2 (StockCountStatus.cs:10) is a dead enum member with no writer anywhere in src/ — you cannot abandon a started count, and the list can filter by a status that can never occur. No reopen either |
| SC-F011 | Revise the count while open | Add / edit / remove counted lines before posting | RMS+RMH+market | Missing | No UpdateStockCount, AddStockCountItem, UpdateStockCountItem or RemoveStockCountItem folder in src/QIIUB.Api/Features/StockCounts/. Natural home: siblings of FinalizeStockCount/, cf. Features/Adjustments/{Update,AddLine,UpdateLine,RemoveLine,RemoveLines} |
A count is immutable from the instant it is created. The whole count arrives in the opening POST and the only subsequent action is finalize. Every yardstick system treats a count as a working document filled and corrected over time (RMS/RMH Open → entry → Calculate → Close, with ad-hoc add/delete). A mis-keyed quantity in a 500-line count is uncorrectable — the only remedy is to finalize the wrong count, posting wrong stock and a wrong adjustment, then manually post a corrective Adjustment. Scored T1 for parity with Adjustments, where the same capability is T1 Implemented (ADJ-F005) |
| ID | Name | Description | Source | Status | Evidence | Notes |
|---|---|---|---|---|---|---|
| SC-F012 | Server-side count-scope generator | Build the count list from a scope (all-at-location / category / supplier / brand / not-counted-since) | RMS+RMH+market | Missing | Caller must enumerate every variant — Items is NotEmpty() (StartStockCountEndpoint.cs:44) and the handler counts only what was passed (:125). No scope column exists (InitialMerchantSchema.cs:1209-1239). Natural home: a scope object on the start request + a server-side variant query |
RMS/RMH generate the count worksheet from exactly these criteria (incl. blank sheets and not-counted-since). Without it, "count this store" means the client must first fetch and post every SKU |
| SC-F013 | Full-count mode | Treat uncounted items as zero, not as untouched | RMS+RMH+market | Missing | Finalize only touches submitted lines (FinalizeStockCountEndpoint.cs:110-113); nothing enumerates un-submitted variants at the location |
This is precisely what separates a full physical inventory from a cycle count. QIIUB supports only the cycle-count shape — corroborated by the design doc naming a second "Physical Count" reason that no code creates (§8-C6) |
| SC-F014 | Print / export a count sheet | Produce the paper or PDF sheet counters work from | RMS+RMH+market | Missing | No print/PDF surface (repo-wide ADR-0070 print gap); natural home: the ADR-0070 print pipeline | Table-stakes in both reference systems (RMH adds configurable layout — SC-F036) |
| SC-F015 | Import counted quantities from a file | Load counts from CSV/delimited file or a handheld export | RMS+RMH+WIDS365+market | Missing | Only POST /stock-counts JSON (StartStockCountEndpoint.cs:67); no import endpoint. Natural home: a bulk-ingest endpoint modelled on WIDS365's web_AdjustmentDetailUpsertByJson (MERGE-idempotent, per-row named errors) |
RMS/RMH accept delimited + XML; RMH adds merge modes and an error preview. The single most reusable idea from WIDS365 |
| SC-F016 | Barcode-scan capture | Count by scanning items rather than typing variant identifiers | RMS+RMH+market | Missing | Request accepts ProductVariantPublicId only (StartStockCountEndpoint.cs:22) — no barcode resolution, although ProductBarcode exists and is even sync-enabled (SyncConstants.cs:13) |
Scan entry is how counts are actually performed. Market table-stakes |
| SC-F017 | Valued variance (shrinkage in money) | Cost the difference so the loss has a dollar figure | RMS+RMH+market | Missing | grep "UnitCost|UnitPrice" src/QIIUB.Api/Features/StockCounts/ → zero hits. Both InventoryAdjustmentLine.UnitCost (:17) and InventoryMovement.UnitCost (:18) are non-nullable decimal, so every count-generated line and movement persists at 0.0000. The manual path does it right — CreateAdjustmentEndpoint.cs:186 UnitCost = lpCost.Cost ?? variant.Cost |
The module's most consequential defect. "How much inventory did we lose to shrink this quarter" — the single most-asked question of a physical count — is unanswerable from QIIUB data. RMH values variance by cost explicitly; the RMS/RMH Type=7 count journal carries cost. In-module gap, not a Reports gap (§2) |
| SC-F018 | Reason coding on the variance | Explain why each counted difference occurred | RMS+RMH | Missing | No reason field on StockCountItem (:12-17). Natural home: a nullable ReasonCodeId on the count line, resolved per line at finalize instead of one blanket header reason |
Demoted from Partial by the adversarial pass (guard b). The only code that would have earned the half-credit is the fixed CYCLE-COUNT constant (FinalizeStockCountEndpoint.cs:127, :133) — and that is already the credited behavior of SC-F006, so crediting it again here double-counts. The actual capability is wholly absent: RMS/RMH carry ReasonCodeID per count entry plus a store-level "reason required" rule, so a count there can separate breakage from theft from mis-scan; QIIUB cannot distinguish them at all. Same correction the Adjustments pass applied to ADJ-F014 |
| SC-F019 | Who counted / who closed | Attribute the count and its closure to a person | RMS+RMH+market | Missing | EmployeeId (InitialMerchantSchema.cs:1218) and ClosedByEmployeeId (:1223) exist as columns with no FK (schema-data.json IsForeignKey:false) and no writer — grep EmployeeId src/QIIUB.Api/Features/StockCounts/ → zero hits; FinalizeStockCountEndpoint.cs:95-97 sets only Status + ClosedDate. Wired siblings for reference: ClosePurchaseOrderEndpoint.cs:108, CloseShiftEndpoint.cs:142 |
Reserved schema with no logic ⇒ Missing per the status definition and the Adjustments precedent (adjustments-scorecard.md:249). RMS/RMH stamp CashierID on 100% of the 40,120 Type=7 count-journal rows sampled. No accountability today, and it blocks any counter-accuracy metric |
| SC-F020 | Recount / second pass | Re-count discrepant items and supersede the first figure | RMH+market | Missing | No pass/attempt/recount column on StockCountItem (:12-17), and decisively no endpoint can revise a line (SC-F011). Natural home: a pass number on the line + a recount endpoint |
Today a recount means starting an entirely new count document |
| SC-F022 | Concurrent-movement handling | Detect, warn about, or reconcile stock that moves while the count is open | RMH+market | Missing | Finalize reads the Variance stored at start (FinalizeStockCountEndpoint.cs:110-113) and never re-reads on-hand; nothing detects drift, warns, offers a re-snapshot, or blocks the movement |
The arithmetic happens to survive because posting is a relative delta (:212 +=) and deltas compose — but there is no way to express "the shelf is authoritative, set it to 98", and no audit signal at all that the count window was contaminated. An all-day count with the register open silently blends counted and transacted stock. This is exactly the RMH 3.9.19 refinement Adjustments parks at adjustments-scorecard.md:328 |
| SC-F023 | Last-counted tracking per item | Record when each item was last counted, to drive coverage and scheduling | RMS+RMH | Missing | No such column on LocationProduct; IX_StockCountItem_MerchantId_ProductVariantId exists (InitialMerchantSchema.cs:4759-4760) but no endpoint queries counts by variant |
RMS/RMH maintain Item.LastCounted; it is what makes "not counted since |
| SC-F024 | Multi-counter / team counting | Split one count across several people, each with their own sheet | RMS+RMH | Missing | One nullable EmployeeId on the header (SC-F019), nothing per line (StockCountItem.cs:12-17) |
RMS/RMH support copying a count per counter and then reconciling |
| SC-F025 | Variance-only review | Show just the discrepant lines before committing | RMH+market | Missing | GetStockCountEndpoint.cs:84-93 returns all lines undifferentiated; no variance filter or flag on the projection |
RMH filters to discrepancies and highlights them. Finalize internally filters Variance != 0 (:111) but nothing exposes that view to a human first |
| SC-F026 | Role gating | Only authorized roles can open and finalize counts | RMS+RMH+market | Implemented | Roles("SystemAdmin","Admin") on all four endpoints |
Coarse RBAC — the market-standard control is present, matching how Adjustments scores the same capability (ADJ-F011). But a Manager cannot count at all, and RMH has a dedicated count permission; noted in §7 rather than scored separately |
| SC-F027 | Count audit trail | An append-only, attributable record of what the count did | RMS+RMH+WIDS365+market | Partial | Movement trail carries ReferenceType="StockCount", ReferenceId, DocumentNumber back to the count (FinalizeStockCountEndpoint.cs:185-187); structured logs at StartStockCountEndpoint.cs:142-143 and FinalizeStockCountEndpoint.cs:241-243 |
What works: the ledger is traceable to the originating count and is append-only. Missing: no attribution is exposed on any response (GetStockCountEndpoint.cs:13-26 has no created-by/closed-by — Adjustments exposes both), and the finalize flip uses ExecuteUpdateAsync, which bypasses the EF audit interceptor, so UpdatedByUserId is not stamped on the transition and a reader sees the stale create-time value (reproduced live — §5c). RowVersion was removed from this row's credit by the adversarial pass: it is inherited base infrastructure (SC-D10), not in-module work. This is the softest Partial in the scorecard — demoting it to Missing would give 38%. |
| ID | Name | Description | Source | Status | Evidence | Notes |
|---|---|---|---|---|---|---|
| SC-F021 | Blind count | Withhold the expected quantity from the person counting | QIIUB-original | Missing | GetStockCountEndpoint.cs:90 always projects SystemQuantity; no mode field on the start request (:13-24), no column (InitialMerchantSchema.cs:1209-1239), no gated projection. Natural home: a CountMode column + a gated projection variant |
Whitespace, not catch-up. Scored QIIUB-original because no scanned product documents an enforced blind mode (Clover/Stocky/Cin7/Odoo absent; Square/Lightspeed/NetSuite unevidenced) and RMS/RMH achieve it only by omitting the expected-quantity column from the printed sheet. The expectation is internal: the platform already has the vocabulary on the cash side — ADR-0027 "Three shift close patterns (Full, Blind, Deferred Count)" — and never applied it to stock. Tiered T3 on market prevalence; a reviewer could argue T2 on audit necessity |
| SC-F028 | Scheduled / recurring cycle counts | Auto-generate counts on a cadence so coverage is systematic | market | Missing | No schedule entity anywhere; natural home: a count-schedule table + a background job. Needs SC-F023 first | ERP tier (NetSuite plan templates + due-date engine; Odoo per-location Inventory Frequency auto-setting the next count date). Absent from RMS/RMH and from every POS-tier product scanned. Contested tier, resolved against the source: the ERP gathering artifact explicitly recommended scoring this "top of T2", on the grounds that both ERPs converge on count-due scheduling by different mechanisms. That recommendation is rebutted here — T2 is defined as "the modern market broadly expects it," and the expectation fails at the POS tier entirely (unevidenced in Square and Lightspeed, absent in Clover, Stocky and Cin7) as well as in both reference systems. Two ERPs converging is evidence of ERP consensus, not of broad market expectation. The adversarial pass reviewed and upheld T3 |
| SC-F029 | ABC-class / risk-driven selection | Choose what to count by value class or last-counted date | market | Missing | No ABC or velocity classification on Product/ProductVariant; natural home: a classification column feeding SC-F012 |
ERP tier; also absent from RMS/RMH beyond the not-counted-since filter |
| SC-F030 | Variance approval / tolerance threshold | Require sign-off, or auto-accept only within a tolerance band | market | Missing | Finalize posts immediately (FinalizeStockCountEndpoint.cs:93-97) and the adjustment is born Completed (:153), skipping Draft review entirely. :111 filters Variance != 0 with no tolerance band |
Not purely ERP tier — the adversarial pass caught the original "ERP tier" framing being falsified by this scorecard's own source: Square ships a confirmed approval gate on stocktakes, and Cin7 has a Draft→Complete confirm step. It is absent from RMS/RMH, and tolerance thresholds remain unevidenced anywhere. Kept T3 on overall prevalence, but the honest read is "present at the top of the POS tier and in ERP, absent in the reference systems" — the most defensible control gap for an auditable count |
| SC-F031 | Freeze transactions during a count | Block or quarantine movement at the location while counting | market | Missing | No freeze column (InitialMerchantSchema.cs:1209-1239); StockCount is referenced by no write path outside its own feature folder |
ERP tier; absent from RMS/RMH. The heavyweight alternative to SC-F022's detect-and-warn |
| SC-F032 | GL / journal posting of the write-off | Post shrinkage expense to accounting | market | Missing | No accounting integration; blocked behind SC-F017 (a $0 variance cannot be journalled) | ERP tier (NetSuite/Odoo). RMS/RMH also lack per-count GL posting — parity with the reference systems |
| SC-F033 | Bin / shelf granularity | Count by bin or shelf location within a store | RMS+RMH+market | Missing | StockCountItem has no bin column (InitialMerchantSchema.cs:2315-2332); no bin concept exists in the platform (cf. the damage-bin also Missing at adjustments-scorecard.md:249) |
RMS/RMH store BinLocation per count entry (though not as a scope criterion). Needs a platform-level bin model first |
| SC-F034 | Serial / lot / expiry-aware counting | Count identified units or track expiry while counting | market | Missing | No serial/lot model on ProductVariant; platform-level prerequisite |
ERP tier; absent from RMS/RMH. Matters for the Rx vertical (expiry) more than Shop |
| SC-F035 | Merge multiple counts | Combine several count documents into one reconciliation | RMH | Missing | No merge endpoint; a count is standalone (FinalizeStockCountEndpoint.cs operates on one countInfo.Id) |
RMH-specific refinement, paired with multi-counter counting (SC-F024) |
| SC-F036 | Configurable sheet layout + count reports | Tailor the printed sheet and produce detailed/summary count reports | RMH | Missing | Blocked behind SC-F014 (no print surface at all) | RMH adds layout configuration and Detailed/Summary reports on top of the base sheet |
| SC-F037 | Count-progress tracking | Show how much of the intended scope has been counted | market | Missing | ListStockCountsResponse (ListStockCountsEndpoint.cs:22-28) carries ItemCount but no expected-scope denominator — without SC-F012 there is no scope to measure against |
Market convenience for long counts |
| SC-F038 | Reverse / unpost a finalized count | Undo a posted count in one action | market | Missing | No inverse of the CAS flip (FinalizeStockCountEndpoint.cs:93-97); Completed is terminal. Natural home: a compensating-adjustment endpoint |
Mirrors ADJ-F016. Today the remedy is a hand-built offsetting Adjustment |
| SC-F039 | Offline count capture on the terminal | Count on a POS device with no connectivity, sync later | QIIUB-original | Missing | StockCount/StockCountItem are absent from SyncConstants.EntityTypes (SyncConstants.cs:9-33), which gates every sync endpoint via IsAllowedEntity (:37); grep -rni "stockcount" apps/qiiub-pos/src → zero hits |
QIIUB's own stated ambition, and its clearest differentiator opportunity — counting a back room is exactly where connectivity fails. Two design docs already claim this works (offline-function-matrix.md:80, 21-pos-client.md:79); it does not (§8-C4/C5). Absent from RMS/RMH and from POS-tier market products |
| SC-F040 | Count in a different unit of measure | Count a case or box and have it convert to the stocking unit | RMH+market | Missing | The count line carries a bare decimal with no UoM reference (StockCountItem.cs:16); natural home: a CountUoMId + conversion snapshot on the line, mirroring PurchaseOrderLine.PurchasingUoMId + UnitsPerPurchaseUoM |
Added by the adversarial pass, which correctly held that ceding this to the Units of Measure module would break the PO precedent: PO scores line-level UoM in-module (PO-F020 Implemented, with only the catalog as a dependency). A counter facing a pallet of 24-packs must do the arithmetic by hand |
Dependencies (excluded from score — listed so the boundary is visible):
| ID | Capability | Owner | Note |
|---|---|---|---|
| SC-D01 | InventoryAdjustment document type + manual Draft→Completed machine |
Adjustments | adjustments-scorecard.md:70; finalize instantiates it, does not own it |
| SC-D02 | ReasonCode catalog (CRUD, merchant-scoped) |
Reason Codes | ReasonCode.cs:5-6; shared with Return/Void/Discount/AR. The lazy auto-create is scored SC-F006 |
| SC-D03 | InventoryMovement append-only ledger + Movements Log UI |
Inventory core | InventoryMovement.cs; MovementsLogPage.tsx surfaces Count movements |
| SC-D04 | LocationProduct.QuantityOnHand row + QoH concurrency helper |
Inventory core (ADR-0020) | MerchantDbContextConcurrencyExtensions.cs; shared by 5+ writers |
| SC-D05 | Aggregate shrinkage / variance-by-period report | Reports | Contestable — see §9. No such report exists on either side (grep -rni "stockcount|variance|shrink" src/QIIUB.Api/Features/Reports/ → zero hits) |
| SC-D06 | DocumentSequence service |
shared-infra | DocumentSequenceService.cs:22; the use is scored SC-F007 |
| SC-D07 | Tax-group resolution for a newly-created LocationProduct |
Tax | ILocationProductTaxResolver; FinalizeStockCountEndpoint.cs:161, :216-218 |
| SC-D08 | Sync push/pull transport | Sync | src/QIIUB.Api/Features/Sync/ exists; simply not wired for counts. The in-module gap is SC-F039 |
| SC-D09 | Employee identity (table, PIN) |
Employees | What the reserved EmployeeId (InitialMerchantSchema.cs:1218) would reference |
| SC-D10 | User attribution base columns (ADR-0062) | Platform | BaseMerchantEntityConfiguration.cs:34-36; inherited infra, not in-module credit |
N/A — not applicable to QIIUB (excluded; reason given):
| ID | Capability | Why N/A |
|---|---|---|
| SC-N01 | HQ/Central count-worksheet distribution + push-back (RMS/RMH Worksheet 501, Task 190, Worksheet 307) | QIIUB runs one cloud database; a merchant-wide user counts any location directly, so there is no HQ/Central-to-store worksheet broadcast or reconciliation tier. Met-differently-by-architecture — the same call as the PO-Planner / Distribution / Adjustments HQ N/As |
| SC-N02 | Central replication / consolidation of count documents | Same single-database reason as SC-N01. RMS/RMH need it because store and Central are separate databases |
| SC-N03 | Central item-management lockdown during an open count (RMH) | A control that exists to protect the HQ↔store replication window SC-N01/N02 removes |
| SC-N04 | WIDS365 AnalyticsItemStockCount dashboard tile |
Name collision, not a count feature — fn_GetItemStockCountAnalytics is SUM(ItemDynamic.Quantity), an on-hand aggregate. Crediting it would be a false positive |
| SC-N05 | Matrix / kit-specific count behavior | Not documented in any source family — the RMS/RMH sweep found it specified in neither system, and no market product documents it. With no source expecting it there is nothing to measure against. Flagged for a human: if the in-flight product bundle-types work lands a real kit model, this should become a T3 row on the next re-score rather than staying N/A |
StockCount (aggregate root) — src/QIIUB.Domain/Merchants/StockCount.cs
BaseEntity (:11) — soft-deletable, auditable, RowVersion. Not BaseImmutableEntity; a completed count is logically immutable but enforced only by the absence of any mutating endpoint, not by type.PublicId scnt_… (:13), DocumentNumber varchar(20) (:14), LocationId (:15), EmployeeId? reserved/unwired (:16), Status tinyint (:17), CountDate datetime2(3) (:18), Notes nvarchar(500)? (:19), ClosedDate datetime2(3)? (:20), ClosedByEmployeeId? reserved/unwired (:21). Base columns per BaseEntity.cs:8-16.(MerchantId, Id); composite FK→Location MerchantId-first with DeleteBehavior.Restrict (StockCountConfiguration.cs:18-22); unique UQ_StockCount_MerchantId_DocumentNumber (:25-27); IX_StockCount_MerchantId_LocationId; unique UQ_StockCount_MerchantId_{GlobalId,PublicId}.EmployeeId and ClosedByEmployeeId — no FK is configured for either (contrast ShiftConfiguration.cs:51, which does wire ClosedByEmployeeId), independently corroborated by the generated docs/html/schema-data.json reporting "IsForeignKey": false. → SC-F019 Missing.StockCountItem (child line) — StockCountItem.cs
PublicId scln_… (:12), StockCountId (:13), ProductVariantId (:14), SystemQuantity decimal(18,4) (:15), CountedQuantity decimal(18,4) (:16), Variance decimal(18,4) (:17, stored, not computed).StockCount and ProductVariant, both MerchantId-first, both Restrict (StockCountItemConfiguration.cs:16-26); IX_StockCountItem_MerchantId_StockCountId (:29-30); IX_StockCountItem_MerchantId_ProductVariantId.UnitCost / UnitPrice — contrast InventoryAdjustmentLine.cs:17-18 and InventoryMovement.cs:18-19, which both have them. This absence is the root of SC-F017.Missing rather than partially served.UX_StockCountItem_MerchantId_StockCountId_PublicId (:34-36) exists explicitly to serve the nested route /stock-counts/{sc}/items/{itemPid} named in its own comment (:32-33) — that endpoint does not exist (§8-C3). A paid-for index with no query behind it.Referenced (not owned): InventoryAdjustment + InventoryAdjustmentLine (→ Adjustments), ReasonCode (→ Reason Codes), InventoryMovement + LocationProduct (→ Inventory core), Location, ProductVariant. MerchantId-first composite FKs and tenant-first composite PKs verified throughout (Critical Rule #1).
| Surface | Verb + Route | Impl (file:line) | Realizes | Roles | Notes |
|---|---|---|---|---|---|
| API | POST /api/stock-counts |
StartStockCountEndpoint.cs:67 |
F001–F004, F007 | SysAdmin/Admin | Opens the count and submits every counted quantity in the same call; snapshots system qty; computes and stores variance; 201 + Location header |
| API | GET /api/stock-counts |
ListStockCountsEndpoint.cs:66 |
F009 | SysAdmin/Admin | Paged; filter location + status; 6 sorts |
| API | GET /api/stock-counts/{PublicId} |
GetStockCountEndpoint.cs:57 |
F008 | SysAdmin/Admin | Header + lines; discontinued-product safe (:70) |
| API | POST /api/stock-counts/{PublicId}/finalize |
FinalizeStockCountEndpoint.cs:50 |
F005, F006, F010 | SysAdmin/Admin | The posting op: CAS flip + movements + QoH upsert + auto-adjustment, one transaction |
grep -rn "stock-counts" apps/ returns zero hits — no route, no page, no API-client method, no React Query hook, no TypeScript type in either app. There is no stockCounts feature folder in apps/qiiub-portal/src/features/ (25 folders; every neighbor in the count's blast radius has one — adjustments, inventory, reasonCodes, priceChanges, transfers). The only frontend artifact is InventoryHubPage.tsx:94 { key: 'stockCounts', icon: ClipboardCheck } — with no href, which the file's own discriminated union (:63-73) routes to DisabledCardTile (:137, :198) rendering a "Coming soon" badge (:211). i18n exists for exactly those two hub strings and nothing else. A merchant cannot perform a physical count through any QIIUB interface today. Per the framework (and the Distribution precedent), §6 scores capability, so this does not lower the % — but it is the #1 practical concern (§7).SyncConstants.EntityTypes (SyncConstants.cs:9-33), so a count can be neither pushed from nor pulled to a terminal — scored as SC-F039.grep -rln "StockCount" src/QIIUB.Api/Features/DevSeed/ → zero files, so even a dev/QA environment has no sample count.MustBePublicId prefix checks on location, variant and count identifiers (wrong prefix → 400); Notes MaximumLength(500) matching the column exactly; CountedQuantity DecimalColumn(18,4) matching the column exactly; Items NotEmpty(); SortDirection restricted to null|asc|desc; Page >= 1; PageSize InclusiveBetween(1,100).MerchantId first in every query (verified across all four endpoints); unknown location or variant → 400 with the offending identifier; CAS status guard → 409 on a non-InProgress finalize; a deliberate 404-before-409 pre-resolve so a foreign or typo'd PublicId never leaks existence; zero-variance count posts nothing; .Local-first LocationProduct lookup preventing SQL-2601 on duplicate variants; QoH RowVersion retry.Absent — business validations (verified not present):
| Missing validation | Effect today | Tracked as |
|---|---|---|
| Duplicate-variant de-duplication on start | Two lines for the same variant both persist with the same SystemQuantity, so variance is double-counted and posted twice. .Distinct() is applied only to the resolution lookup (StartStockCountEndpoint.cs:97); the persist loop iterates raw req.Items (:125). Acknowledged in a test comment (AdjustmentAndCountTests.cs:472-473 "StartStockCount does not dedup") — the SQL-2601 symptom was fixed, the data defect was not |
§7 hardening bundle |
CountedQuantity >= 0 |
A negative counted quantity is accepted, producing a nonsensical variance | §7 hardening bundle |
Cap on Items per request |
A full-store count can post an unbounded request body (40k lines) in one call; contrast the explicit cap on adjustment batch-delete (AdjustmentAndCountTests.cs:1927) |
§7 hardening bundle |
SortBy allowlist |
An unknown SortBy silently falls through to the default instead of 400 |
folded into SC-F009 Partial |
Per the framework rule these are bundled as one §7 hardening row rather than split into micro-features — splitting would pad the denominator. The duplicate-variant item is the materially dangerous one: it silently corrupts posted stock.
The §6 % says "how complete vs a mature POS"; this says "how trustworthy is the built part." Testedness never enters §6.
tests/QIIUB.Tests.Integration/Features/Inventory/AdjustmentAndCountTests.cs:406-695. Counts are context; the signal is the matrix.| Flow | Tested |
|---|---|
Start → snapshots SystemQuantity |
✓ (:406) |
| Finalize with variance → creates adjustment + movement + QoH | ✓ (:435) |
Two lines same variant, no existing LocationProduct → aggregates (SQL-2601 regression) |
✓ (:470) |
| Zero-variance finalize → no adjustment, no movement | ✓ (:514) |
Cross-tenant: foreign PublicId → 404 on Get and Finalize |
✓ (:537, :554) |
| Already-finalized → 409 | ✓ (:586) |
Wrong-prefix PublicId → 400 (4 endpoints) |
✓ (:605, :616, :631, :646, :679) |
| List filter by location + invalid sort direction | ✓ (:656, :689) |
| Cross-merchant isolation on the list endpoint (seed A and B, assert B sees only B) | ✗ — only forged-PublicId 404s exist. The Adjustments half of the same file does have it (Adjustment_CrossMerchantIsolation, :697). This is a rule-level gap — .claude/rules/csharp.md §Testing Requirements makes cross-merchant isolation mandatory per merchant-scoped feature |
| Transaction rollback (side effect throws → status flip reverts) | ✗ — untested despite being the stated reason the transaction exists (FinalizeStockCountEndpoint.cs:66-72) |
UnitCost on generated lines/movements |
✗ — which is exactly why SC-F017 survived; the Adjustments path has these assertions (:1080, :1101) |
CYCLE-COUNT reason auto-created on first use, reused on second |
✗ — the find-or-create branch (:129-143) is unasserted |
Concurrent finalize (the retry path at :233) |
✗ |
| Duplicate-variant data correctness (not just the QoH aggregate) | ✗ |
3bfe3b23), DB restored to zero residue afterward. Start: POST /api/stock-counts with countedQuantity: 41 against on-hand 46 → 201, CNT-000002, status InProgress, and the line persisted SystemQuantity 46.0000 / CountedQuantity 41.0000 / Variance -5.0000 — confirming SC-F002/F003/F004 and that variance is computed and stored at start. Finalize: POST /api/stock-counts/{pid}/finalize → 200, varianceCount: 1, adjustmentDocumentNumber: ADJ-000003. Observed in SQL: LocationProduct.QuantityOnHand 46 → 41; one InventoryMovement with MovementType = 6 (Count), Quantity -5.0000, ReferenceType 'StockCount', DocumentNumber 'CNT-000002'; an InventoryAdjustment born Status = 1 (Completed) with reason CYCLE-COUNT and notes "Auto-generated from stock count CNT-000002"; and its line QuantityChange -5.0000 with notes "Count variance: system=46, counted=41". Confirmed end-to-end: SC-F001, F002, F003, F004, F005, F006, F007, and the InProgress → Completed half of SC-F010. Scoped down by the adversarial pass: the run drove POST + finalize and verified the results in SQL, so it does not independently prove SC-F008 (the GET detail projection was never called) and it exercised only the transition that exists — the absent Cancelled path is by definition untestable.UnitCost .0000 / UnitPrice .00, while the counted variant carries ProductVariant.Cost = 474.2600. So a 5-unit shrink of a $474.26 item was booked as $0.00 instead of $2,371.30. The manual Adjustments path would have snapshotted it via lpCost.Cost ?? variant.Cost (CreateAdjustmentEndpoint.cs:186) — LocationProduct.Cost was NULL here, so the fallback would have applied and produced the correct figure. This is the defect, confirmed, not inferred.StockCount.EmployeeId and ClosedByEmployeeId were both NULL after a successful finalize. UpdatedByUserId was populated — but with the create-time value, identical to CreatedByUserId, confirming the finalize ExecuteUpdateAsync does not stamp the transition and that a reader sees a stale attribution.CYCLE-COUNT find-or-create branch (FinalizeStockCountEndpoint.cs:129-143) is real and was exercised — the reason-code row did not exist beforehand and was created by this finalize (ReasonCode Id 62, CreatedAtUtc matching the finalize timestamp to the millisecond), with no other adjustment referencing it. §5c lists this branch as untested; the live run is the only evidence it works. It also confirms §2's boundary call: finalizing a count writes into the shared Reason Codes catalog, with no endpoint or role check on that module.QuantityOnHand reverted to 46, and DocumentSequence.NextNumber reset to its pre-test values (CNT → 1, ADJ → 2). Re-queried: all six residue counts zero.Named test gaps → one §7 "test hardening" row, led by the mandatory cross-merchant isolation test and the UnitCost assertions.
Weights T1=3, T2=2, T3=1; Implemented=1.0, Partial=0.5, Missing=0.0; Dependency/N/A excluded.
| Tier | Impl | Partial | Missing | Dep-excl | Scored | Σ value | tier% |
|---|---|---|---|---|---|---|---|
| T1 (×3) | 8 | 2 | 1 | — | 11 | 9.0 | 81.8 |
| T2 (×2) | 1 | 1 | 13 | — | 15 | 1.5 | 10.0 |
| T3 (×1) | 0 | 0 | 14 | — | 14 | 0.0 | 0.0 |
| Excluded | 10 Dep + 5 N/A |
overall% = 100 × ( 3×9.0 + 2×1.5 + 1×0.0 ) / ( 3×11 + 2×15 + 1×14 )
= 100 × ( 27 + 3 + 0 ) / ( 33 + 30 + 14 )
= 100 × 30 / 77 = 39.0% → 39%
Overall = 39% (T1 82 / T2 10 / T3 0). 40 scored features + 10 Dependency + 5 N/A = 55 mapped IDs.
This is the post-adversarial number — the first synthesis read 41%, and the pass corrected two things: SC-F018 reason-coding was demoted Partial → Missing (its only credited code was the CYCLE-COUNT constant already credited to SC-F006 — a guard-(b) double-count, the same error the Adjustments pass caught in ADJ-F014), and multi-UoM counting was reclassified from a Dependency on Units of Measure to an in-module gap (new SC-F040), because PO scores line-level UoM in-module (PO-F020) and ceding it here would have let a real gap hide behind a neighbor. The pass also upheld the two contested tier calls (SC-F021 blind → T3, SC-F028 schedules → T3) and the T1 parity argument, and rejected demoting SC-F003.
T1 is not 100% — the first module in the family where that is true, and it is the honest read rather than a harsher rubric. Two T1 capabilities that Adjustments scores as Implemented are not present here: line management (ADJ-F005 Implemented → SC-F011 Missing) and a complete status lifecycle (ADJ-F006 Implemented → SC-F010 Partial, since Cancelled has no writer). Scoring them any higher would require treating the same capability differently in two sibling scorecards.
Flat-Partial caveat. Only three Partials survive the pass, and two are thin: SC-F009 (list missing search, dates and aggregates) and SC-F027 (a trail with no attribution surfaced — the softest 0.5 in the document; demoting it would give 38%). SC-F010 is the one genuine "core-need-met, refinement-missing" Partial. The defensible range is 38–39%; read the headline as the top of that range, not a midpoint.
Anti-gaming guards (each checked):
Missing to a lighter tier is exactly the manoeuvre this guard exists to catch.EmployeeId/ClosedByEmployeeId pair earns one row (SC-F019 Missing), not two, and does not also credit SC-F027 — the base CreatedByUserId infra is Dependency SC-D10, not in-module credit, exactly as the Adjustments pass established (adjustments-scorecard.md:249). The four absent validations are bundled into one §7 row rather than split. SortBy validation is folded into SC-F009, not given its own row. SC-F014 print and SC-F036 sheet-layout are distinct (base surface vs configuration), and F036 is explicitly noted as blocked behind F014. The pass caught one violation of this guard and it was applied: SC-F018 reason-coding had been credited Partial for the CYCLE-COUNT constant that SC-F006 already credits, and is now Missing. It also confirmed the reverse direction is clean — concurrent open counts, which QIIUB permits by omission, are folded into SC-F001 rather than claimed as a feature (§9).Dependency/N/A hides an in-module gap. The most consequential call: valued shrinkage (SC-F017) is scored in-module Missing, not ceded to Reports — the defect is that this module fails to snapshot cost onto lines it authors. The five N/As are single-database architecture (N01–N03), a proven name collision (N04), or a capability no source family documents at all (N05); none conceals a QIIUB gap. The pass caught one violation here too: multi-UoM counting had been filed as a Dependency on Units of Measure, but PO scores line-level UoM in-module (PO-F020), so it is now the in-module gap SC-F040. Cancelled being a dead enum is scored as a Partial lifecycle, not dismissed.Implemented has file:line; every Partial names what works and what is missing.Reproducibility honesty. The denominator (55 mapped rows) freezes at this first synthesis; a re-score updates statuses only. Family ranking: PO 74 / Rcv 67 / Adj 63 / Trf 60 / Dist 58 / Stock Counts 39 — last, by 19 points. The shape is unusual: a genuinely well-engineered posting core (transactional, CAS-guarded, concurrency-retried, ledger-typed, and snapshotting a baseline most of the market does not) attached to almost no operational module. Stock Counts is best read as a finalize endpoint that works, and a count workflow that was never built.
| Rank | Gap (ID) | Tier | Impact | Recommendation | ADR? |
|---|---|---|---|---|---|
| 1 | No portal surface (§5) | — | Highest — practical. No merchant can count without direct API calls; the hub already advertises the feature as "Coming soon" | Build the portal list + detail + count-entry pages ADR-0081 already specifies (/inventory/counts, /inventory/counts/$id) over the existing 4 endpoints — but F011 first, since a UI over a single-shot immutable POST is barely usable |
No (UI over shipped API) |
| 2 | SC-F011 revise the count while open | T1 | A mis-keyed quantity is uncorrectable; the count must be assembled perfectly client-side in one shot | Add UpdateStockCount (header) + add/update/remove item endpoints, mirroring Features/Adjustments/ one-for-one. The unused UX_StockCountItem_…_PublicId index already exists for exactly this route |
No (extends model) |
| 3 | SC-F017 valued variance | T2 | Shrinkage cannot be measured in money — the primary business output of a physical count | Quick win — snapshot UnitCost/UnitPrice onto the adjustment lines and movements at finalize, exactly as CreateAdjustmentEndpoint.cs:186 does. Fields already exist and are non-nullable |
No |
| 4 | SC-F010 cancel / abandon a count | T1 | A started count can never be closed except by posting it | Quick win — a CancelStockCount endpoint flipping to the already-modelled Cancelled status, mirroring CancelAdjustmentEndpoint.cs |
No |
| 5 | SC-F019 who counted / who closed | T2 | No accountability on a stock-affecting operation; blocks any counter-accuracy metric | Quick win — write EmployeeId / ClosedByEmployeeId from context and add the two FKs. Note the finalize flip must stop bypassing the audit interceptor |
No |
| 6 | Validation hardening bundle (§5b) | — | Duplicate variants silently double-post stock; negative counts accepted; unbounded request body | Dedup or reject repeated variants on start; >= 0 rule; Items cap; SortBy allowlist |
No |
| 7 | SC-F012 / F013 scope generator + full-count mode | T2 | "Count this store" is not expressible; only the cycle-count shape exists | A scope object on the start request (all-at-location / category / supplier / not-counted-since) + a zero-out-uncounted mode. Needs SC-F023 last-counted for the date criterion | Requires ADR (new count semantics — and there is no ADR at all today) |
| 8 | SC-F015 / F016 import + scan capture | T2 | Counting by typing variant identifiers is not a real workflow | Bulk-ingest endpoint modelled on WIDS365's per-row-error JSON MERGE; barcode resolution over the existing ProductBarcode |
No (extends model) |
| 9 | SC-F022 concurrent-movement detection | T2 | An all-day count silently blends counted and transacted stock with no audit signal | At finalize, re-read on-hand and flag lines whose baseline moved; surface it rather than silently posting | No (extends model) |
| 10 | SC-F020 / F025 / F021 recount, variance review, blind count | T2/T3 | The count is not defensible as an audit control | Add a pass number + recount endpoint; a variance-only view; a blind mode gating the SystemQuantity projection. Blind is whitespace, not catch-up — no scanned competitor documents it, so building it would differentiate rather than close a gap |
Requires ADR (count semantics) |
| 11 | SC-F030 approval / tolerance | T3 | No control on a high-value variance; the adjustment is born Completed with no review |
Approval gate before posting — would meet-or-beat both RMS/RMH and most of the market | Requires ADR (new state) |
| 12 | SC-F039 offline count capture | T3 | QIIUB's own differentiator, already claimed by two design docs | Add StockCount/StockCountItem to SyncConstants.EntityTypes + a POS count surface; or correct the two design docs |
Requires ADR (sync entity set) |
| 13 | SC-F018 / SC-F040 per-line reason code + count UoM | T2/T3 | A count cannot say why a line differs (breakage vs theft vs mis-scan), and a counter facing 24-packs converts by hand | Both are one nullable column plus resolution on StockCountItem — ReasonCodeId (mirroring the header pattern) and CountUoMId + conversion snapshot (mirroring PurchaseOrderLine.PurchasingUoMId). Best done together with #2, since neither is reachable without line-level editing |
No (extends model) |
| 14 | Test hardening (§5c) | — | The mandatory cross-merchant isolation test is missing; UnitCost unasserted (which is why #3 survived) |
Add the isolation test, UnitCost assertions, rollback fault-injection, and the reason-code find-or-create cases |
No |
| 15 | No ADR governs count semantics (§8) | — | The module's most consequential behavior lives only in inline comments | Write the missing ADR when #7/#10 are picked up — and amend ADR-0081, which mis-states the endpoint and over-claims delivered scope | Requires ADR |
At measurement (3bfe3b23) a search for stock count / physical count / cycle count / shrinkage / variance returned no open issue tracking any of these gaps — every one was untracked. #289 PublicId Audit Phase 2 is tangential (touches many modules).
Two issues were filed from this analysis before merge, so the four highest-value gaps are now tracked:
| Issue | Type | Covers | Priority |
|---|---|---|---|
| #1030 | bug |
The §5b duplicate-variant double-post — StartStockCount persists raw req.Items while .Distinct() applies only to the resolution lookup, so duplicate lines share a baseline and post the variance twice. Filed separately because it is the only finding that silently corrupts posted stock |
P1 |
| #1031 | tech-debt |
SC-F017 valued variance · SC-F010 cancel / dead Cancelled enum · SC-F019 counter attribution — three small, mostly-already-modelled gaps one PR can close together |
P2 |
Everything else in §7 remains untracked, including the whole T3 tier and the §7 #1 no-portal-surface gap. The stable IDs are the join key: reference them (e.g. SC-F017) in any future issue body so this section can link gap ↔ ticket on the next re-score, without which this table rots into a manual snapshot.
Eight doc-vs-code contradictions, both sides cited. The headline finding is a negative one: no ADR governs how physical counts work in QIIUB — not blind vs informed, not snapshot vs recompute, not freeze semantics, not partial vs full scope, and not the variance→adjustment pipeline, which is the module's most consequential behavior and exists only as inline comments (FinalizeStockCountEndpoint.cs:66-72, :190-198) and an entity XML doc (StockCount.cs:8-9). For a module whose finalize mutates on-hand stock and mints two documents, that is itself worth surfacing.
docs/adr/0081-inventory-hub-information-architecture.md:22 specifies POST /api/stock-counts/{id}/close; the code exposes /finalize (FinalizeStockCountEndpoint.cs:50). The same line advertises "CRUD" — there is no U and no D (§3 SC-F011).:47-48 names /inventory/counts → StockCountsListPage and /inventory/counts/$id → StockCountDetailPage ("navy hero + Tabs + close flow"). Neither exists (grep -rn "inventory/counts" apps/qiiub-portal/src/ → zero hits). The ADR frames these as one of the hub's "five owned sub-routes" — four shipped, this one did not, and the accepted ADR was never amended, so it overstates delivered scope.StockCountItem as an API-addressable Category-2 editable line; it is neither. docs/adr/0082-uniform-public-id-strategy.md:52 groups it with the 10 editable child lines addressable via nested routes. The PublicId, the backfill (AddPublicIdToMerchantAggregateRootsAndLines.cs:513) and the composite index (:584-586) all shipped, and StockCountItemConfiguration.cs:32-33 names the intended route — but no such endpoint exists and the line is not editable at all. Infrastructure paid for, capability absent.offline-function-matrix.md:80 claims counts work offline. "Stock count | Queued | ... StockCount and StockCountItem created locally. Submitted on sync." Refuted: neither entity is in SyncConstants.EntityTypes (SyncConstants.cs:9-33), there is no local POS table, and grep -rni "stockcount" apps/qiiub-pos/src → zero hits. The clause "auto-adjustment generated server-side" is true; the offline half is not.21-pos-client.md:79 claims the POS pushes counts. "Push: sales, shifts, cash movements, customer creations, stock counts." Same refutation as C4.inventory-supply-chain.md:496 names a second reason code that is never created. It says variance auto-generates an adjustment with reason 'Cycle Count' or 'Physical Count'. The code only ever looks up or creates "CYCLE-COUNT" (FinalizeStockCountEndpoint.cs:127, :133); there is no branch that would choose. Consistent with SC-F013 — QIIUB has no full physical-count mode, only the cycle-count shape.PROGRESS.md:316 bundles the endpoint count. "StockCounts + PriceChanges bundled (9)" — Stock Counts contributes 4; the other 5 are PriceChanges. Not a defect, but "9" should not be read as this module's surface.Completed vs Finalize. The status and the entity doc say "completed"; the endpoint and route say finalize. Cosmetic, same drift class as C1, worth normalizing if the module is revisited.Decisions needed from a human:
Cancelled — wire it or drop it? Leaving a status that can be filtered but never occur is worse than either.BaseEntity; immutability rests solely on the absence of endpoints.Every capability in the 4-source inventory mapped to a disposition. scored = own §3 row; folded = subsumed; N/A = out of scope; dep = neighbor. Sources: artifact-stock-counts-gathering-{rmhrms,wids365,repo}.md + artifact-stock-counts-market-{pos,retail,erp}.md.
| Source inventory line | Disposition | Where |
|---|---|---|
| RMS/RMH — table-stakes in both | ||
Count document + Open → Calculated → Closed lifecycle |
scored (Partial) | SC-F010 |
| Count reference number + description | scored | SC-F007 (number); description folded into SC-F001 Notes |
| Scoped generation: all / dept / category / supplier / filter / not-counted-since / blank sheet | scored (Missing) | SC-F012 |
| Ad-hoc add and delete of count lines | scored (Missing) | SC-F011 |
| Manual quantity entry | scored | SC-F002 |
| Scan entry | scored (Missing) | SC-F016 |
| Delimited / XML import | scored (Missing) | SC-F015 |
| Per-line count time | folded | covered by SC-F027 (audit trail); no per-line timestamp beyond CreatedAtUtc |
| Per-line bin | scored (Missing) | SC-F033 |
| Per-line cost | scored (Missing) | SC-F017 |
| Print count sheet | scored (Missing) | SC-F014 |
Calculate variance |
scored | SC-F004 |
Commit posts the differential to on-hand |
scored | SC-F005 |
InventoryTransferLog Type=7 count journal |
scored | SC-F005 (movement) + dep SC-D03 (the ledger table) |
Who-committed on the journal (CashierID, 100% populated) |
scored (Missing) | SC-F019 |
| Cost on the journal | scored (Missing) | SC-F017 |
| Reason code per count line + store-level "reason required" rule | scored (Missing) | SC-F018 |
| Copy a count per counter | scored (Missing) | SC-F024 |
| Count history | scored (Partial) | SC-F027 |
Item.LastCounted |
scored (Missing) | SC-F023 |
| HQ/Central Worksheet 501 + Task 190 reconciliation + discrepancy report | N/A | SC-N01 |
| Worksheet 307 push-back to stores | N/A | SC-N01 |
| RMS/RMH — advanced (mostly RMH) | ||
| Merge of multiple counts | scored (Missing) | SC-F035 |
| Recount / second pass | scored (Missing) | SC-F020 |
| Discrepancy-only filter + highlighting | scored (Missing) | SC-F025 |
| Variance valued by cost (explicit in RMH) | scored (Missing) | SC-F017 |
| Configurable sheet layout + Detailed/Summary reports | scored (Missing) | SC-F036 |
| CSV export for handhelds | folded | covered by SC-F015 (import/export of counted quantities) |
| Import merge modes + error preview | folded | covered by SC-F015 |
| Exclude-inactive option (RMH 3.11.16) | folded | covered by SC-F012 (scope generator); QIIUB's inverse behavior — deliberately including discontinued products — is credited in SC-F008 |
| Auto-reconciliation of movement while the count is open (RMH 3.9.19) | scored (Missing) | SC-F022 — the line Adjustments parks at adjustments-scorecard.md:328 |
| Central item-management lockdown during Worksheet 501 | N/A | SC-N03 |
| Named count role permission (RMH) | folded | covered by SC-F026 (role gating); QIIUB's RBAC is coarser, noted there and in §7 |
| RMS/RMH — absent in both (so not a parity gap; market-driven only) | ||
| Enforced blind count | scored (Missing) | SC-F021 — sourced market, explicitly noted as absent from RMS/RMH |
| Per-line who-counted attribution | scored (Missing) | SC-F024 (team counting) + SC-F019 (header attribution) |
| Variance tolerance / approval routing | scored (Missing) | SC-F030 |
| Scheduled / recurring cycle counts | scored (Missing) | SC-F028 |
| Sales freeze during a count | scored (Missing) | SC-F031 |
| Serial / lot / expiry-aware counting | scored (Missing) | SC-F034 |
| Bin/aisle as a scope criterion | folded | covered by SC-F033 (bin granularity) + SC-F012 (scope) |
| Count sheets distributed from HQ/Central | N/A | SC-N01 |
| Count documents replicated / consolidated centrally | N/A | SC-N02 |
| Counting from the POS or a native mobile app | scored (Missing) | SC-F039 (offline POS capture) + SC-F016 (scan) |
| WIDS365 | ||
| No physical/cycle count entity at all (18 evidence-backed negatives) | — | Contributes no capability line to the denominator; the negative result is stated in §3 and is why WIDS365 is a wider subset here than in the previous five modules |
Counting done via generic AdjustmentHeader/AdjustmentDetail, signed delta, operator computes variance |
dep | SC-D01 — QIIUB's equivalent is the auto-generated adjustment, scored SC-F006 (strictly ahead of WIDS365 here) |
Structured bulk ingest web_AdjustmentDetailUpsertByJson (MERGE-idempotent, ItemID-or-ItemCode, per-row errors, clipboard + .txt/.csv/.tsv) |
scored (Missing) | SC-F015 — named as the recommended model |
| Temporal system-versioned audit on header and detail | scored (Partial) | SC-F027; QIIUB uses the movement ledger + RowVersion instead of temporal tables (ADR-0023) |
RecordDeletedLog deletion audit with full payload |
folded | covered by SC-F027; QIIUB has no count-delete path at all (SC-F011), so there is nothing to audit yet |
Granular per-action permissions (Approve declared but unimplemented) |
folded | covered by SC-F026; the unimplemented Approve corroborates SC-F030 being rare below ERP |
AnalyticsItemStockCount dashboard tile |
N/A | SC-N04 — name collision, an on-hand SUM, not a count feature |
| Market — table-stakes tier | ||
| Open a count / stocktake document | scored | SC-F001 |
| Capture counted quantity | scored | SC-F002 |
| Automatic variance calculation | scored | SC-F004 |
| Post to on-hand (adjustment record / movement ledger / direct set) | scored | SC-F005 + SC-F006 |
| Partial / cycle count vs full stocktake | scored (Missing for full) | SC-F013; the cycle shape is SC-F001 |
| Count scope filters (category / brand / supplier / tag / location) | scored (Missing) | SC-F012 |
| Mobile app / barcode-scanner count entry | scored (Missing) | SC-F016 |
| Count-sheet print / PDF | scored (Missing) | SC-F014 |
| CSV import / export of counted quantities | scored (Missing) | SC-F015 |
| Variance valuation (cost impact / shrinkage $) | scored (Missing) | SC-F017 |
| Count history / audit trail | scored (Partial) | SC-F027 |
| Who-counted attribution | scored (Missing) | SC-F019 |
| Recount / second pass | scored (Missing) | SC-F020 |
| Blind vs informed counting | scored (Missing) | SC-F021 |
| Reconciling sales that happen mid-count | scored (Missing) | SC-F022 |
| Multi-user simultaneous counting | scored (Missing) | SC-F024 |
| Reason codes on variance | scored (Missing) | SC-F018 |
| Count-progress tracking (% counted) | scored (Missing) | SC-F037 |
| "Uncounted items → zero or leave alone" policy | scored (Missing) | SC-F013 |
| Role / permission gating | scored | SC-F026 |
| Multi-location rollups | folded | covered by SC-F012 scope + SC-D05 reporting; QIIUB's single database makes cross-location querying native, so there is no rollup mechanism to miss |
| API access to counts | scored | SC-F001/F005/F008/F009 — the API is QIIUB's only surface (§5) |
| Offline counting | scored (Missing) | SC-F039 |
| Market — advanced / ERP tier | ||
| Scheduled / recurring cycle counts + count schedules | scored (Missing) | SC-F028 |
| ABC-class / last-counted-driven selection | scored (Missing) | SC-F029 |
| Variance approval workflow + tolerance thresholds | scored (Missing) | SC-F030 |
| Freezing transactions during a count | scored (Missing) | SC-F031 |
| GL / journal posting of the write-off | scored (Missing) | SC-F032 |
| Serial / lot / expiry-aware counting | scored (Missing) | SC-F034 |
| Bin-level counting | scored (Missing) | SC-F033 |
| Count templates / persisted count plan (NetSuite) | folded | covered by SC-F012 (a saved scope is a template) |
| Reverse / unpost a posted count | scored (Missing) | SC-F038 |
| Vendor / family / group / subsidiary / department scope (NetSuite) | folded | covered by SC-F012 |
| Include zero-quantity rows in the count (NetSuite) | folded | covered by SC-F012; QIIUB's GetValueOrDefault(variantId, 0) (StartStockCountEndpoint.cs:128) already tolerates a never-stocked variant, credited in SC-F003 |
| Bin-order / count-sequence sorting of the sheet (NetSuite) | folded | covered by SC-F033 (bin granularity) + SC-F014 (sheet) |
| Inventory-status-aware counting (NetSuite) | folded | covered by SC-F034 (serial/lot/status-aware counting) |
| Recalculate Snapshot after movement (NetSuite) | folded | covered by SC-F022 — NetSuite's answer to the exact drift problem QIIUB leaves unguarded |
| Item locked from a second concurrent count (NetSuite) | folded | covered by SC-F031 (freeze/lock); QIIUB has no lock of any kind |
| Location locked / trading stopped during stocktake (Cin7) | folded | covered by SC-F031; noted in §3 as unavailable to a POS |
| Multi-UoM per counted line (NetSuite); RMH purchase/stocking unit split | scored (Missing) | SC-F040 — reclassified from a Units-of-Measure dependency by the adversarial pass, per the PO-F020 in-module precedent |
| Negative on-hand permitted at count (Lightspeed R) | folded | covered by the §5b hardening bundle — QIIUB's inverse gap is that it accepts a negative counted quantity |
| "Zero All" / "Zero out missed" bulk actions (Stocky) | folded | covered by SC-F013 (full-count mode) |
| Activity log with user + timestamp (Cin7) | scored (Partial/Missing) | SC-F027 (trail) + SC-F019 (who-counted) |
| Variance Financials tab + GL account (Cin7) | scored (Missing) | SC-F017 (valued variance) + SC-F032 (GL posting) |
| Clover core ships no stocktake (count/audit belongs to Clover Sport, a separate product) | — | Contributes no capability line; recorded because it sets the POS-tier floor and is why "nothing is universal across the POS tier" (§3) |
| Stocky retirement 2026-08-31 shrinking the Shopify baseline | — | Dated market fact, not a capability; recorded in §3 and the Appendix so a future re-score does not benchmark against a withdrawn product |
| QIIUB-original sweep | ||
Offline-first count capture on the terminal (offline-function-matrix.md:80, 21-pos-client.md:79) |
scored (Missing) | SC-F039 — and the docs contradict the code (§8-C4/C5) |
Auto-generated correction document from variance (StockCount.cs:8-9; inventory-supply-chain.md:496) |
scored | SC-F006 — QIIUB does this automatically where WIDS365 requires hand-computed deltas |
Movement ledger typed Count, distinct from manual Adjustment (ADR-0023 document-trail tier) |
scored | SC-F005 |
Portal counts UI specified by ADR-0081 :47-48 |
— | Surface, not capability — §5/§7 #1 per the framework and the Distribution precedent; deliberately not a taxonomy row so the score stays comparable across modules |
| AI-assisted count (variance anomaly detection) | N/A | Not in any ADR, design doc, or the agents spec (ADRs 0072-0079) for this module — no source claims it, so it does not enter the denominator |
| Single cloud database (no HQ↔store count replication) | N/A | SC-N01/N02 |
Folds added by the adversarial pass (guard (e) sweep — the pass found 18 dropped source lines: 8 RMS/RMH, 4 ERP, 2 market and 4 WIDS365 positives. None moves the score, but an unmapped line silently shrinks the denominator, so each is reconciled below. They resolve into 16 rows, not 18: NetSuite's tally-scanning and GS1/UPC-parsing lines share one row, and the WIDS365 date-range list filter has no row here because it is already reconciled inside SC-F009, which names it as one of that row's missing pieces.):
| Source inventory line | Disposition | Where |
|---|---|---|
Offline-inventory quantities reconciled inside the count (QuantityToOffline / QuantityFromOffline, InventoryOffline) |
folded | covered by SC-F022 (movement during the count); the hold-bin itself is Adjustments' (ADJ-F027), not this module's |
| Multiple counts open concurrently at one location (both RMS and RMH) | folded | covered by SC-F001 — QIIUB has this (nothing constrains concurrent InProgress counts). Deliberately folded with a note rather than given its own Implemented row: crediting an unconstrained absence as a feature would be the flattering read, and NetSuite treats the opposite (locking an item from a second count) as the capability — see SC-F031 |
| Bulk "Set Date" across count lines (RMS) | folded | covered by SC-F011 (no line revision of any kind) |
| Unsaved-changes prompt (RMH 3.11.1) | folded | UI behavior — §5 surface gap, not a capability |
| Guided variance-investigation checklist | folded | covered by SC-F025 (variance review); a documentation practice rather than a feature |
| Zone map / aisle assignment | folded | covered by SC-F033 (bin granularity) + SC-F012 (scope) |
| Effective-dated scheduling of the reconciliation worksheet | folded | covered by SC-N01 — it schedules the HQ/Central worksheet, already N/A |
| Matrix / kit-specific count behavior | N/A | SC-N05 |
| Annual full-count date configuration (NetSuite) | folded | covered by SC-F028 (scheduling) |
| Count / row user assignment (NetSuite, Odoo) | folded | covered by SC-F019 (who-counted) + SC-F024 (team counting) |
| Tally scanning + GS1/UPC parsing (NetSuite) | folded | covered by SC-F016 (scan capture) |
| Paid-tier feature gating (Square Inventory Plus) | N/A | Commercial packaging, not a capability — QIIUB's module/feature-flag system is a separate concern (CentralDB ModuleDefinition) |
| Informed counting = present (the other half of the blind/informed pair) | folded | covered by SC-F008 — QIIUB has informed counting; only the blind half (SC-F021) is missing, so the pair must not read as wholly absent |
| Header quantity / valuation totals (WIDS365) | folded | covered by SC-F009 (the missing list/detail aggregates) + SC-F017 (valuation) |
TransferID link from an adjustment to its source document (WIDS365) |
dep | SC-D01 — QIIUB's equivalent traceability is on the movement (ReferenceType/ReferenceId), credited in SC-F027 |
| PDF of the finished count document (WIDS365) | folded | covered by SC-F014 (print / export) |
Every line item in all four source families now maps to a scored ID, a named fold, an N/A, or a dependency. The first synthesis claimed this prematurely; the claim holds only as of the adversarial pass.
inventory-supply-chain.md:496; note changes since Last-Updated.Implemented/Partial file:line still resolves — especially FinalizeStockCountEndpoint.cs:118-237, the range Adjustments also cites.ShiftCount still belongs to Shifts; Reports still owns the aggregate shrinkage report (SC-D05 is flagged contestable).UnitCost is now written at finalize — if so SC-F017 flips and SC-F032 unblocks.stockCounts portal folder or a route for /inventory/counts now exists — that closes §7 #1 without changing the %.