From 101bf69271f3814631c19a82a49f6cae274485c7 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 27 Jul 2026 09:55:15 +0000 Subject: [PATCH] feat(warehouses): map GRN numbers to the goods owner GRN--- 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. --- .../src/common/grn.util.spec.ts | 40 +++++ apps/edr-freight-api/src/common/grn.util.ts | 34 +++- ...990000000000-IndodeYardsAndCargoRouting.ts | 131 +++++++++++++++ .../facility-handling.service.ts | 2 + .../dto/create-warehouse-yard.dto.ts | 27 +++- .../entities/warehouse-yard.entity.ts | 31 +++- .../warehouses/scheduling-read.facade.ts | 7 + .../warehouses/warehouse-inventory.service.ts | 61 +++++-- .../warehouses/warehouse-yards.repository.ts | 19 ++- .../warehouses/warehouse-yards.service.ts | 14 +- .../warehouses/ReceiveInventoryModal.tsx | 81 ++++++++-- .../src/components/warehouses/options.test.ts | 153 ++++++++++++++++++ .../src/components/warehouses/options.ts | 54 +++++++ .../src/pages/warehouses/ArrivalQueuePage.tsx | 89 +++++++--- .../backoffice/src/types/warehouse.ts | 11 ++ 15 files changed, 690 insertions(+), 64 deletions(-) create mode 100644 apps/edr-freight-api/src/common/grn.util.spec.ts create mode 100644 apps/edr-freight-api/src/migrations/2990000000000-IndodeYardsAndCargoRouting.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/warehouses/options.test.ts diff --git a/apps/edr-freight-api/src/common/grn.util.spec.ts b/apps/edr-freight-api/src/common/grn.util.spec.ts new file mode 100644 index 000000000..d95c934ca --- /dev/null +++ b/apps/edr-freight-api/src/common/grn.util.spec.ts @@ -0,0 +1,40 @@ +import { generateGrnNumber, grnOwnerSlug } from './grn.util'; + +/** + * The GRN is mapped to the goods owner for BOTH directions, so a note is + * identifiable by who owns the cargo. The reference slice stays the uniqueness + * anchor — one owner can have several bookings received the same day. + */ +const date = new Date('2026-07-27T09:15:00Z'); +const bookingId = '1a2b3c4d-1111-2222-3333-444455556666'; + +describe('GRN number', () => { + it('maps an import GRN to the owner', () => { + expect(generateGrnNumber('IMPORT', bookingId, date, 'Shafici Pharmaceutical')).toBe( + 'GRN-IMPORT-20260727-SHAFICIPHARM-1A2B3C4D', + ); + }); + + it('maps an export GRN to the owner the same way', () => { + expect(generateGrnNumber('EXPORT', bookingId, date, 'Tria Trading PLC')).toBe( + 'GRN-EXPORT-20260727-TRIATRADINGP-1A2B3C4D', + ); + }); + + it('keeps the owner-less format when there is no owner (manual walk-in)', () => { + expect(generateGrnNumber('WH', bookingId, date)).toBe('GRN-WH-20260727-1A2B3C4D'); + expect(generateGrnNumber('WH', bookingId, date, ' ')).toBe('GRN-WH-20260727-1A2B3C4D'); + }); + + it('stays unique per booking for one owner on one day', () => { + const a = generateGrnNumber('IMPORT', bookingId, date, 'Acme'); + const b = generateGrnNumber('IMPORT', 'ffffffff-9999-0000-0000-000000000000', date, 'Acme'); + expect(a).not.toBe(b); + }); + + it('strips punctuation and caps the owner segment', () => { + expect(grnOwnerSlug('Ethio-Djibouti Railway S.C.')).toBe('ETHIODJIBOUT'); + expect(grnOwnerSlug('a/b c')).toBe('ABC'); + expect(grnOwnerSlug(null)).toBeNull(); + }); +}); diff --git a/apps/edr-freight-api/src/common/grn.util.ts b/apps/edr-freight-api/src/common/grn.util.ts index 5cae30302..128e0496a 100644 --- a/apps/edr-freight-api/src/common/grn.util.ts +++ b/apps/edr-freight-api/src/common/grn.util.ts @@ -1,13 +1,41 @@ /** - * Goods Received Note number: `GRN---`. + * Goods Received Note number: `GRN----`. + * + * The GRN is mapped to the goods OWNER (the booking's customer / consignee) for + * both import and export, so a note is identifiable by who owns the cargo + * without opening it. The trailing reference slice stays as the uniqueness + * anchor — one owner can have several bookings received on the same day. + * Owner-less receipts (manual walk-ins with no booking) fall back to the + * original `GRN---` form. * * Shared so a GRN raised at a load/unload facility is indistinguishable from one * raised in a warehouse — the two live in different tables * (facility_handling_events vs warehouse_inventory), and a second generator would * eventually let their formats drift apart. */ -export function generateGrnNumber(direction: string, referenceId: string, date: Date): string { +export function generateGrnNumber( + direction: string, + referenceId: string, + date: Date, + ownerName?: string | null, +): string { const stamp = date.toISOString().slice(0, 10).replace(/-/g, ''); const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase(); - return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`; + const owner = grnOwnerSlug(ownerName); + const base = `GRN-${direction.toUpperCase()}-${stamp}`; + return owner ? `${base}-${owner}-${suffix}` : `${base}-${suffix}`; +} + +/** + * Owner name → GRN-safe token: letters/digits only, upper-cased, capped so a + * long company name can't run away with the number. Null when there is nothing + * usable, which drops the segment rather than emitting an empty `--`. + */ +export function grnOwnerSlug(ownerName?: string | null): string | null { + const slug = (ownerName ?? '') + .normalize('NFKD') + .replace(/[^a-zA-Z0-9]+/g, '') + .toUpperCase() + .slice(0, 12); + return slug || null; } diff --git a/apps/edr-freight-api/src/migrations/2990000000000-IndodeYardsAndCargoRouting.ts b/apps/edr-freight-api/src/migrations/2990000000000-IndodeYardsAndCargoRouting.ts new file mode 100644 index 000000000..d89e0c04b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2990000000000-IndodeYardsAndCargoRouting.ts @@ -0,0 +1,131 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Indode's real 11-yard layout, plus the plumbing to auto-route a booking to + * the right yard by cargo type (and, for container yards, trade direction): + * + * - `warehouse_yards.direction` — IMPORT | EXPORT | BOTH | null. Only + * meaningful for CONTAINER_YARD, where import and export stacks are + * physically separate (Yard 5 vs Yard 6). Everything else takes cargo + * either way. A CONTAINER_YARD left at null/BOTH is a signal too: it means + * "not a customer cargo yard" — Yards 10/11 (service/equipment) are + * CONTAINER_YARD structurally but must never be offered for ordinary + * import/export cargo, so the frontend match requires an EXACT IMPORT/ + * EXPORT direction hit for container freight rather than treating BOTH as + * a wildcard. + * - `warehouse_yard_cargo_types` — which cargo types a yard accepts (mirrors + * the existing `cargo_type_wagon_types` join table). Empty = open to any + * cargo type of the yard's structural type (additive, never restrictive + * by default), so this cannot break a yard nobody has configured yet. + * + * Three cargo types didn't exist yet (Fertilizer, Coffee, Tea) — added here + * so Yards 1 and 9 have a real mapping ready for when they reopen. + */ +export class IndodeYardsAndCargoRouting2990000000000 implements MigrationInterface { + name = "IndodeYardsAndCargoRouting2990000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.warehouse_yards + ADD COLUMN IF NOT EXISTS direction varchar(10) + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.warehouse_yard_cargo_types ( + yard_id uuid NOT NULL REFERENCES freight.warehouse_yards (id) ON DELETE CASCADE, + cargo_type_id uuid NOT NULL REFERENCES freight.cargo_types (id) ON DELETE CASCADE, + PRIMARY KEY (yard_id, cargo_type_id) + ) + `); + + // New cargo types Indode's yard list names but the catalog didn't have yet. + await queryRunner.query(` + INSERT INTO freight.cargo_types (code, cargo_type_name, unit_of_measure, is_active) + VALUES + ('FERTILIZER', 'Fertilizer', 'PER_TON', true), + ('COFFEE', 'Coffee', 'PER_TON', true), + ('TEA', 'Tea', 'PER_TON', true) + ON CONFLICT (code) DO NOTHING + `); + + // The 11 real yards at Indode Open Warehouse (code 'IOW'). + await queryRunner.query(` + INSERT INTO freight.warehouse_yards + (warehouse_id, name, code, type, direction, status, is_active) + SELECT w.id, y.name, y.code, y.type, y.direction, y.status, y.status = 'ACTIVE' + FROM freight.warehouses w + CROSS JOIN (VALUES + ('Y1', 'Bagged Cargo Discharge - Fertilizer', 'BULK_YARD', NULL, 'INACTIVE'), + ('Y2', 'Break Bulk', 'GENERAL_CARGO_YARD', NULL, 'ACTIVE'), + ('Y3', 'Ro-Ro / Pac', 'GENERAL_CARGO_YARD', NULL, 'ACTIVE'), + ('Y4', 'Dry Bulk', 'BULK_YARD', NULL, 'INACTIVE'), + ('Y5', 'Container Terminal - Import (Stack Area)', 'CONTAINER_YARD', 'IMPORT', 'ACTIVE'), + ('Y6', 'Container Terminal - Export', 'CONTAINER_YARD', 'EXPORT', 'ACTIVE'), + ('Y7', 'Cold Chain', 'COLD_STORAGE_YARD', NULL, 'INACTIVE'), + ('Y8', 'Chemical', 'HAZARDOUS_YARD', NULL, 'INACTIVE'), + ('Y9', 'Coffee and Tea', 'GENERAL_CARGO_YARD', NULL, 'INACTIVE'), + ('Y10', 'Container Service Yard - Maintenance', 'CONTAINER_YARD', 'BOTH', 'ACTIVE'), + ('Y11', 'Equipment (Empty Container)', 'CONTAINER_YARD', 'BOTH', 'ACTIVE') + ) AS y(code, name, type, direction, status) + WHERE w.code = 'IOW' + ON CONFLICT (warehouse_id, code) DO NOTHING + `); + + // One default zone per new yard, matching its yard's type — every existing + // yard (CY-1, CY-A) already follows this one-zone-per-yard shape. + await queryRunner.query(` + INSERT INTO freight.warehouse_zones (yard_id, name, code, type, status, is_active) + SELECT y.id, y.name || ' Zone 1', 'Z1', + CASE y.type + WHEN 'CONTAINER_YARD' THEN 'CONTAINER_ZONE' + WHEN 'COLD_STORAGE_YARD' THEN 'COLD_STORAGE_ZONE' + WHEN 'HAZARDOUS_YARD' THEN 'HAZARDOUS_ZONE' + WHEN 'BULK_YARD' THEN 'BULK_ZONE' + ELSE 'GENERAL_CARGO_ZONE' + END, + y.status, y.status = 'ACTIVE' + FROM freight.warehouse_yards y + JOIN freight.warehouses w ON w.id = y.warehouse_id + WHERE w.code = 'IOW' AND y.code LIKE 'Y%' + ON CONFLICT (yard_id, code) DO NOTHING + `); + + // Cargo-type routing. Yards 5/6/10/11 (CONTAINER_YARD) are intentionally + // left with no rows — direction alone decides those, per the entity comment. + await queryRunner.query(` + INSERT INTO freight.warehouse_yard_cargo_types (yard_id, cargo_type_id) + SELECT y.id, ct.id + FROM freight.warehouses w + JOIN freight.warehouse_yards y ON y.warehouse_id = w.id + JOIN (VALUES + ('Y1', 'FERTILIZER'), + ('Y2', 'STEEL_BILLET'), ('Y2', 'PLASTIC_BARREL'), ('Y2', 'MACHINERY'), ('Y2', 'LIVESTOCK'), + ('Y3', 'AUTOMOBILE'), ('Y3', 'TRUCK'), + ('Y4', 'BARLY'), ('Y4', 'BEANS'), ('Y4', 'BULK'), ('Y4', 'CEREAL'), + ('Y4', 'EDIBLE_OIL'), ('Y4', 'RICE'), ('Y4', 'SUGAR'), ('Y4', 'WHEAT'), + ('Y7', 'PERISHABLE'), + ('Y9', 'COFFEE'), ('Y9', 'TEA') + ) AS m(yard_code, cargo_code) ON m.yard_code = y.code + JOIN freight.cargo_types ct ON ct.code = m.cargo_code + WHERE w.code = 'IOW' + ON CONFLICT (yard_id, cargo_type_id) DO NOTHING + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DELETE FROM freight.warehouse_zones z + USING freight.warehouse_yards y, freight.warehouses w + WHERE z.yard_id = y.id AND y.warehouse_id = w.id + AND w.code = 'IOW' AND y.code LIKE 'Y%' + `); + await queryRunner.query(` + DELETE FROM freight.warehouse_yards y + USING freight.warehouses w + WHERE y.warehouse_id = w.id AND w.code = 'IOW' AND y.code LIKE 'Y%' + `); + // Cargo types and the join table are left in place — other data may have + // started referencing them since; dropping columns/tables is not reversible + // once real rows exist, and leaving them is harmless. + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/facility-handling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/facility-handling.service.ts index 20ac4859a..4fe20dbf5 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/facility-handling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/facility-handling.service.ts @@ -48,10 +48,12 @@ export class FacilityHandlingService { if (!facility?.hasFacility) return null; const occurredAt = input.occurredAt ?? new Date(); + // Mapped to the goods owner, same as every warehouse-raised GRN. const grnNumber = generateGrnNumber( booking.tradeDirection ?? 'DOMESTIC', booking.id, occurredAt, + booking.company?.name ?? null, ); // Link the storage record when this facility keeps cargo — that link is diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts index 56b9d0810..39a90ef8e 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts @@ -1,7 +1,12 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; +import { IsArray, IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; -import { WAREHOUSE_YARD_TYPES, WarehouseYardType } from '../entities/warehouse-yard.entity'; +import { + WAREHOUSE_YARD_DIRECTIONS, + WAREHOUSE_YARD_TYPES, + WarehouseYardDirection, + WarehouseYardType, +} from '../entities/warehouse-yard.entity'; export class CreateWarehouseYardDto { @ApiPropertyOptional({ format: 'uuid', description: 'Optional — taken from the route param when omitted' }) @@ -46,4 +51,22 @@ export class CreateWarehouseYardDto { @IsNumber() @Min(0) maxVolume?: number; + + @ApiPropertyOptional({ + enum: WAREHOUSE_YARD_DIRECTIONS, + description: 'Trade direction this yard serves. Only meaningful for CONTAINER_YARD — omit/BOTH for everything else.', + }) + @IsOptional() + @IsEnum(WAREHOUSE_YARD_DIRECTIONS) + direction?: WarehouseYardDirection; + + @ApiPropertyOptional({ + type: [String], + format: 'uuid', + description: 'Cargo types this yard accepts. Empty/omitted = open to any cargo type of this yard\'s structural type.', + }) + @IsOptional() + @IsArray() + @IsUUID('4', { each: true }) + cargoTypeIds?: string[]; } diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-yard.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-yard.entity.ts index 6e3c93292..5169f486a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-yard.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-yard.entity.ts @@ -1,6 +1,7 @@ import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; +import { Column, Entity, Index, JoinColumn, JoinTable, ManyToMany, ManyToOne, OneToMany } from 'typeorm'; +import { CargoType } from '../../rule-engine/entities/cargo-type.entity'; import { Warehouse } from './warehouse.entity'; import { WarehouseZone } from './warehouse-zone.entity'; @@ -16,6 +17,15 @@ export type WarehouseYardType = (typeof WAREHOUSE_YARD_TYPES)[number]; export const WAREHOUSE_YARD_STATUSES = ['ACTIVE', 'INACTIVE'] as const; export type WarehouseYardStatus = (typeof WAREHOUSE_YARD_STATUSES)[number]; +/** + * Which trade direction this yard serves. Only meaningful for CONTAINER_YARD, + * where import and export stacks are physically separate areas (e.g. Indode's + * Yard 5 for import vs Yard 6 for export) — every other yard type takes cargo + * either way, so BOTH/null is the right default there. + */ +export const WAREHOUSE_YARD_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const; +export type WarehouseYardDirection = (typeof WAREHOUSE_YARD_DIRECTIONS)[number]; + @Entity({ schema: 'freight', name: 'warehouse_yards' }) @Index(['warehouseId']) @Index(['type']) @@ -64,6 +74,25 @@ export class WarehouseYard extends BaseEntity { @Column({ name: 'is_active', type: 'boolean', default: true }) isActive!: boolean; + /** Null = BOTH (no direction restriction). Only relevant for CONTAINER_YARD. */ + @Column({ name: 'direction', type: 'varchar', length: 10, nullable: true }) + direction?: WarehouseYardDirection | null; + + /** + * Cargo types this yard accepts — e.g. Yard 3 (Ro-Ro) takes Automobile/Truck, + * Yard 9 (Coffee and Tea) takes only those two. Empty/no rows = open to any + * cargo type of the yard's structural `type` (the pre-existing behavior), + * so this is additive and never blocks a yard that hasn't been configured. + */ + @ManyToMany(() => CargoType) + @JoinTable({ + name: 'warehouse_yard_cargo_types', + schema: 'freight', + joinColumn: { name: 'yard_id', referencedColumnName: 'id' }, + inverseJoinColumn: { name: 'cargo_type_id', referencedColumnName: 'id' }, + }) + cargoTypes?: CargoType[]; + @OneToMany(() => WarehouseZone, (zone) => zone.yard) zones?: WarehouseZone[]; } diff --git a/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts index fd967cc8c..59f4b5478 100644 --- a/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts +++ b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts @@ -17,6 +17,8 @@ export interface ImportTrainRow { route: string | null; origin: string | null; destination: string | null; + /** freight.yards.id the train is heading to — lets the frontend restrict the unload warehouse picker to the warehouse actually at this station, instead of listing every warehouse. */ + destinationStationId: string | null; arrivalTime: string | null; totalBookings: number; totalContainers: number; @@ -38,6 +40,8 @@ export interface ImportTrainItemRow { freightType: string | null; containerNumber: string | null; cargoType: string | null; + /** Cargo type CODE (e.g. "WHEAT"), for matching against a yard's configured cargo types — `cargoType` above is the display name. */ + cargoTypeCode: string | null; weight: number | null; arrivalTime: string | null; currentStatus: string | null; @@ -205,6 +209,7 @@ export class SchedulingReadFacade { ts.train_number AS "trainNumber", oy.code AS "origin", dy.code AS "destination", + dy.id AS "destinationStationId", oy.country AS "originCountry", dy.country AS "destinationCountry", COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime", @@ -280,6 +285,7 @@ export class SchedulingReadFacade { WHERE c.booking_id = b.id AND c.deleted_at IS NULL ORDER BY c.container_number LIMIT 1) AS "containerNumber", COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType", + cgt.code AS "cargoTypeCode", b.cargo_total_weight_vgm AS "weight", COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime", COALESCE(inv.status, b.status) AS "currentStatus", @@ -376,6 +382,7 @@ export class SchedulingReadFacade { ts.train_number AS "trainNumber", oy.code AS "origin", dy.code AS "destination", + dy.id AS "destinationStationId", dy.label AS "destinationName", oy.country AS "originCountry", dy.country AS "destinationCountry", diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 275cf08dc..c0b3cc060 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -1141,6 +1141,8 @@ export class WarehouseInventoryService { async autoUnloadArrived(): Promise { const arrived: { id: string; + /** Goods owner (company) — the GRN number is mapped to it. */ + customer: string | null; weight: string | null; freightType: string | null; tradeDirection: string | null; @@ -1158,10 +1160,12 @@ export class WarehouseInventoryService { WHERE bc2.booking_id = b.id AND bcu.deleted_at IS NULL) ) AS weight, b.freight_type AS "freightType", b.trade_direction AS "tradeDirection", - cgt.code AS "cargoTypeCode" + cgt.code AS "cargoTypeCode", + company.name AS customer FROM freight.bookings b LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id + LEFT JOIN freight.companies company ON company.id = b.company_id WHERE b.status = ANY($1) AND b.deleted_at IS NULL AND inv.id IS NULL`, [this.ARRIVED_BOOKING_STATUSES], ); @@ -1198,7 +1202,7 @@ export class WarehouseInventoryService { status: 'RECEIVED', arrivedAt: new Date(), ...(booking.tradeDirection === 'EXPORT' - ? { grnNumber: this.generateGrnNumber('EXPORT', booking.id, new Date()) } + ? { grnNumber: this.generateGrnNumber('EXPORT', booking.id, new Date(), booking.customer) } : {}), notes: allocated?.rule ? `Auto-unloaded → ${allocated.path}` : 'Auto-unloaded from arrival queue', }); @@ -1223,12 +1227,19 @@ export class WarehouseInventoryService { // A GRN is the receipt for cargo entering the warehouse, so every booking // gets one on unload — import as well as export. The direction only decides // the GRN prefix, not whether one is issued. - const [bookingRow]: Array<{ tradeDirection: string | null }> = await this.dataSource.query( - `SELECT trade_direction AS "tradeDirection" - FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, - [bookingId], - ); + // The GRN is mapped to the goods owner (the booking's company), so pull it + // alongside the direction rather than issuing an owner-less number. + const [bookingRow]: Array<{ tradeDirection: string | null; ownerName: string | null }> = + await this.dataSource.query( + `SELECT b.trade_direction AS "tradeDirection", + company.name AS "ownerName" + FROM freight.bookings b + LEFT JOIN freight.companies company ON company.id = b.company_id + WHERE b.id = $1 AND b.deleted_at IS NULL`, + [bookingId], + ); const grnDirection = bookingRow?.tradeDirection ?? 'WH'; + const ownerName = bookingRow?.ownerName ?? null; let location: DefaultLocation | null = dto.warehouseId && dto.yardId && dto.zoneId @@ -1252,7 +1263,7 @@ export class WarehouseInventoryService { // Keep an already-issued GRN rather than reissuing; mint one otherwise. ...(existing[0].grnNumber ? {} - : { grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt) }), + : { grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt, ownerName) }), notes: dto.notes ?? existing[0].notes ?? 'Unloaded', }); return this.findById(existing[0].id); @@ -1267,7 +1278,7 @@ export class WarehouseInventoryService { weight: 0, status: 'RECEIVED', arrivedAt, - grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt), + grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt, ownerName), notes: dto.notes ?? 'Unloaded', }); return this.findById(saved.id); @@ -1577,7 +1588,7 @@ export class WarehouseInventoryService { } const now = new Date(); - const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now); + const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now, booking.customer); const truckEntrance = dto.truckEntrance ? this.mergeSystemTruckEntrance(dto.truckEntrance, booking) : undefined; @@ -2143,6 +2154,8 @@ export class WarehouseInventoryService { const bookings: { id: string; status: string; + /** Goods owner (company) — the GRN number is mapped to it. */ + customer: string | null; weight: string | null; freightType: string | null; tradeDirection: string | null; @@ -2165,10 +2178,12 @@ export class WarehouseInventoryService { WHERE bc2.booking_id = b.id AND bcu.deleted_at IS NULL) ) AS weight, b.freight_type AS "freightType", b.trade_direction AS "tradeDirection", - cgt.code AS "cargoTypeCode" + cgt.code AS "cargoTypeCode", + company.name AS customer FROM freight.train_schedule_bookings tsb JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id + LEFT JOIN freight.companies company ON company.id = b.company_id WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL AND b.destination_yard_id = $2`, [scheduleId, schedule.destinationStationId], @@ -2235,7 +2250,7 @@ export class WarehouseInventoryService { unloadedAt: now, arrivedAt: existing.arrivedAt ?? now, // Import GRN is issued automatically at train unload. - ...(existing.grnNumber ? {} : { grnNumber: this.generateGrnNumber('IMPORT', booking.id, now) }), + ...(existing.grnNumber ? {} : { grnNumber: this.generateGrnNumber('IMPORT', booking.id, now, booking.customer) }), }); await this.activityLog.record({ activityType: 'INVENTORY_UNLOADED', @@ -2285,7 +2300,7 @@ export class WarehouseInventoryService { quantity: 1, weight: Number(booking.weight) || 0, status: 'UNLOADED', - grnNumber: this.generateGrnNumber('IMPORT', booking.id, now), + grnNumber: this.generateGrnNumber('IMPORT', booking.id, now, booking.customer), arrivedAt: now, unloadedAt: now, notes: allocated?.rule ? `Unloaded → ${allocated.path}` : 'Unloaded from arrived import train', @@ -2719,7 +2734,12 @@ export class WarehouseInventoryService { this.assertCapacity('Zone', zone, weight, volume, containerCount); const now = new Date(); - const grnNumber = this.generateGrnNumber(bookingDirection ?? 'WH', dto.bookingId ?? 'MANUAL', now); + const grnNumber = this.generateGrnNumber( + bookingDirection ?? 'WH', + dto.bookingId ?? 'MANUAL', + now, + truckEntrance?.ownerName ?? bookingSource?.customer, + ); const receiveNote = this.buildReceiveNote({ grnNumber, notes: dto.notes?.trim() || 'Single booking received', @@ -5350,7 +5370,9 @@ export class WarehouseInventoryService { }); const rows: Array<[string, unknown]> = [ ['Booking Reference', data.bookingReference], - ['Customer / Consignee', data.customerName], + // The GRN is mapped to the owner (import: consignee, export: shipper) — + // named explicitly so the note reads the same for both directions. + ["Owner's Name", data.customerName], ['Customer TIN', data.customerTin], ['Booking Status', data.bookingStatus], ['Service Type', data.serviceType], @@ -5981,8 +6003,13 @@ export class WarehouseInventoryService { } /** Shared with the facility handling flow — see common/grn.util.ts. */ - private generateGrnNumber(direction: string, referenceId: string, date: Date): string { - return generateGrnNumber(direction, referenceId, date); + private generateGrnNumber( + direction: string, + referenceId: string, + date: Date, + ownerName?: string | null, + ): string { + return generateGrnNumber(direction, referenceId, date, ownerName); } private async generateReleaseReference(item: WarehouseInventory): Promise { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.repository.ts index 99bbdd21f..41f6aacaf 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.repository.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.repository.ts @@ -1,8 +1,9 @@ import { BaseRepository } from '@edr/api-common'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { DeepPartial, Repository } from 'typeorm'; +import { CargoType } from '../rule-engine/entities/cargo-type.entity'; import { WarehouseYard } from './entities/warehouse-yard.entity'; @Injectable() @@ -10,4 +11,20 @@ export class WarehouseYardsRepository extends BaseRepository { constructor(@InjectRepository(WarehouseYard) repository: Repository) { super(repository); } + + /** The cargoTypes relation can't ride a column UPDATE — sync it via entity save, like the plain columns. */ + async update(id: string, data: DeepPartial): Promise { + const { cargoTypes, ...columns } = data; + if (Object.keys(columns).length) { + await this.repository.update(id, columns as never); + } + if (cargoTypes) { + const entity = await this.repository.findOne({ where: { id } as never }); + if (entity) { + entity.cargoTypes = cargoTypes as CargoType[]; + await this.repository.save(entity); + } + } + return this.findById(id); + } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts index 5b5e2b227..874de75db 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { CargoType } from '../rule-engine/entities/cargo-type.entity'; import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto'; import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto'; import { WarehouseYard } from './entities/warehouse-yard.entity'; @@ -15,7 +16,7 @@ export class WarehouseYardsService { findAll(): Promise { return this.yardsRepository.findAll({ - relations: { warehouse: true, zones: true }, + relations: { warehouse: true, zones: true, cargoTypes: true }, order: { code: 'ASC' }, }); } @@ -23,14 +24,14 @@ export class WarehouseYardsService { findByWarehouse(warehouseId: string): Promise { return this.yardsRepository.findAll({ where: { warehouseId }, - relations: { zones: true }, + relations: { zones: true, cargoTypes: true }, order: { code: 'ASC' }, }); } async findById(id: string): Promise { const yard = await this.yardsRepository.findById(id, { - relations: { warehouse: true, zones: true }, + relations: { warehouse: true, zones: true, cargoTypes: true }, }); if (!yard) { @@ -51,6 +52,7 @@ export class WarehouseYardsService { name: dto.name.trim(), code: dto.code.trim(), type: dto.type, + direction: dto.direction ?? null, capacityWeight: dto.capacityWeight ?? null, capacityContainers: dto.capacityContainers ?? null, maxWeight: dto.maxWeight ?? dto.capacityWeight ?? null, @@ -60,6 +62,8 @@ export class WarehouseYardsService { currentVolume: 0, status: 'ACTIVE', isActive: true, + // Join rows are written by the save (RESTRICT FK rejects unknown ids). + cargoTypes: (dto.cargoTypeIds ?? []).map((id) => ({ id }) as CargoType), }); } @@ -84,12 +88,16 @@ export class WarehouseYardsService { name: dto.name?.trim() ?? existing.name, code: dto.code?.trim() ?? existing.code, type: dto.type ?? existing.type, + direction: dto.direction ?? existing.direction, capacityWeight: newCapacityWeight, capacityContainers: newCapacityContainers, maxWeight: dto.maxWeight ?? existing.maxWeight, maxVolume: dto.maxVolume ?? existing.maxVolume, status, isActive: status === 'ACTIVE', + ...(dto.cargoTypeIds + ? { cargoTypes: dto.cargoTypeIds.map((cargoTypeId) => ({ id: cargoTypeId }) as CargoType) } + : {}), }); if (!updated) { diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index 54e60d8b2..cfbdd7b82 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -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({ {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); diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/options.test.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/options.test.ts new file mode 100644 index 000000000..9c0181b40 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/options.test.ts @@ -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 => + ({ + 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); + }); +}); diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/options.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/options.ts index d124a3325..958e2d7f7 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/options.ts +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/options.ts @@ -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); diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx index 61afa426c..8bb1080d9 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx @@ -18,6 +18,8 @@ import { WarehouseOpsKpiStrip, formatDate, formatNumber, + warehousesAtStation, + yardsForBooking, } from '@/components/warehouses'; import { useAutoUnloadArrivedBookings, @@ -49,14 +51,6 @@ const getPendingUnloadBookings = (train: ImportTrain) => const isFullyUnloaded = (train: ImportTrain) => Boolean(train.fullyUnloaded) || (train.totalBookings > 0 && getPendingUnloadBookings(train) === 0); -const locationTypesForFreight = (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 isContainerFreight = (freightType: string | null | undefined) => (freightType ?? '').toUpperCase() === 'CONTAINER'; @@ -65,7 +59,7 @@ function isUnloadPending(item: ImportTrainItem) { } function ImportTrainDetailRows({ - scheduleId, + train, warehouses, yards, zones, @@ -73,7 +67,7 @@ function ImportTrainDetailRows({ onAssignmentChange, onReadyChange, }: { - scheduleId: string; + train: ImportTrain; warehouses: Warehouse[]; yards: WarehouseYard[]; zones: WarehouseZone[]; @@ -81,11 +75,61 @@ function ImportTrainDetailRows({ onAssignmentChange: (bookingId: string, draft: AssignmentDraft) => void; onReadyChange: (ready: boolean) => void; }) { - const { data: items = [], isLoading } = useImportTrainItems(scheduleId); - const warehouseOptions = useMemo( - () => warehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })), - [warehouses], + const { data: items = [], isLoading } = useImportTrainItems(train.scheduleId); + // 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(isUnloadPending).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(isUnloadPending).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(isUnloadPending); @@ -135,12 +179,17 @@ function ImportTrainDetailRows({ {items.map((item: ImportTrainItem) => { const draft = assignments[item.bookingId] ?? {}; - const { yardTypes, zoneTypes } = locationTypesForFreight(item.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: item.freightType, + tradeDirection: 'IMPORT', + cargoTypeCode: item.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 = isUnloadPending(item); @@ -395,7 +444,7 @@ export default function ArrivalQueuePage() { ; capacityWeight: number | null; capacityContainers: number | null; maxWeight: number | null; @@ -518,6 +525,8 @@ export interface ImportTrain { route: string | null; origin: string | null; destination: string | null; + /** freight.yards.id the train is heading to — matches Warehouse.stationId, so the unload picker can be scoped to the warehouse actually at this station. */ + destinationStationId: string | null; departureTime?: string | null; arrivalTime: string | null; totalBookings: number; @@ -632,6 +641,8 @@ export interface ImportTrainItem { freightType: string | null; containerNumber: string | null; cargoType: string | null; + /** Cargo type CODE (e.g. "WHEAT"), for matching against a yard's configured cargo types — `cargoType` above is the display name. */ + cargoTypeCode: string | null; weight: number | null; arrivalTime: string | null; currentStatus: string | null;