feat(warehouse): require a GRN before export cargo can be loaded onto a train

The export chain is: paid -> received into the warehouse -> GRN raised on arrival
-> loaded onto the allocated wagon. Receipt was already structural (the inventory
row only exists once receive() runs) and the wagon was already required, but the
GRN was merely displayed, never enforced — so cargo could be loaded and
dispatched without one.

- loadable now also requires a GRN, so the queue won't offer un-GRN'd cargo.
- loadItemsOntoTrain skips items with no GRN, so the rule holds server-side and
  a hand-made API call can't bypass it.
- Read the GRN from inv.grn_number (what receive() stamps) and fall back to the
  note only for legacy/seeded rows; it previously read the note alone, which the
  real receive path merely mirrors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-07-16 13:32:38 +00:00
parent d9db4cbc0c
commit a26fa61d66

View File

@@ -1616,7 +1616,12 @@ export class WarehouseInventoryService {
ct.container_number AS "containerNumber",
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
inv.weight AS "weight",
substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') AS "grnNumber",
-- receive() stamps the GRN onto the row and mirrors it into the
-- note; prefer the column and fall back for legacy/seeded rows.
COALESCE(
inv.grn_number,
substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')
) AS "grnNumber",
inv.inspection_status AS "inspectionStatus",
inv.status AS "status",
wl.wagon_id AS "wagonId",
@@ -1649,7 +1654,11 @@ export class WarehouseInventoryService {
return rows.map((r) => ({
...r,
loadable: r.status === 'READY_FOR_LOADING' && Boolean(r.wagonId),
// Export flow: received at the warehouse -> GRN -> loaded onto its wagon.
// The row only exists once the goods were received, so requiring a GRN and
// an allocated wagon completes the chain.
loadable:
r.status === 'READY_FOR_LOADING' && Boolean(r.wagonId) && Boolean(r.grnNumber),
}));
}
@@ -1702,6 +1711,9 @@ export class WarehouseInventoryService {
if (!item) { skip('Not assigned to this train'); continue; }
if (item.status === 'LOADED') { skip('Already loaded'); continue; }
if (item.status !== 'READY_FOR_LOADING') { skip(`Not ready for loading (status ${item.status})`); continue; }
// Export: the GRN is raised when the goods arrive at the warehouse, and
// nothing rides a train without one.
if (!item.grnNumber) { skip('No GRN — receive the goods and generate the GRN first'); continue; }
if (!item.wagonId) { skip('No wagon allocated — allocate a wagon first'); continue; }
try {