feat(inventory): stock quantities are fractional - #142
Merged
Conversation
…ilently AppSettings.features is loose JSON, so a key that names no module is not a type error -- isFeatureEnabled ignores it and the module quietly stays at its registry default. The demo seed shipped four such keys: commission, storefront, invoicing and deliveryScheduling are not module keys at all. Only three of its seven were real. The consequence was not subtle. Seventeen real modules sat at their defaults on every fresh clone, so Invoices 404'd, and POS, tills, gift cards, purchasing, accounting, marketing, blog, time tracking and the client portal were absent from the nav entirely -- on a system that has all of them built. assertKnownModules() validates against MODULES and throws, naming the bad keys and listing the valid ones. Anything writing a features map should go through it, so a typo fails where it is written rather than becoming a missing screen weeks later. The demo seed now turns on what a furniture retailer would actually run. Deliberately left off: the two legacy-import modules (they exist for one migration path), dmarcTools (another vertical), and blogComments (nothing can post one without a public visitor). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A roll of fabric or wallpaper is a length, not a count. The selling side has always known that -- OrderLineItem.orderedQuantity and fulfilledQty are both Decimal -- while InventoryPosition.quantity and InventoryTransfer.quantity were Int. So holt could take an order for 12.5 yards and could not hold 12.5 yards. The split is invisible until somebody receives a part roll, and then every count, transfer and allocation silently rounds. A transfer of 18.75 yards stored 18. Both columns are now Decimal(12,3). Three places because fabric is sold to the eighth of a yard and 0.125 is exact at three; two would round it to 0.13 and lose an eighth on every cut. Postgres widens integer to numeric in place, so no data moves and every existing whole-unit row is unchanged. lib/inventory/quantity.ts owns the arithmetic -- deliberately not journalEntry's round2(), which is money and rounds like money. The important piece is qtyEquals(): planDraw decided whether a draw exhausted a position with `===`, and exact float equality on fractional quantities is how a position ends up stranded at 0.0000001 -- present in every count, sellable to nobody, never cleaned up because it never reads as exhausted. Ten call sites, all mechanical apart from the allocator. Verified against a real database: holding a part roll, selling to the eighth, cutting one order across two bolts and leaving the remnant sellable, shorting honestly rather than rounding, consuming an allocation with no sliver left behind, and 0.1 + 0.2 still exhausting a 0.3 position. Also adds docs/domains/manufacturing.md -- a feasibility plan for the wider make-to-order case that prompted this. Fractional stock is step one of it and was needed regardless, because holt already sells COM by the yard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The plan was written against roll goods. Rescoped to discrete manufacturing generally, with the Tier-1 gaps folded into the steps rather than listed separately: UoM conversion (not just a label), multi-level BoMs with scrap percentage, partial job completion, and a removal strategy to go with lots. Adds what Odoo gives that this does not, in three tiers by whether a small manufacturer would feel it -- and says plainly that reaching Odoo's manufacturing is a product line rather than a project. What is reachable is the subset such a business actually runs on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ecimals Prisma returns a Decimal for the widened columns, and `expect(x.quantity) .toBe(5)` compares it as the string "5". Six assertions in inventoryAllocation.integration.test.ts, all mechanical -- toQty() at the read. CI caught these because the local run before pushing covered the unit suite and the two integration files this change touches by name, not the whole suite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Same shape as the last commit, found by grepping every stock-quantity assertion across __tests__ at once instead of waiting for CI to surface them one file at a time: inventoryOrderWiring, transferForOrder and inventorySnapshotGenerate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
goetchstone
force-pushed
the
feat/fractional-stock
branch
2 times, most recently
from
August 26, 2026 08:57
aa6c229 to
22980fb
Compare
goetchstone
added a commit
that referenced
this pull request
Aug 26, 2026
The Up Board and Store Traffic were hardcoded onto the dashboard. Both are showroom-floor conventions -- a rotation only exists where staff take turns on a floor, a door counter only exists where there is a door to count. A wholesaler, a manufacturer or an online-only shop got two permanently-empty cards on the FIRST screen after login, and an empty traffic card does not read as "no counter here", it reads as "nobody came in". Both are modules now, defaulting OFF, gated on the dashboard and across all five API routes that serve them. When neither is on the dashboard says so and points at the settings screen, rather than rendering nothing. A migration turns each ON where there is evidence it is already in use -- rows in the table it drives -- so no existing deployment loses a feature it relies on by upgrading. Off-by-default is right for the next deployment; silently removing something from the last one is not. Also fixes what this uncovered: - The features map in the demo seed was never actually corrected. #142 added the assertKnownModules import and the call itself did not apply, so main has been shipping a dead import and the same four invalid keys. Now 18 real modules, verified against the registry: no invalid key survives. - moduleManifest.test.ts pinned the module set with equality, so adding one failed a guard about a refactor that happened months ago. It now asserts CONTAINS -- every pre-refactor module still present, none renamed, no default flipped -- which is the guarantee that was actually worth having. - The POS asked /api/warehouse/positions for a productId the handler did not support, so the register got 50 arbitrary rows; and it read storeLocation.name where the API returns a flattened locationName, so the loop skipped every row. Every cart line said "0 on hand here" against a showroom holding twenty. It also counted stock committed to other orders as sellable, which is how the same sofa gets sold twice -- there is a freeOnly filter now, sharing freePositionWhere() with the allocator so the register and the allocator cannot disagree about what is sellable. Verified both directions in the running app: on, the dashboard shows 93 entries today against 110 last year and a five-person rotation with live statuses; off, both sections are gone and the page says where to switch them on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
goetchstone
added a commit
that referenced
this pull request
Aug 26, 2026
* fix(observability): the alert path was feeding itself logError() records an ErrorEvent. recordError() calls reportOpsAlert the first time it sees a fingerprint. reportOpsAlert logged itself through logError. So every alert produced a NEW message -- "ops-alert: " prepended to the previous title -- which is a new fingerprint, which is a first sighting, which alerts again: ops-alert: New error: ops-alert: New error: ops-alert: New error: ... One unconfigured integration was enough to start it. A missing Axper API key logged once, and the loop turned that into 1,154 ErrorEvent rows and a server too busy to answer a login. Every row was the loop's own output, so the error log -- the thing you reach for when something is wrong -- was the least usable artifact in the system. Two guards. opsAlert.ts now logs through `logger`, which writes to stdout and stops, and says at the top that nothing in it may call logError; the two channel-failure paths inside it had the same bug and are fixed the same way. And recordError refuses to re-enter the alert path, so no future caller can reopen it from the other side. Measured on the demo after the fix: /app went from 2.6-3.7s to 0.02-0.04s, and ErrorEvent stays empty instead of filling with its own output. opsAlertLoop.test.ts pins both guards, and checks that the fix was not simply "stop alerting" -- the cheapest wrong answer would pass the first three assertions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(seed): --reset deletes what it does not reseed, and now says so `seed:demo --reset` truncates EVERY table, including the ones other seeders own. Run on its own it therefore deletes the CMS content and the roles and does not put them back -- so the storefront quietly loses its copy, hours later, with nothing in the log to explain it. That is exactly how it was noticed: somebody asked where the copy went. Two changes. `npm run seed:all` runs the three in dependency order, which is what anyone reseeding a demo actually wants. And --reset now names the seeders whose tables it just emptied, so running the parts by hand cannot silently leave holes. The warning replaces a stray empty block that was sitting at the end of the seed -- pre-existing, and a good spot for something that should have been there all along. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(modules): the dashboard composes, it does not assume The Up Board and Store Traffic were hardcoded onto the dashboard. Both are showroom-floor conventions -- a rotation only exists where staff take turns on a floor, a door counter only exists where there is a door to count. A wholesaler, a manufacturer or an online-only shop got two permanently-empty cards on the FIRST screen after login, and an empty traffic card does not read as "no counter here", it reads as "nobody came in". Both are modules now, defaulting OFF, gated on the dashboard and across all five API routes that serve them. When neither is on the dashboard says so and points at the settings screen, rather than rendering nothing. A migration turns each ON where there is evidence it is already in use -- rows in the table it drives -- so no existing deployment loses a feature it relies on by upgrading. Off-by-default is right for the next deployment; silently removing something from the last one is not. Also fixes what this uncovered: - The features map in the demo seed was never actually corrected. #142 added the assertKnownModules import and the call itself did not apply, so main has been shipping a dead import and the same four invalid keys. Now 18 real modules, verified against the registry: no invalid key survives. - moduleManifest.test.ts pinned the module set with equality, so adding one failed a guard about a refactor that happened months ago. It now asserts CONTAINS -- every pre-refactor module still present, none renamed, no default flipped -- which is the guarantee that was actually worth having. - The POS asked /api/warehouse/positions for a productId the handler did not support, so the register got 50 arbitrary rows; and it read storeLocation.name where the API returns a flattened locationName, so the loop skipped every row. Every cart line said "0 on hand here" against a showroom holding twenty. It also counted stock committed to other orders as sellable, which is how the same sofa gets sold twice -- there is a freeOnly filter now, sharing freePositionWhere() with the allocator so the register and the allocator cannot disagree about what is sellable. Verified both directions in the running app: on, the dashboard shows 93 entries today against 110 last year and a five-person rotation with live statuses; off, both sections are gone and the page says where to switch them on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(delivery): plan GPS telematics and third-party carriers Two integrations answering the same question -- where is this delivery and when does it land -- for the two ways a delivery happens: our truck, or somebody else's. They share the conclusion that matters. Both end in markHandedOver(), which generates the invoice and recognises the sale, so whether the fact arrived from a geofence or a carrier's status callback changes nothing downstream. That seam is already built; these are two more ways to feed it. Scoped by what the owner actually wants: GPS position tied to deliveries, and no video. Explicitly out of scope are driver scorecards, harsh-braking alerts and idle-time league tables -- the default reason telematics gets bought, the reason drivers resent it, and a measurement of the wrong thing. The test applied throughout is whether a feature changes what somebody does: an ETA text does, a monthly braking score does not. Three gaps found in the current model. Vehicle has no device identity. CustomerAddress has no latitude or longitude at all, which blocks geofencing, distance and sequencing -- it is a prerequisite rather than a nice-to-have. DeliveryStop.actualArrival already exists and nothing sets it. And one structural blocker for carriers: DeliveryStop.deliveryRunId is non-null, so a stop requires a run which requires a Vehicle. A third-party delivery has neither and therefore cannot be a stop -- invisible to the dispatch board and every metric reading it. The recommendation is a nullable FK rather than synthetic vehicles per carrier, because a carrier is not a truck and every report that counts vehicles would start counting carriers. DeliveryZone.isThirdParty and carrierName already exist and are display-only, which is worth knowing before someone assumes they do something. The value ranking puts telling the customer first, because a furniture delivery that fails for want of somebody being home costs a whole truck slot twice, and an ETA message is the cheapest thing that reduces it. Cost-per-delivery is fourth and is the one most retailers never learn -- holt is unusually placed to answer it because it already holds the fee side in DeliveryZone. Retention and employee-notice are called out as decisions to make before devices are installed rather than after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(delivery): the knobs are configuration, not decisions Retention and employee notice were written up as "two things to decide deliberately", which is the wrong frame: a decision gets made once and then lives in someone's memory. These are deployment facts, and CLAUDE.md 61-63 applies to them exactly as it does everywhere else. So the doc now carries a configuration table instead, covering every number in it -- provider, poll interval, retention window, geofence radius, how much warning a customer gets and on which channel, when dispatch hears a run is slipping, whether the customer sees the van at all, whether the resequencer even offers a suggestion, and which carrier serves which zone. A shop with two vans and a shop with a carrier in three states want different answers to all of them and neither should be editing code to get them. Two points kept and sharpened. Retention is the setting with a consequence outside the building, and a retention setting nothing enforces is worse than none -- it writes down that you delete data you are still holding. So it needs a scheduled purge that runs and records that it ran, dropping raw positions while keeping the derived per-stop metrics the costing actually needs. And the notice is a configured document with a per-staff acknowledgement, not a constant, because the wording varies by state. That is also what makes this an easy conversation with drivers: tracking the van for the customer's benefit sells itself, tracking the person does not, and the notice can only say so specifically if the deployment can edit it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(seed): the coverage manifest matches what the seed now writes CI caught this, which is the manifest doing its job: eleven models are seeded now and were still marked `todo`, so the coverage gate failed on STALE -- "claimed outstanding, came back populated". Lead, CustomerInteraction, Proposal, ProposalLineItem, GiftCardPreset, EmailTemplate, LabelTemplate, TradeTier, SalesGoal and UpBoardEntry. TrafficSnapshot was marked SKIPPED, with the reason that its columns carried one vendor's brand. They stopped doing so in #127, when axperStoreName became sourceStoreName -- so the reason had outlived the fact and nobody noticed, because a skip with a stale reason looks exactly like a skip with a good one. The demo seeds two years of it now, and the traffic API falls back to those rows whenever the live counter is unreachable. Seed coverage: 79 -> 93 seeded, 63 -> 53 outstanding. No tranche emptied, so SEED_TRANCHES is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A roll of fabric or wallpaper is a length, not a count.
The selling side has always known that —
OrderLineItem.orderedQuantityandfulfilledQtyare bothDecimal. The stock side did not:InventoryPosition.quantityandInventoryTransfer.quantitywereInt. So holt could take an order for 12.5 yards and could not hold 12.5 yards.That split is invisible until somebody receives a part roll, and then every count, transfer and allocation silently rounds. A transfer of 18.75 yards stored 18.
The change
Both columns become
Decimal(12,3). Three places because fabric is sold to the eighth of a yard and 0.125 is exact at three — two would round it to 0.13 and lose an eighth on every cut. Postgres widens integer→numeric in place, so no data moves and every existing whole-unit row is unchanged.lib/inventory/quantity.tsowns the arithmetic. Deliberately notjournalEntry'sround2(), which is money and rounds like money — a cent is the smallest thing you can owe; an eighth of a yard is not two decimal places.The one non-mechanical piece is
qtyEquals().planDrawdecided whether a draw exhausted a position with===, and exact float equality on fractional quantities is how a position ends up stranded at 0.0000001 — present in every count, sellable to nobody, never cleaned up because it never reads as exhausted.Ten call sites in total; the other nine are conversions at the Prisma boundary.
Verified against a real database
__tests__/integration/fractionalStock.integration.test.ts:0.1 + 0.2still exhausts a0.3position3,438 unit tests pass;
npm run validate— 0 errors. Migration applied to a fresh database withprisma migrate deploy; both columns confirmednumeric(12,3).Also in here
Two commits, separable. The first fixes a module-registry bug the demo audit surfaced:
AppSettings.featuresis loose JSON, so a key naming no module is silently ignored. The seed shipped four such keys —commission,storefront,invoicing,deliverySchedulingare not module keys at all. Only three of its seven were real, which left seventeen modules at their registry defaults: Invoices 404'd, and POS, tills, gift cards, purchasing, accounting, marketing, blog, time tracking and the client portal were absent from the nav on a system that has all of them built.assertKnownModules()now throws, naming the bad keys.And
docs/domains/manufacturing.md— a feasibility plan for the wider make-to-order case that prompted this. Fractional stock is step one of it and was needed regardless, since holt already sells COM by the yard.🤖 Generated with Claude Code