Skip to content

feat: file each commodity row in its Schedule B unit, and record Incoterms on the CEVA SLI - #50

Merged
JoelA510 merged 2 commits into
mainfrom
claude/uom-incoterms-sli-kfz3n5
Aug 20, 2026
Merged

feat: file each commodity row in its Schedule B unit, and record Incoterms on the CEVA SLI#50
JoelA510 merged 2 commits into
mainfrom
claude/uom-incoterms-sli-kfz3n5

Conversation

@JoelA510

@JoelA510 JoelA510 commented Aug 20, 2026

Copy link
Copy Markdown
Owner

Pull Request: Unit of quantity, and Incoterms that reach the paper

📋 Summary

Two ways a generated SLI could state something the shipment does not. Several Schedule B numbers are reported in kilograms while every invoice in this trade counts pieces — the app has always warned about that and then filed the piece count anyway. And the CEVA form's Incoterm was ticked by matching the raw string against the box names, so a term carrying its named place (DAP Singapore) ticked nothing at all, producing a form with no delivery term on it and no sign of the omission.

Each commodity row is now filed in the unit its commodity number requires, changeable before generating; and the Incoterm is read as a rule rather than matched literally, with its named place preserved.

✨ Highlights

  • Unit of quantity resolved once, consumed four times: reconcile decides the unit each row is filed in and restates the quantity into it. Both SLI adapters and both keying sheets read that decision, so the form and the sheet prepared for one shipment cannot state different quantities for the same goods.
  • The Schedule B unit is the default, not a suggestion: where the Census file reports a code by weight, the row carries its net weight instead of the invoice's piece count. 9031.90.0000 on the sample shipments is exactly this.
  • Changeable before generating: the review screen's commodity table gained a per-code unit picker for the codes that accept more than one (NO+KG), where only the filer knows how the goods are actually measured. Units the shipment cannot state are listed disabled with the reason rather than hidden — "there is no net weight for these goods" is the answer somebody is looking for.
  • Nothing is invented, and nothing derived passes as transcribed: a layout that prints no weights keeps its printed figure with the reconciliation saying why; a figure that was worked out is labelled as converted or as a net weight wherever it appears, never as "as invoiced".
  • Incoterms read as rules: DAP Singapore, FOB Origin - Collect, cif rotterdam and Ex Works all resolve to the rule the form has a box for. One reader in src/domain/incoterms.ts, shared by both adapters and by the Vendor A trade-terms parser.
  • The named place survives — but only where it is one: it reaches Nippon's box 15 and CEVA's special instructions. What follows the rule is not always a place, so a freight qualifier (Origin - Collect) is kept out of a box captioned "NAMED PLACE/PORT".
  • Retired rules reported, never remapped: DAT and DDU are named with their Incoterms 2020 replacement and left unticked. Reclassifying a delivery term is the filer's decision, not the adapter's.

🗺️ Roadmap Progress

Item ID Feature Name Phase Status Notes
Unit of quantity ✅ Done New roadmap row. Rows filed in the unit their commodity number requires; changeable per code; identical across both SLIs and both keying sheets.
Incoterms ✅ Done New roadmap row. Rule read off the stated text, named place preserved, withdrawn rules reported rather than remapped.
Schedule B validation ✅ Done Existing row unchanged in status; the sb-uom check now judges the unit a row is filed in rather than the one the invoice printed.

🏗️ Architecture Decisions

Key Patterns & Decisions

  • The resolution lives in reconcile, not in the adapters. Only the Census dataset knows what a code requires, and reconcile is the one place that holds it. Adapters read reportingUom / reportingQuantity off the row. Two implementations of "what unit is this?" is how a form and a keying sheet come to disagree.
  • The choice is keyed by commodity number, not by row. The Census file requires a unit per code, so "file 9031.90.0000 in kilograms" holds for every row carrying it and survives a regrouping that splits or merges rows.
  • The document's own figures are never overwritten. quantity and sourceUom still say what was printed, so every totals check still proves the rows against the source. reportingQuantity is a separate field, not a mutation.
  • Canonical forms decide conversions; the filed unit keeps its own spelling. canonicalUnit exists to make what a document prints comparable with what the Census file requires. Applying it to the file's side too would file NO against the 51 commodity numbers the file reports in PCS.
  • Rounding scales with the conversion. Three decimals is right for kilograms and wrong for any unit that is a large multiple of the one being converted from — 0.004 tonnes is 4 kg, not 4.263.
  • Restatement returns null rather than guessing. restateQuantity refuses where nothing on the row supports the figure — no net weight, a weight that cannot be counted back into pieces, a content-mass unit.
  • parseIncoterm is domain knowledge, not carrier knowledge. It lives in src/domain/incoterms.ts so that both adapters and the Vendor A parser share one answer; the parser previously carried a second, unvalidated copy.
  • CEVA box 24 names the unit only when it is not a count. A count is written bare, matching the filed vendorA3 SLI. 4.263 on its own, in a column that has held piece counts on every form before it, is a number nobody can place.
  • The per-shipment unit choice is session state, not saved. A unit remembered from a previous shipment would quietly file goods that were measured differently; it resets when a new document is parsed.

Logic Flow / State Changes

graph TD
    A["Census file: units per code"] --> B["reconcile"]
    C["CIPL: quantity, uom, net weight"] --> B
    D["Review screen: chosen unit per code"] --> B
    B --> E["SLILine.reportingUom + reportingQuantity"]
    E --> F["CEVA box 24"]
    E --> G["Nippon boxes 24 + 25"]
    E --> H["FedEx / UPS keying sheets"]
    E --> I["sb-uom check"]
Loading

🔍 Review Guide

🚨 High Risk / Compliance Sensitive

  • src/domain/units.ts — the conversion rules themselves. What is convertible, what deliberately is not, how the precision scales, and how a derived figure is labelled.
  • src/domain/reconcile/index.ts — where the unit is chosen per row, and the sb-uom check now judging the filed unit rather than the printed one.
  • src/carriers/ceva/adapter.ts — box 24, and the Incoterm block that decides what gets ticked, what gets written out, and what gets warned about.
  • src/domain/incoterms.ts — new. The rule list, the written-out phrasings, and the named-place / freight-qualifier distinction.

🧠 Medium Complexity

  • src/carriers/keying-sheet/index.ts — restating each keying group from its own figures rather than apportioning the SLI row's; the blank quantity total on a mixed-unit sheet; the Note and Notes entries that say which rows are not piece counts.
  • src/features/review.tsx — the unit picker and the note beside each filed figure.
  • src/domain/schedule-b/index.ts — the expanded alias table and the reworded unit check.
  • src/domain/cipl/parse-vendor-a.tsincotermFromTradeTerms routed through the shared reader.

🟢 Low Risk

  • src/domain/types.ts, src/domain/reconcile/lines.ts — new fields, source-based defaults, and roundTo re-exported from its single definition.
  • src/App.tsx, src/features/manual-fields.tsx — state wiring; the named-place field is no longer Nippon-only, since CEVA can now record one.
  • README.md, roadmap.md.

🧪 Verification Plan

1. Environment Setup

  • npm install — no new dependencies.

2. Manual Verification

  • Unit of quantity:
    1. Upload a CIPL containing a line classified 9031.90.0000 (reported in KG).
    2. The commodity table's Filed qty & unit column shows the net weight in KG, noted "this code is reported by weight, not by the piece", while Invoice qty still shows the piece count.
    3. Change the picker to the invoice's own unit (PCS (as invoiced)); the filed figure returns to the piece count, the note says it is by your choice, and the Schedule B unit check turns to a warning.
    4. Generate the CEVA SLI: box 24 reads 4.263 KG for that row and a bare count for the others.
  • Incoterms:
    1. On a document whose Incoterm is DAP Singapore, generate the CEVA SLI — the DAP box is ticked and special instructions read Incoterm: DAP Singapore.
    2. On the same document with Nippon selected, the DAP option is chosen and box 15 reads Singapore.
    3. With trade terms of FOB Origin - Collect, FOB is ticked and box 15 stays empty — "Origin - Collect" is a freight term, not a port.
    4. Set the Incoterm to a DAT term: no box is ticked, the term is written out, and the warning names DPU as its replacement.

3. Automated Tests

npm run check   # typecheck, lint, 752 tests, production build
npm test -- src/domain/units.test.ts
npm test -- src/domain/reconcile/reporting-units.test.ts
npm test -- src/carriers/carriers.test.ts

Verified in a real browser against a synthetic CIPL: upload, the picker's options and disabled states, and the filed figure following the selection.

⚠️ One expectation deliberately changed

carriers.test.ts asserted box 24 of the filed vendorA3 SLI as 10\r75\r12. Its third row is 9031.90.0000, which the Census file reports in kilograms — so the 12 that was filed was the reporting error this PR fixes, and the expectation is now 10\r75\r4.263 KG with that reasoning recorded beside it. This suite is gated on shipment documents that are not committed, so it could not be run here; it is worth a run where the fixtures are present.

🔁 Review round (commit a4e48ff)

A review pass over the first commit found ten issues, all fixed in a4e48ff with regression tests. The ones worth knowing about:

  • Conversion precision. Restated quantities were rounded to three decimals whatever the conversion, so a kilogram weight filed as tonnes came out 0.004 — 6% of the declared weight lost to rounding. Places now scale with the conversion factor.
  • Derived figures read as transcribed. A within-family conversion reported basis source, so the review screen said "As invoiced" about a figure the document does not carry. New converted basis, surfaced everywhere a filed quantity is explained.
  • PCS codes filed NO. scheduleBUnit was being canonicalised, filing a unit the Census file does not list for those 51 codes.
  • The X codes keyed as zero. The keying sheet had no case for the eight commodity numbers filed with no quantity, keying a literal 0 against goods with a value.
  • Mixed-unit total. The Commodities grid still summed a quantity column that can now hold pieces beside kilograms; blank when mixed, matching the call the review screen already made.
  • Freight terms in the named-place box, and disagreeing precedence. FOB Origin - Collect put "Origin - Collect" into box 15; and CEVA preferred the document's place while Nippon preferred the operator's, so the same two inputs filed two ways. Both now take the operator's, and CEVA warns when it overrides the document.

📉 Detailed Changelog (Collapsible)
  • src/domain/units.ts: restateQuantity, canRestate, resolveReportingQuantity, roundTo, scale-aware rounding, the kilogram and item unit families, and the QuantityBasis type.
  • src/domain/incoterms.ts (new): parseIncoterm, RETIRED_INCOTERMS, isNamedPlace.
  • src/domain/types.ts: SLILine gains scheduleBUnits, reportingUom, reportingQuantity, reportingBasis.
  • src/domain/reconcile/index.ts: reportingUnits option; per-row unit resolution after aggregation; classification check passes the filed unit and basis.
  • src/domain/reconcile/lines.ts: source-based defaults for the new fields; roundTo re-exported from units.
  • src/domain/schedule-b/index.ts: unit aliases for grams, tonnes, dozens, gross, hundreds and thousands; sb-uom judged on the filed unit, with distinct messages for "no weight available" and "changed by hand".
  • src/domain/cipl/parse-vendor-a.ts: incotermFromTradeTerms validated through parseIncoterm.
  • src/carriers/ceva/adapter.ts: Incoterm resolved and recorded; named-place precedence and conflict warning; box 24 carries the reported unit.
  • src/carriers/nippon-express/adapter.ts: boxes 24/25 from the reported unit; named place defaulted from the term when it is one; normaliseIncoterm becomes formOption.
  • src/carriers/keying-sheet/index.ts: rows keyed in the SLI's unit, restated per group; unit price per keyed unit; blank quantity total on a mixed-unit sheet; Note-column and Notes-tab entries for weight-keyed, converted and no-quantity rows.
  • src/features/review.tsx: Filed qty & unit column, ReportingUnitPicker, basisNote.
  • src/features/manual-fields.tsx: named place shown for CEVA too, with its own hint.
  • src/App.tsx: reportingUnits state, reset on parse, fed to reconcile.
  • src/domain/units.test.ts, src/domain/reconcile/reporting-units.test.ts: new suites, no fixtures needed.
  • src/carriers/carriers.test.ts: Incoterm reading, named-place handling, CEVA recording, Nippon boxes 24/25, and an end-to-end document → form → sheet suite built on the synthetic CIPL.

🤖 Generated with Claude Code

https://claude.ai/code/session_013x3f6KRnFFe1R83BSTZewU

…terms on the CEVA SLI

Two problems that both end in a form stating something the shipment does not.

**Unit of quantity.** Several Schedule B numbers are reported in kilograms while every
invoice in this trade counts pieces — `9031.90.0000` is the one on the sample shipments —
and the app has always warned about it while filing the piece count anyway. Each row is
now filed in the unit its commodity number requires, taking the net weight where the code
is reported by weight, and the unit is changeable per commodity number on the review
screen for the codes that accept more than one (`NO+KG`), where only the filer knows how
the goods are measured.

Resolved once in `reconcile` and read from there by both SLI adapters and both keying
sheets, so the paperwork prepared for one shipment cannot disagree with itself. The
document's own figures are never overwritten — `quantity` and `sourceUom` still say what
was printed, and the totals still reconcile against them. Where nothing supports the
required unit (a layout that prints no weights), the printed figure is filed and the
reconciliation says why; no quantity is invented to fit a unit. Units a shipment cannot
state are offered and disabled with the reason rather than hidden.

**Incoterms on the CEVA form.** The box was ticked by matching the raw string against the
box names, so `DAP Singapore` from the in-house invoice form and `FOB Origin - Collect`
from the Vendor A trade terms ticked nothing at all — a form filed with no delivery term
on it. The rule is now read off the string (bare code, named place, lower case, or written
out in words) and both adapters use the same reader. The named place is kept: it goes to
Nippon's box 15, and to CEVA's special instructions, which is the only place that form can
record it. A rule withdrawn since Incoterms 2020 is reported with its replacement rather
than silently remapped — reclassifying a delivery term is the filer's decision.

The filed vendorA3 expectation for box 24 changes with this: its third row carries
4.263 KG in place of the 12 pieces that were filed against a code reported by weight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013x3f6KRnFFe1R83BSTZewU
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown

Status

  • Head SHA: a4e48ff3674bb5fbd56077328f7a568e9e280aee
  • Base SHA: ca5e79768b0566c3adb15d7caca6e866261e5c7f
  • CI state: pending
  • Files reviewed: 19
  • Patches proposed: 0
  • Change types: code, docs-only

Findings

No issues found in changed scope.

Proposed patches

None

CI failure mapping

CI is still running (pending).

Diagnostic plan

npm ci
npm run lint
npm test -- --watch=false
npm run build
npx --yes markdown-link-check <changed-docs>
npx --yes cspell <changed-docs>

Next actions

  • Awaiting updates for head a4e48ff3674bb5fbd56077328f7a568e9e280aee

Meta

Processed at 2026-08-20 17:14:30.702 UTC
Idempotency key = a4e48ff

…ling, unit spellings

Ten findings from the review on #50, all in the new unit-of-quantity and Incoterm paths.

**Correctness**

- Restated quantities were rounded to three decimals whatever the conversion, so every
  down-scaling unit lost the figure it was converting. 4.263 kg filed as tonnes came out
  `0.004` — 6% of the shipment's declared weight gone into the rounding. The places now
  scale with the conversion: dividing by a thousand buys three more of them.
- A within-family conversion reported `basis: 'source'`, so the review screen said "As
  invoiced" and the Schedule B check said "matching the invoice" about a figure the
  document does not carry. New `converted` basis, explained wherever a filed quantity is.
- `scheduleBUnit` was canonicalised, which filed `NO` against the 51 commodity numbers the
  Census file reports in `PCS` — a unit it does not list for them. Canonical forms decide
  what converts into what; the filed unit keeps the spelling it was asked for.
- The keying sheet had no case for the eight codes Schedule B files with no quantity: it
  keyed a literal `0` with no note. It now keys the document's own count and says the
  figure is for the application rather than the declaration.
- The Commodities grid still summed the quantity column across rows that can now be in
  different units, printing pieces added to kilograms. Blank when mixed, as the review
  screen already does for the same column.
- `parseIncoterm`'s named place is whatever follows the rule, and `FOB Origin - Collect`
  put "Origin - Collect" into a box captioned "NAMED PLACE/PORT". A freight-qualifier
  guard keeps payment terms out of it.
- CEVA preferred the document's named place, Nippon the operator's — the same two inputs
  filed two ways, and CEVA discarded the operator's entry silently. Both now take the
  operator's, and CEVA warns when it overrides a place the document states.
- `basisNote` said "by your choice" whenever a choice existed for the code, including on a
  row where the choice was unreachable and discarded. It now reports whether the choice
  was honoured, so that row points at its missing weight instead.

**Structure**

- `parseIncoterm` moves to `src/domain/incoterms.ts`. It is domain knowledge, not carrier
  knowledge, and `incotermFromTradeTerms` was carrying a second unvalidated copy that
  accepted any leading three letters — `PPD Collect` yielded an Incoterm of `PPD`.
- `roundTo` had been duplicated into `units.ts`; `reconcile/lines.ts` now re-exports the
  one definition.
- Dropped a redundant guard and folded an unreachable arm in the CEVA Incoterm branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013x3f6KRnFFe1R83BSTZewU
@JoelA510
JoelA510 merged commit 13601c6 into main Aug 20, 2026
4 checks passed
JoelA510 pushed a commit that referenced this pull request Aug 20, 2026
A follow-up review of #50 found thirteen issues. Two of them defeated the precision
fix that PR shipped, which is the reason for this one.

**The quantity never reached the form at full precision.** `roundScaled` chose a
precision to suit each conversion, and then both adapters rounded to exactly three
decimals on the way into the box. A commodity number reported in tonnes — 247 of them
in the shipped Census file — printed `0.004` against a 4.263 kg row, under-declaring by
6%, and a 0.4 kg row printed `0.000`. The keying sheet kept the full figure, so the SLI
and the sheet for one shipment stated different quantities: exactly the disagreement the
feature exists to prevent. One `formatQuantity` in `form-utils` now serves both forms,
treating three decimals as the floor rather than the ceiling.

**Named places were being thrown away whole.** `isNamedPlace` rejected the entire
remainder when a freight qualifier appeared anywhere in it, so `CIF Rotterdam Prepaid`
left box 15 empty with no warning. Replaced by `namedPlaceFrom`, which truncates at the
qualifier and keeps `Rotterdam`. It also drops punctuation-only remainders, so `EXW.` no
longer writes `.` into a form box.

**CEVA dropped an operator's named place** whenever the term itself did not parse
(`Ex Factory` + `SFO` wrote the term alone), and raised a conflict warning against
freight wording that was never a competing place. The term written to special
instructions is now built from the rule and the place that applies, and the comparison
goes through `namedPlaceFrom`.

**The keying sheet read the wrong SLI row.** `aggregateLines` splits rows on the
canonical unit as well as D/F and the export-control triplet, so one commodity number can
be two rows filing two units. Keyed on the code alone, the kilogram group was told it
should be filing NO and the sheet asserted it had no figure for it — about goods the SLI
files in KG. The lookup is now keyed on code and unit, with the code alone as a fallback.

Also: box 24 appended the unit to plain piece counts under the 51 commodity numbers the
Census file reports in `PCS`; the grid blanked its quantity total whenever two groups
spelled one unit differently (`PCS` beside `EA`); the unit-of-quantity failure message
hard-coded `KG` and so named the wrong remedy for tonne and gram codes; `basisNote`
compared raw spellings and reported a row as deviating from a unit it was actually
filing; `parseIncoterm` ran twice per fill; and `keyedQuantity` re-implemented the
identity test `restateQuantity` already performs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013x3f6KRnFFe1R83BSTZewU
JoelA510 added a commit that referenced this pull request Aug 20, 2026
Follow-up review rounds on #50 — quantity precision, Incoterm recording

Four review rounds after #50. The headline defect: #50's precision fix never
reached the printed form, because both adapters re-rounded the restated quantity
to three decimals — a tonne-reported code printed 0.004 against a 4.263 kg row
while the keying sheet kept the full figure.

Also withdraws the named-place extraction heuristic in favour of leaving the box
for the operator with a warning, and keeps `roundTo` untouched so the quantity
work cannot move customs values.

The fixture-backed suite has not run against any of this; it is gated on shipment
documents that are not committed.
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.

2 participants