Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 32 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,12 @@ of defaulting to zero.
6. **Validates every Schedule B number** against the U.S. Census Bureau AES commodity file:
ten digits, currently active, reported in the required unit of quantity, and plausibly
describing the goods.
7. **Proves the arithmetic** before generating anything. Quantities, weights and values must
7. **Files each row in the unit its commodity number is reported in.** Where Schedule B
reports a code by weight and the invoice counts pieces, the row carries the net weight
instead β€” and the review screen lets you change the unit before anything is generated.
8. **Proves the arithmetic** before generating anything. Quantities, weights and values must
sum back to the totals printed on the source document.
8. **Fills the carrier's real blank form** and downloads it, still editable and unsigned.
9. **Fills the carrier's real blank form** and downloads it, still editable and unsigned.

## Supported carriers

Expand Down Expand Up @@ -282,7 +285,33 @@ This is desktop-only because it has to be: census.gov serves the file with no
The dataset is the authority on three things the CIPL cannot tell you: whether a code is
currently valid, its official description, and the unit of quantity AES requires. That last
one matters more than it looks β€” `9031.90.0000` and `8483.10.5000` are reported in
kilograms, not pieces, and the tool flags a piece count filed against them.
kilograms, not pieces, and a piece count filed against them is a reporting error even when
every other number on the form is right.

## Unit of quantity

Each commodity row is filed in the unit its Schedule B number requires, wherever the
shipment can state it. A code reported in kilograms carries the row's net weight rather than
the invoice's piece count; a code reported by the piece is unchanged. Both carrier forms and
both keying sheets take the same figure from the same place, so the paperwork prepared for
one shipment cannot disagree with itself.

Where a code accepts more than one unit β€” `NO+KG` β€” only the filer knows how the goods are
actually measured, so the review screen offers the choice per commodity number. Units the
shipment cannot state are listed and disabled with the reason, rather than hidden: "there is
no net weight for these goods" is the answer somebody is looking for. Where nothing supports
the required unit at all, the document's own figure is filed and the reconciliation says so β€”
no quantity is ever invented to fit a unit.

## Incoterms

The rule is read off whatever the document or the operator supplied, not matched literally:
`DAP Singapore`, `FOB Origin - Collect`, `cif rotterdam` and `Ex Works` all resolve to the
rule the form has a box for. The named place is kept β€” an Incoterm without one does not say
which port β€” and written into the CEVA form's special instructions, which is the only place
that form can record it. A rule withdrawn since Incoterms 2020 (`DAT`, `DDU`) is reported
with its replacement rather than silently mapped onto it: reclassifying a delivery term is
the filer's decision.

## Local data

Expand Down
2 changes: 2 additions & 0 deletions roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ B refresh, which cannot work in a browser.
| **Second CIPL format** | βœ… Done | The SAP-style `SHIPMENT#` layout behind a format registry: single currency, stated ECCNs, no weights, two-digit dates. |
| **Reconciliation** | βœ… Done | USD set selection, invoice↔packing-list join, grouping by Schedule B + D/F, blocking totals checks. |
| **Schedule B validation** | βœ… Done | Census AES dataset: 10 digits, currently active, required unit of quantity, description plausibility. Staleness warning once a January/July revision passes the dataset's date. |
| **Unit of quantity** | βœ… Done | Each row filed in the unit its commodity number requires β€” the net weight where Schedule B reports a code by weight β€” with the unit changeable per code on the review screen, carried identically into both SLIs and both keying sheets. Never invents a figure a shipment cannot state. |
| **Incoterms** | βœ… Done | The rule read off `DAP Singapore`, `FOB Origin - Collect` or `Ex Works` rather than matched literally, with the named place preserved and written where a form has no box for it. Rules withdrawn since Incoterms 2020 reported, never remapped. |
| **Item library** | βœ… Done | Item-master import (.xlsx/.csv/.tsv, columns matched by heading, explicit weight unit) supplying per-part weights, with the `####.##.####` + concordance filter flagging codes to fix by part number. |
| **Nippon Express adapter** | βœ… Done | Blank form 01/04/2022, 8 commodity rows, per-cell fields. |
| **CEVA adapter** | βœ… Done | Blank form 11201-C3 rev. 8/2023, multiline column fields, whole-dollar values. |
Expand Down
31 changes: 29 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,14 @@ export function App() {
const [carrierId, setCarrierId] = useState<ShipmentCarrierId>('nippon-express')
const [profile, setProfile] = useState<CompanyProfile>(EMPTY_PROFILE)
const [settings, setSettings] = useState<ShipmentSettings>(() => defaultShipmentSettings(getAdapter('nippon-express')))
/**
* The unit of quantity to file each commodity number in, keyed by normalised code.
*
* Per shipment rather than saved, and empty unless somebody changes something: the default
* already follows Schedule B wherever these documents can state it, and a unit remembered
* from a previous shipment would quietly file goods that were measured differently.
*/
const [reportingUnits, setReportingUnits] = useState<Record<string, string>>({})
const [overrides, setOverrides] = useState<OverrideRecord[]>([])
const [partOverrides, setPartOverrides] = useState<PartOverrideRecord[]>([])
const [items, setItems] = useState<ItemLibraryEntry[]>([])
Expand Down Expand Up @@ -255,6 +263,10 @@ export function App() {
setBusy(true)
setParsed(next)
setError(null)
// A unit was chosen for the commodity numbers on the *last* document. These goods are
// measured however this document says they are, so the choice starts again from the
// Schedule B default rather than carrying over onto codes that happen to repeat.
setReportingUnits({})

try {
const header = next.headers[next.availableSets[0]]
Expand Down Expand Up @@ -338,9 +350,10 @@ export function App() {
// Only consulted for formats that state no weights; a printed weight always wins.
unitWeightsByPart,
itemsByPart,
reportingUnits,
maxRows: adapter.maxCommodityRows,
})
}, [parsed, scheduleB, settings.eccn, settings.sme, settings.license, overrides, codesByPart, unitWeightsByPart, itemsByPart, adapter])
}, [parsed, scheduleB, settings.eccn, settings.sme, settings.license, overrides, codesByPart, unitWeightsByPart, itemsByPart, reportingUnits, adapter])

const draft = useMemo(
() => (reconciliation ? buildDraft(reconciliation, profile, settings, adapter) : null),
Expand Down Expand Up @@ -829,7 +842,21 @@ export function App() {
onSaveDescription={(part, description, text) => void savePartDescription(part, description, text)}
onClearCode={(part) => void clearPartCode(part)}
/>
<CommodityTable reconciliation={reconciliation} />
<CommodityTable
reconciliation={reconciliation}
reportingUnits={reportingUnits}
onReportingUnitChange={(code, unit) =>
setReportingUnits((current) => {
// An empty choice is "use the Schedule B default", which is the absence of
// an entry rather than an entry saying nothing β€” otherwise the blank sits
// in the record and reads later as a unit somebody picked.
const next = { ...current }
if (unit) next[code] = unit
else delete next[code]
return next
})
}
/>
<ChecksPanel checks={checks} canGenerate={canGenerate} />
<OverridesPanel
reconciliation={reconciliation}
Expand Down
Loading
Loading