mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 00:38:11 +00:00
feat(warehouses): map GRN numbers to the goods owner
GRN-<DIR>-<DATE>-<REF8> carried no owner, so a note couldn't be identified by who owns the cargo. Add an owner segment sourced from the booking's company at every generation point (import, export, facility, manual receive), keep REF8 for uniqueness, and label the GRN document row Owner's Name.
This commit is contained in:
@@ -80,7 +80,7 @@ import { MoveInventoryModal } from './MoveInventoryModal';
|
||||
import { ReleaseOrderModal } from './ReleaseOrderModal';
|
||||
import { StoreInventoryModal } from './StoreInventoryModal';
|
||||
import { WarehouseInquiryTable } from './WarehouseInquiryTable';
|
||||
import { extractDownloadErrorMessage, extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options';
|
||||
import { extractDownloadErrorMessage, extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions, warehousesAtStation, yardsForBooking } from './options';
|
||||
import { openPdfBlob } from './pdf';
|
||||
import '@/components/overview/overview.css';
|
||||
|
||||
@@ -1978,14 +1978,6 @@ function LoadedExportTab({
|
||||
);
|
||||
}
|
||||
|
||||
const importLocationTypesForFreight = (freightType: string | null | undefined) => {
|
||||
const normalized = (freightType ?? '').toUpperCase();
|
||||
if (normalized === 'CONTAINER') {
|
||||
return { yardTypes: ['CONTAINER_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['CONTAINER_ZONE', 'GENERAL_CARGO_ZONE'] };
|
||||
}
|
||||
return { yardTypes: ['BULK_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['BULK_ZONE', 'GENERAL_CARGO_ZONE'] };
|
||||
};
|
||||
|
||||
const isImportContainerFreight = (freightType: string | null | undefined) =>
|
||||
(freightType ?? '').toUpperCase() === 'CONTAINER';
|
||||
|
||||
@@ -2039,10 +2031,60 @@ function ImportTrainDetailTable({
|
||||
enabled: Boolean(train.scheduleId),
|
||||
}),
|
||||
);
|
||||
const warehouseOptions = useMemo(
|
||||
() => warehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })),
|
||||
[warehouses],
|
||||
// A train only ever unloads at the warehouse actually sitting at its
|
||||
// destination station — Indode's train never offers Sebeta's warehouse.
|
||||
const scopedWarehouses = useMemo(
|
||||
() => warehousesAtStation(warehouses, train.destinationStationId),
|
||||
[warehouses, train.destinationStationId],
|
||||
);
|
||||
const warehouseOptions = useMemo(
|
||||
() => scopedWarehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })),
|
||||
[scopedWarehouses],
|
||||
);
|
||||
// With exactly one warehouse at the station there is nothing to choose —
|
||||
// pre-fill it so staff only has to pick yard/zone, not re-discover Indode.
|
||||
useEffect(() => {
|
||||
if (scopedWarehouses.length !== 1) return;
|
||||
const onlyWarehouseId = scopedWarehouses[0].id;
|
||||
items.filter(isImportUnloadPending).forEach((item) => {
|
||||
if (!assignments[item.bookingId]?.warehouseId) {
|
||||
onAssignmentChange(item.bookingId, { ...assignments[item.bookingId], warehouseId: onlyWarehouseId });
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [scopedWarehouses, items]);
|
||||
|
||||
// Once a booking's warehouse is known, its yard (and then zone) follow from
|
||||
// what the cargo actually is — a Wheat booking only ever has one candidate
|
||||
// yard (Dry Bulk) once Indode's real yard layout is configured, so staff
|
||||
// never see a picker for something that isn't actually a choice.
|
||||
useEffect(() => {
|
||||
items.filter(isImportUnloadPending).forEach((item) => {
|
||||
const draft = assignments[item.bookingId];
|
||||
if (!draft?.warehouseId) return;
|
||||
|
||||
if (!draft.yardId) {
|
||||
const candidateYards = yardsForBooking(yards, {
|
||||
warehouseId: draft.warehouseId,
|
||||
freightType: item.freightType,
|
||||
tradeDirection: 'IMPORT',
|
||||
cargoTypeCode: item.cargoTypeCode,
|
||||
});
|
||||
if (candidateYards.length === 1) {
|
||||
onAssignmentChange(item.bookingId, { ...draft, yardId: candidateYards[0].id });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!draft.zoneId) {
|
||||
const candidateZones = zones.filter((zone) => zone.yardId === draft.yardId);
|
||||
if (candidateZones.length === 1) {
|
||||
onAssignmentChange(item.bookingId, { ...draft, zoneId: candidateZones[0].id });
|
||||
}
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [assignments, items, yards, zones]);
|
||||
|
||||
useEffect(() => {
|
||||
const pending = items.filter(isImportUnloadPending);
|
||||
@@ -2093,12 +2135,17 @@ function ImportTrainDetailTable({
|
||||
<Table.Tbody>
|
||||
{items.map((it: ImportTrainItem) => {
|
||||
const draft = assignments[it.bookingId] ?? {};
|
||||
const { yardTypes, zoneTypes } = importLocationTypesForFreight(it.freightType);
|
||||
const yardOptions = yards
|
||||
.filter((yard) => yard.warehouseId === draft.warehouseId && yardTypes.includes(yard.type))
|
||||
.map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` }));
|
||||
const yardOptions = yardsForBooking(yards, {
|
||||
warehouseId: draft.warehouseId,
|
||||
freightType: it.freightType,
|
||||
tradeDirection: 'IMPORT',
|
||||
cargoTypeCode: it.cargoTypeCode,
|
||||
}).map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` }));
|
||||
// The yard is already scoped to what this cargo can go into — a
|
||||
// zone's own type always matches its parent yard's purpose (see the
|
||||
// Indode seed migration), so no separate zone-type filter is needed.
|
||||
const zoneOptions = zones
|
||||
.filter((zone) => zone.yardId === draft.yardId && zoneTypes.includes(zone.type))
|
||||
.filter((zone) => zone.yardId === draft.yardId)
|
||||
.map((zone) => ({ value: zone.id, label: `${zone.name} (${zone.code})` }));
|
||||
const pending = isImportUnloadPending(it);
|
||||
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { warehousesAtStation, yardsForBooking } from "./options";
|
||||
import type { Warehouse, WarehouseYard } from "@/types/warehouse";
|
||||
|
||||
// Mirrors Indode's real 11-yard layout at a reduced scale, so these cases read
|
||||
// against the actual booking-routing decisions staff rely on.
|
||||
const yard = (overrides: Partial<WarehouseYard>): WarehouseYard =>
|
||||
({
|
||||
id: overrides.code,
|
||||
warehouseId: "indode",
|
||||
name: overrides.code,
|
||||
code: overrides.code,
|
||||
type: "GENERAL_CARGO_YARD",
|
||||
capacityWeight: null,
|
||||
capacityContainers: null,
|
||||
maxWeight: null,
|
||||
maxVolume: null,
|
||||
currentWeight: 0,
|
||||
currentContainers: 0,
|
||||
currentVolume: 0,
|
||||
status: "ACTIVE",
|
||||
isActive: true,
|
||||
...overrides,
|
||||
}) as WarehouseYard;
|
||||
|
||||
const YARDS: WarehouseYard[] = [
|
||||
yard({ code: "Y2", type: "GENERAL_CARGO_YARD", cargoTypes: [{ id: "1", code: "STEEL_BILLET" }] }),
|
||||
yard({ code: "Y3", type: "GENERAL_CARGO_YARD", cargoTypes: [{ id: "2", code: "AUTOMOBILE" }, { id: "3", code: "TRUCK" }] }),
|
||||
yard({ code: "Y4", type: "BULK_YARD", status: "INACTIVE", isActive: false, cargoTypes: [{ id: "4", code: "WHEAT" }] }),
|
||||
yard({ code: "Y5", type: "CONTAINER_YARD", direction: "IMPORT" }),
|
||||
yard({ code: "Y6", type: "CONTAINER_YARD", direction: "EXPORT" }),
|
||||
yard({ code: "Y10", type: "CONTAINER_YARD", direction: "BOTH" }), // service yard
|
||||
yard({ code: "Y11", type: "CONTAINER_YARD", direction: "BOTH" }), // equipment yard
|
||||
];
|
||||
|
||||
describe("yardsForBooking", () => {
|
||||
it("container import narrows to exactly the import stack", () => {
|
||||
const result = yardsForBooking(YARDS, {
|
||||
warehouseId: "indode",
|
||||
freightType: "CONTAINER",
|
||||
tradeDirection: "IMPORT",
|
||||
cargoTypeCode: null,
|
||||
});
|
||||
expect(result.map((y) => y.code)).toEqual(["Y5"]);
|
||||
});
|
||||
|
||||
it("container export narrows to exactly the export stack", () => {
|
||||
const result = yardsForBooking(YARDS, {
|
||||
warehouseId: "indode",
|
||||
freightType: "CONTAINER",
|
||||
tradeDirection: "EXPORT",
|
||||
cargoTypeCode: null,
|
||||
});
|
||||
expect(result.map((y) => y.code)).toEqual(["Y6"]);
|
||||
});
|
||||
|
||||
it("never offers a BOTH-direction container yard (service/equipment) for ordinary cargo", () => {
|
||||
const result = yardsForBooking(YARDS, {
|
||||
warehouseId: "indode",
|
||||
freightType: "CONTAINER",
|
||||
tradeDirection: "IMPORT",
|
||||
cargoTypeCode: null,
|
||||
});
|
||||
expect(result.map((y) => y.code)).not.toContain("Y10");
|
||||
expect(result.map((y) => y.code)).not.toContain("Y11");
|
||||
});
|
||||
|
||||
it("bulk cargo narrows to the yard configured for that exact cargo type", () => {
|
||||
const automobile = yardsForBooking(YARDS, {
|
||||
warehouseId: "indode",
|
||||
freightType: "BULK",
|
||||
tradeDirection: "IMPORT",
|
||||
cargoTypeCode: "AUTOMOBILE",
|
||||
});
|
||||
expect(automobile.map((y) => y.code)).toEqual(["Y3"]);
|
||||
|
||||
const steel = yardsForBooking(YARDS, {
|
||||
warehouseId: "indode",
|
||||
freightType: "BULK",
|
||||
tradeDirection: "IMPORT",
|
||||
cargoTypeCode: "STEEL_BILLET",
|
||||
});
|
||||
expect(steel.map((y) => y.code)).toEqual(["Y2"]);
|
||||
});
|
||||
|
||||
it("falls back to every non-container yard when the one configured for this cargo type is closed", () => {
|
||||
// Y4 (Dry Bulk, WHEAT) is inactive — never strand staff with an empty
|
||||
// picker just because the ideal yard is closed; same safety net as
|
||||
// warehousesAtStation falling back when a station has no mapped warehouse.
|
||||
const result = yardsForBooking(YARDS, {
|
||||
warehouseId: "indode",
|
||||
freightType: "BULK",
|
||||
tradeDirection: "IMPORT",
|
||||
cargoTypeCode: "WHEAT",
|
||||
});
|
||||
expect(result.map((y) => y.code).sort()).toEqual(["Y2", "Y3"]);
|
||||
});
|
||||
|
||||
it("falls back to every non-container yard when no yard is configured for that cargo type yet", () => {
|
||||
const result = yardsForBooking(YARDS, {
|
||||
warehouseId: "indode",
|
||||
freightType: "BULK",
|
||||
tradeDirection: "IMPORT",
|
||||
cargoTypeCode: "SOMETHING_UNMAPPED",
|
||||
});
|
||||
expect(result.map((y) => y.code).sort()).toEqual(["Y2", "Y3"]);
|
||||
});
|
||||
|
||||
it("a yard with no configured cargo types is open to anything (unconfigured, not restrictive)", () => {
|
||||
const openYard = yard({ code: "GENERIC", type: "BULK_YARD" });
|
||||
const result = yardsForBooking([...YARDS, openYard], {
|
||||
warehouseId: "indode",
|
||||
freightType: "BULK",
|
||||
tradeDirection: "IMPORT",
|
||||
cargoTypeCode: "STEEL_BILLET",
|
||||
});
|
||||
expect(result.map((y) => y.code).sort()).toEqual(["GENERIC", "Y2"]);
|
||||
});
|
||||
|
||||
it("only offers yards at the requested warehouse", () => {
|
||||
const otherWarehouseYard = yard({ code: "SEBETA-Y1", warehouseId: "sebeta", type: "GENERAL_CARGO_YARD" });
|
||||
const result = yardsForBooking([...YARDS, otherWarehouseYard], {
|
||||
warehouseId: "indode",
|
||||
freightType: "BULK",
|
||||
tradeDirection: "IMPORT",
|
||||
cargoTypeCode: null,
|
||||
});
|
||||
expect(result.map((y) => y.code)).not.toContain("SEBETA-Y1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("warehousesAtStation", () => {
|
||||
const warehouse = (id: string, stationId: string | null): Warehouse =>
|
||||
({ id, stationId, name: id, code: id } as Warehouse);
|
||||
|
||||
it("restricts to the warehouse at the given station", () => {
|
||||
const warehouses = [warehouse("indode", "station-a"), warehouse("sebeta", "station-b")];
|
||||
const result = warehousesAtStation(warehouses, "station-a");
|
||||
expect(result.map((w) => w.id)).toEqual(["indode"]);
|
||||
});
|
||||
|
||||
it("falls back to every warehouse when the station has no match", () => {
|
||||
const warehouses = [warehouse("indode", "station-a"), warehouse("sebeta", "station-b")];
|
||||
const result = warehousesAtStation(warehouses, "station-unknown");
|
||||
expect(result).toEqual(warehouses);
|
||||
});
|
||||
|
||||
it("falls back to every warehouse when the station is null", () => {
|
||||
const warehouses = [warehouse("indode", "station-a")];
|
||||
expect(warehousesAtStation(warehouses, null)).toEqual(warehouses);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
WAREHOUSE_ZONE_TYPES,
|
||||
WAREHOUSE_STATUSES,
|
||||
INVENTORY_STATUSES,
|
||||
type Warehouse,
|
||||
type WarehouseYard,
|
||||
} from '@/types/warehouse';
|
||||
|
||||
export const humanizeEnum = (value: string) =>
|
||||
@@ -16,6 +18,58 @@ export const humanizeEnum = (value: string) =>
|
||||
const toOptions = (values: readonly string[]) =>
|
||||
values.map((value) => ({ value, label: humanizeEnum(value) }));
|
||||
|
||||
/**
|
||||
* Warehouses actually located at a train's station — e.g. a train destined for
|
||||
* Indode should only offer Indode's own warehouse, not Sebeta's or Modjo's.
|
||||
* Falls back to every warehouse when the station is unmapped (no `stationId`
|
||||
* match anywhere), so unusual/legacy data never blocks the unload flow entirely.
|
||||
*/
|
||||
export const warehousesAtStation = (warehouses: Warehouse[], stationId: string | null | undefined) => {
|
||||
if (!stationId) return warehouses;
|
||||
const atStation = warehouses.filter((w) => w.stationId === stationId);
|
||||
return atStation.length ? atStation : warehouses;
|
||||
};
|
||||
|
||||
/**
|
||||
* Yards at ONE warehouse eligible to receive a booking, given what it actually
|
||||
* is — e.g. at Indode: container import always narrows to Yard 5, export to
|
||||
* Yard 6; a Wheat booking narrows to Yard 4 (Dry Bulk), not Break Bulk or
|
||||
* Coffee/Tea. Mirrors `warehousesAtStation`'s fallback philosophy: an
|
||||
* unconfigured yard (no cargo types set) stays open rather than disappearing,
|
||||
* but a yard that IS configured for other cargo never shows for a mismatch.
|
||||
*
|
||||
* Container yards are the one case with no such fallback: a CONTAINER_YARD
|
||||
* left at direction BOTH/null (Indode's Yard 10 service yard, Yard 11
|
||||
* equipment yard) is a service/equipment yard, not a customer cargo yard, and
|
||||
* must never be offered just because the exact-direction stack is missing.
|
||||
*/
|
||||
export const yardsForBooking = (
|
||||
yards: WarehouseYard[],
|
||||
params: {
|
||||
warehouseId: string | null | undefined;
|
||||
freightType: string | null | undefined;
|
||||
tradeDirection: string | null | undefined;
|
||||
cargoTypeCode: string | null | undefined;
|
||||
},
|
||||
): WarehouseYard[] => {
|
||||
const atWarehouse = yards.filter((y) => y.warehouseId === params.warehouseId && y.isActive);
|
||||
const isContainer = (params.freightType ?? '').toUpperCase() === 'CONTAINER';
|
||||
|
||||
if (isContainer) {
|
||||
const direction = (params.tradeDirection ?? '').toUpperCase();
|
||||
return atWarehouse.filter((y) => y.type === 'CONTAINER_YARD' && y.direction === direction);
|
||||
}
|
||||
|
||||
const nonContainer = atWarehouse.filter((y) => y.type !== 'CONTAINER_YARD');
|
||||
if (!params.cargoTypeCode) return nonContainer;
|
||||
|
||||
const cargoMatched = nonContainer.filter((y) => {
|
||||
const codes = (y.cargoTypes ?? []).map((c) => c.code);
|
||||
return codes.length === 0 || codes.includes(params.cargoTypeCode as string);
|
||||
});
|
||||
return cargoMatched.length ? cargoMatched : nonContainer;
|
||||
};
|
||||
|
||||
export const warehouseTypeOptions = toOptions(WAREHOUSE_TYPES);
|
||||
export const yardTypeOptions = toOptions(WAREHOUSE_YARD_TYPES);
|
||||
export const zoneTypeOptions = toOptions(WAREHOUSE_ZONE_TYPES);
|
||||
|
||||
Reference in New Issue
Block a user