Skip to content

feat(inventory): stock quantities are fractional - #142

Merged
goetchstone merged 5 commits into
mainfrom
feat/fractional-stock
Aug 26, 2026
Merged

feat(inventory): stock quantities are fractional#142
goetchstone merged 5 commits into
mainfrom
feat/fractional-stock

Conversation

@goetchstone

Copy link
Copy Markdown
Owner

we need to be able to both order and sell fractional yards anyway

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. The stock side did not: InventoryPosition.quantity and InventoryTransfer.quantity were Int. 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.ts owns the arithmetic. Deliberately not journalEntry's round2(), 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(). 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 in total; the other nine are conversions at the Prisma boundary.

Verified against a real database

__tests__/integration/fractionalStock.integration.test.ts:

  • holds a part roll (37.5 yards)
  • sells to the eighth — 2.125 allocated, 7.875 left free
  • cuts one order across two bolts and leaves the remnant sellable (8.25 + 6.75 of 12.5, remnant 5.75)
  • shorts honestly rather than rounding — 4.5 requested against 4.2 on hand gives a 0.3 shortfall, not a rounded fill
  • consumes a fractional allocation with no sliver left behind
  • moves a part roll between locations (18.75, which used to store as 18)
  • 0.1 + 0.2 still exhausts a 0.3 position

3,438 unit tests pass; npm run validate — 0 errors. Migration applied to a fresh database with prisma migrate deploy; both columns confirmed numeric(12,3).

Also in here

Two commits, separable. The first fixes a module-registry bug the demo audit surfaced: AppSettings.features is loose JSON, so a key naming no module is silently ignored. The seed shipped four such keyscommission, storefront, invoicing, deliveryScheduling are 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

goetchstone and others added 5 commits August 26, 2026 04:56
…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
goetchstone force-pushed the feat/fractional-stock branch 2 times, most recently from aa6c229 to 22980fb Compare August 26, 2026 08:57
@goetchstone
goetchstone merged commit 2e56261 into main Aug 26, 2026
9 checks passed
@goetchstone
goetchstone deleted the feat/fractional-stock branch August 26, 2026 09:58
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant