Merge pull request #974 from Tria-plc/warehouseselection

feat(warehouses): map GRN numbers to the goods owner

Each booking is keyed by its own bookingId and matched using its own freightType/cargoTypeCode — not the train's. So on one Indode train with, say, 3 bookings (Wheat, Steel Billet, a container), all 3 can auto-select simultaneously, each to its own correct yard:

Wheat → Dry Bulk (Yard 4)
Steel Billet → Break Bulk (Yard 2)
Container import → Yard 5
Only requirement per booking: warehouse already picked (auto-fills first, single-warehouse case) and exactly one yard candidate for that booking's own cargo type. Bookings with an ambiguous/unmapped cargo type just get a real dropdown instead, independently of the others.
This commit is contained in:
Hagernesh Tadesse
2026-07-27 12:58:59 +03:00
committed by GitHub
15 changed files with 690 additions and 64 deletions

View File

@@ -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();
});
});

View File

@@ -1,13 +1,41 @@
/** /**
* Goods Received Note number: `GRN-<DIRECTION>-<YYYYMMDD>-<REF8>`. * Goods Received Note number: `GRN-<DIRECTION>-<YYYYMMDD>-<OWNER>-<REF8>`.
*
* 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-<DIRECTION>-<YYYYMMDD>-<REF8>` form.
* *
* Shared so a GRN raised at a load/unload facility is indistinguishable from one * Shared so a GRN raised at a load/unload facility is indistinguishable from one
* raised in a warehouse — the two live in different tables * raised in a warehouse — the two live in different tables
* (facility_handling_events vs warehouse_inventory), and a second generator would * (facility_handling_events vs warehouse_inventory), and a second generator would
* eventually let their formats drift apart. * 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 stamp = date.toISOString().slice(0, 10).replace(/-/g, '');
const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase(); 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;
} }

View File

@@ -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<void> {
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<void> {
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.
}
}

View File

@@ -48,10 +48,12 @@ export class FacilityHandlingService {
if (!facility?.hasFacility) return null; if (!facility?.hasFacility) return null;
const occurredAt = input.occurredAt ?? new Date(); const occurredAt = input.occurredAt ?? new Date();
// Mapped to the goods owner, same as every warehouse-raised GRN.
const grnNumber = generateGrnNumber( const grnNumber = generateGrnNumber(
booking.tradeDirection ?? 'DOMESTIC', booking.tradeDirection ?? 'DOMESTIC',
booking.id, booking.id,
occurredAt, occurredAt,
booking.company?.name ?? null,
); );
// Link the storage record when this facility keeps cargo — that link is // Link the storage record when this facility keeps cargo — that link is

View File

@@ -1,7 +1,12 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; 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 { export class CreateWarehouseYardDto {
@ApiPropertyOptional({ format: 'uuid', description: 'Optional — taken from the route param when omitted' }) @ApiPropertyOptional({ format: 'uuid', description: 'Optional — taken from the route param when omitted' })
@@ -46,4 +51,22 @@ export class CreateWarehouseYardDto {
@IsNumber() @IsNumber()
@Min(0) @Min(0)
maxVolume?: number; 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[];
} }

View File

@@ -1,6 +1,7 @@
import { BaseEntity } from '@edr/api-common'; 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 { Warehouse } from './warehouse.entity';
import { WarehouseZone } from './warehouse-zone.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 const WAREHOUSE_YARD_STATUSES = ['ACTIVE', 'INACTIVE'] as const;
export type WarehouseYardStatus = (typeof WAREHOUSE_YARD_STATUSES)[number]; 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' }) @Entity({ schema: 'freight', name: 'warehouse_yards' })
@Index(['warehouseId']) @Index(['warehouseId'])
@Index(['type']) @Index(['type'])
@@ -64,6 +74,25 @@ export class WarehouseYard extends BaseEntity {
@Column({ name: 'is_active', type: 'boolean', default: true }) @Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean; 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) @OneToMany(() => WarehouseZone, (zone) => zone.yard)
zones?: WarehouseZone[]; zones?: WarehouseZone[];
} }

View File

@@ -17,6 +17,8 @@ export interface ImportTrainRow {
route: string | null; route: string | null;
origin: string | null; origin: string | null;
destination: 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; arrivalTime: string | null;
totalBookings: number; totalBookings: number;
totalContainers: number; totalContainers: number;
@@ -38,6 +40,8 @@ export interface ImportTrainItemRow {
freightType: string | null; freightType: string | null;
containerNumber: string | null; containerNumber: string | null;
cargoType: 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; weight: number | null;
arrivalTime: string | null; arrivalTime: string | null;
currentStatus: string | null; currentStatus: string | null;
@@ -205,6 +209,7 @@ export class SchedulingReadFacade {
ts.train_number AS "trainNumber", ts.train_number AS "trainNumber",
oy.code AS "origin", oy.code AS "origin",
dy.code AS "destination", dy.code AS "destination",
dy.id AS "destinationStationId",
oy.country AS "originCountry", oy.country AS "originCountry",
dy.country AS "destinationCountry", dy.country AS "destinationCountry",
COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime", 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 WHERE c.booking_id = b.id AND c.deleted_at IS NULL
ORDER BY c.container_number LIMIT 1) AS "containerNumber", ORDER BY c.container_number LIMIT 1) AS "containerNumber",
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType", COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
cgt.code AS "cargoTypeCode",
b.cargo_total_weight_vgm AS "weight", b.cargo_total_weight_vgm AS "weight",
COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime", COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime",
COALESCE(inv.status, b.status) AS "currentStatus", COALESCE(inv.status, b.status) AS "currentStatus",
@@ -376,6 +382,7 @@ export class SchedulingReadFacade {
ts.train_number AS "trainNumber", ts.train_number AS "trainNumber",
oy.code AS "origin", oy.code AS "origin",
dy.code AS "destination", dy.code AS "destination",
dy.id AS "destinationStationId",
dy.label AS "destinationName", dy.label AS "destinationName",
oy.country AS "originCountry", oy.country AS "originCountry",
dy.country AS "destinationCountry", dy.country AS "destinationCountry",

View File

@@ -1141,6 +1141,8 @@ export class WarehouseInventoryService {
async autoUnloadArrived(): Promise<AutoUnloadResult> { async autoUnloadArrived(): Promise<AutoUnloadResult> {
const arrived: { const arrived: {
id: string; id: string;
/** Goods owner (company) — the GRN number is mapped to it. */
customer: string | null;
weight: string | null; weight: string | null;
freightType: string | null; freightType: string | null;
tradeDirection: string | null; tradeDirection: string | null;
@@ -1158,10 +1160,12 @@ export class WarehouseInventoryService {
WHERE bc2.booking_id = b.id AND bcu.deleted_at IS NULL) WHERE bc2.booking_id = b.id AND bcu.deleted_at IS NULL)
) AS weight, ) AS weight,
b.freight_type AS "freightType", b.trade_direction AS "tradeDirection", 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 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.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.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`, WHERE b.status = ANY($1) AND b.deleted_at IS NULL AND inv.id IS NULL`,
[this.ARRIVED_BOOKING_STATUSES], [this.ARRIVED_BOOKING_STATUSES],
); );
@@ -1198,7 +1202,7 @@ export class WarehouseInventoryService {
status: 'RECEIVED', status: 'RECEIVED',
arrivedAt: new Date(), arrivedAt: new Date(),
...(booking.tradeDirection === 'EXPORT' ...(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', 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 // 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 // gets one on unload — import as well as export. The direction only decides
// the GRN prefix, not whether one is issued. // the GRN prefix, not whether one is issued.
const [bookingRow]: Array<{ tradeDirection: string | null }> = await this.dataSource.query( // The GRN is mapped to the goods owner (the booking's company), so pull it
`SELECT trade_direction AS "tradeDirection" // alongside the direction rather than issuing an owner-less number.
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, const [bookingRow]: Array<{ tradeDirection: string | null; ownerName: string | null }> =
[bookingId], 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 grnDirection = bookingRow?.tradeDirection ?? 'WH';
const ownerName = bookingRow?.ownerName ?? null;
let location: DefaultLocation | null = let location: DefaultLocation | null =
dto.warehouseId && dto.yardId && dto.zoneId dto.warehouseId && dto.yardId && dto.zoneId
@@ -1252,7 +1263,7 @@ export class WarehouseInventoryService {
// Keep an already-issued GRN rather than reissuing; mint one otherwise. // Keep an already-issued GRN rather than reissuing; mint one otherwise.
...(existing[0].grnNumber ...(existing[0].grnNumber
? {} ? {}
: { grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt) }), : { grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt, ownerName) }),
notes: dto.notes ?? existing[0].notes ?? 'Unloaded', notes: dto.notes ?? existing[0].notes ?? 'Unloaded',
}); });
return this.findById(existing[0].id); return this.findById(existing[0].id);
@@ -1267,7 +1278,7 @@ export class WarehouseInventoryService {
weight: 0, weight: 0,
status: 'RECEIVED', status: 'RECEIVED',
arrivedAt, arrivedAt,
grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt), grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt, ownerName),
notes: dto.notes ?? 'Unloaded', notes: dto.notes ?? 'Unloaded',
}); });
return this.findById(saved.id); return this.findById(saved.id);
@@ -1577,7 +1588,7 @@ export class WarehouseInventoryService {
} }
const now = new Date(); 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 const truckEntrance = dto.truckEntrance
? this.mergeSystemTruckEntrance(dto.truckEntrance, booking) ? this.mergeSystemTruckEntrance(dto.truckEntrance, booking)
: undefined; : undefined;
@@ -2143,6 +2154,8 @@ export class WarehouseInventoryService {
const bookings: { const bookings: {
id: string; id: string;
status: string; status: string;
/** Goods owner (company) — the GRN number is mapped to it. */
customer: string | null;
weight: string | null; weight: string | null;
freightType: string | null; freightType: string | null;
tradeDirection: string | null; tradeDirection: string | null;
@@ -2165,10 +2178,12 @@ export class WarehouseInventoryService {
WHERE bc2.booking_id = b.id AND bcu.deleted_at IS NULL) WHERE bc2.booking_id = b.id AND bcu.deleted_at IS NULL)
) AS weight, ) AS weight,
b.freight_type AS "freightType", b.trade_direction AS "tradeDirection", 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 FROM freight.train_schedule_bookings tsb
JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL 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.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 WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
AND b.destination_yard_id = $2`, AND b.destination_yard_id = $2`,
[scheduleId, schedule.destinationStationId], [scheduleId, schedule.destinationStationId],
@@ -2235,7 +2250,7 @@ export class WarehouseInventoryService {
unloadedAt: now, unloadedAt: now,
arrivedAt: existing.arrivedAt ?? now, arrivedAt: existing.arrivedAt ?? now,
// Import GRN is issued automatically at train unload. // 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({ await this.activityLog.record({
activityType: 'INVENTORY_UNLOADED', activityType: 'INVENTORY_UNLOADED',
@@ -2285,7 +2300,7 @@ export class WarehouseInventoryService {
quantity: 1, quantity: 1,
weight: Number(booking.weight) || 0, weight: Number(booking.weight) || 0,
status: 'UNLOADED', status: 'UNLOADED',
grnNumber: this.generateGrnNumber('IMPORT', booking.id, now), grnNumber: this.generateGrnNumber('IMPORT', booking.id, now, booking.customer),
arrivedAt: now, arrivedAt: now,
unloadedAt: now, unloadedAt: now,
notes: allocated?.rule ? `Unloaded → ${allocated.path}` : 'Unloaded from arrived import train', 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); this.assertCapacity('Zone', zone, weight, volume, containerCount);
const now = new Date(); 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({ const receiveNote = this.buildReceiveNote({
grnNumber, grnNumber,
notes: dto.notes?.trim() || 'Single booking received', notes: dto.notes?.trim() || 'Single booking received',
@@ -5350,7 +5370,9 @@ export class WarehouseInventoryService {
}); });
const rows: Array<[string, unknown]> = [ const rows: Array<[string, unknown]> = [
['Booking Reference', data.bookingReference], ['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], ['Customer TIN', data.customerTin],
['Booking Status', data.bookingStatus], ['Booking Status', data.bookingStatus],
['Service Type', data.serviceType], ['Service Type', data.serviceType],
@@ -5981,8 +6003,13 @@ export class WarehouseInventoryService {
} }
/** Shared with the facility handling flow — see common/grn.util.ts. */ /** Shared with the facility handling flow — see common/grn.util.ts. */
private generateGrnNumber(direction: string, referenceId: string, date: Date): string { private generateGrnNumber(
return generateGrnNumber(direction, referenceId, date); direction: string,
referenceId: string,
date: Date,
ownerName?: string | null,
): string {
return generateGrnNumber(direction, referenceId, date, ownerName);
} }
private async generateReleaseReference(item: WarehouseInventory): Promise<string> { private async generateReleaseReference(item: WarehouseInventory): Promise<string> {

View File

@@ -1,8 +1,9 @@
import { BaseRepository } from '@edr/api-common'; import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; 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'; import { WarehouseYard } from './entities/warehouse-yard.entity';
@Injectable() @Injectable()
@@ -10,4 +11,20 @@ export class WarehouseYardsRepository extends BaseRepository<WarehouseYard> {
constructor(@InjectRepository(WarehouseYard) repository: Repository<WarehouseYard>) { constructor(@InjectRepository(WarehouseYard) repository: Repository<WarehouseYard>) {
super(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<WarehouseYard>): Promise<WarehouseYard | null> {
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);
}
} }

View File

@@ -1,5 +1,6 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; 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 { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto';
import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto'; import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto';
import { WarehouseYard } from './entities/warehouse-yard.entity'; import { WarehouseYard } from './entities/warehouse-yard.entity';
@@ -15,7 +16,7 @@ export class WarehouseYardsService {
findAll(): Promise<WarehouseYard[]> { findAll(): Promise<WarehouseYard[]> {
return this.yardsRepository.findAll({ return this.yardsRepository.findAll({
relations: { warehouse: true, zones: true }, relations: { warehouse: true, zones: true, cargoTypes: true },
order: { code: 'ASC' }, order: { code: 'ASC' },
}); });
} }
@@ -23,14 +24,14 @@ export class WarehouseYardsService {
findByWarehouse(warehouseId: string): Promise<WarehouseYard[]> { findByWarehouse(warehouseId: string): Promise<WarehouseYard[]> {
return this.yardsRepository.findAll({ return this.yardsRepository.findAll({
where: { warehouseId }, where: { warehouseId },
relations: { zones: true }, relations: { zones: true, cargoTypes: true },
order: { code: 'ASC' }, order: { code: 'ASC' },
}); });
} }
async findById(id: string): Promise<WarehouseYard> { async findById(id: string): Promise<WarehouseYard> {
const yard = await this.yardsRepository.findById(id, { const yard = await this.yardsRepository.findById(id, {
relations: { warehouse: true, zones: true }, relations: { warehouse: true, zones: true, cargoTypes: true },
}); });
if (!yard) { if (!yard) {
@@ -51,6 +52,7 @@ export class WarehouseYardsService {
name: dto.name.trim(), name: dto.name.trim(),
code: dto.code.trim(), code: dto.code.trim(),
type: dto.type, type: dto.type,
direction: dto.direction ?? null,
capacityWeight: dto.capacityWeight ?? null, capacityWeight: dto.capacityWeight ?? null,
capacityContainers: dto.capacityContainers ?? null, capacityContainers: dto.capacityContainers ?? null,
maxWeight: dto.maxWeight ?? dto.capacityWeight ?? null, maxWeight: dto.maxWeight ?? dto.capacityWeight ?? null,
@@ -60,6 +62,8 @@ export class WarehouseYardsService {
currentVolume: 0, currentVolume: 0,
status: 'ACTIVE', status: 'ACTIVE',
isActive: true, 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, name: dto.name?.trim() ?? existing.name,
code: dto.code?.trim() ?? existing.code, code: dto.code?.trim() ?? existing.code,
type: dto.type ?? existing.type, type: dto.type ?? existing.type,
direction: dto.direction ?? existing.direction,
capacityWeight: newCapacityWeight, capacityWeight: newCapacityWeight,
capacityContainers: newCapacityContainers, capacityContainers: newCapacityContainers,
maxWeight: dto.maxWeight ?? existing.maxWeight, maxWeight: dto.maxWeight ?? existing.maxWeight,
maxVolume: dto.maxVolume ?? existing.maxVolume, maxVolume: dto.maxVolume ?? existing.maxVolume,
status, status,
isActive: status === 'ACTIVE', isActive: status === 'ACTIVE',
...(dto.cargoTypeIds
? { cargoTypes: dto.cargoTypeIds.map((cargoTypeId) => ({ id: cargoTypeId }) as CargoType) }
: {}),
}); });
if (!updated) { if (!updated) {

View File

@@ -80,7 +80,7 @@ import { MoveInventoryModal } from './MoveInventoryModal';
import { ReleaseOrderModal } from './ReleaseOrderModal'; import { ReleaseOrderModal } from './ReleaseOrderModal';
import { StoreInventoryModal } from './StoreInventoryModal'; import { StoreInventoryModal } from './StoreInventoryModal';
import { WarehouseInquiryTable } from './WarehouseInquiryTable'; 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 { openPdfBlob } from './pdf';
import '@/components/overview/overview.css'; 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) => const isImportContainerFreight = (freightType: string | null | undefined) =>
(freightType ?? '').toUpperCase() === 'CONTAINER'; (freightType ?? '').toUpperCase() === 'CONTAINER';
@@ -2039,10 +2031,60 @@ function ImportTrainDetailTable({
enabled: Boolean(train.scheduleId), enabled: Boolean(train.scheduleId),
}), }),
); );
const warehouseOptions = useMemo( // A train only ever unloads at the warehouse actually sitting at its
() => warehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })), // destination station — Indode's train never offers Sebeta's warehouse.
[warehouses], 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(() => { useEffect(() => {
const pending = items.filter(isImportUnloadPending); const pending = items.filter(isImportUnloadPending);
@@ -2093,12 +2135,17 @@ function ImportTrainDetailTable({
<Table.Tbody> <Table.Tbody>
{items.map((it: ImportTrainItem) => { {items.map((it: ImportTrainItem) => {
const draft = assignments[it.bookingId] ?? {}; const draft = assignments[it.bookingId] ?? {};
const { yardTypes, zoneTypes } = importLocationTypesForFreight(it.freightType); const yardOptions = yardsForBooking(yards, {
const yardOptions = yards warehouseId: draft.warehouseId,
.filter((yard) => yard.warehouseId === draft.warehouseId && yardTypes.includes(yard.type)) freightType: it.freightType,
.map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` })); 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 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})` })); .map((zone) => ({ value: zone.id, label: `${zone.name} (${zone.code})` }));
const pending = isImportUnloadPending(it); const pending = isImportUnloadPending(it);

View File

@@ -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);
});
});

View File

@@ -4,6 +4,8 @@ import {
WAREHOUSE_ZONE_TYPES, WAREHOUSE_ZONE_TYPES,
WAREHOUSE_STATUSES, WAREHOUSE_STATUSES,
INVENTORY_STATUSES, INVENTORY_STATUSES,
type Warehouse,
type WarehouseYard,
} from '@/types/warehouse'; } from '@/types/warehouse';
export const humanizeEnum = (value: string) => export const humanizeEnum = (value: string) =>
@@ -16,6 +18,58 @@ export const humanizeEnum = (value: string) =>
const toOptions = (values: readonly string[]) => const toOptions = (values: readonly string[]) =>
values.map((value) => ({ value, label: humanizeEnum(value) })); 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 warehouseTypeOptions = toOptions(WAREHOUSE_TYPES);
export const yardTypeOptions = toOptions(WAREHOUSE_YARD_TYPES); export const yardTypeOptions = toOptions(WAREHOUSE_YARD_TYPES);
export const zoneTypeOptions = toOptions(WAREHOUSE_ZONE_TYPES); export const zoneTypeOptions = toOptions(WAREHOUSE_ZONE_TYPES);

View File

@@ -18,6 +18,8 @@ import {
WarehouseOpsKpiStrip, WarehouseOpsKpiStrip,
formatDate, formatDate,
formatNumber, formatNumber,
warehousesAtStation,
yardsForBooking,
} from '@/components/warehouses'; } from '@/components/warehouses';
import { import {
useAutoUnloadArrivedBookings, useAutoUnloadArrivedBookings,
@@ -49,14 +51,6 @@ const getPendingUnloadBookings = (train: ImportTrain) =>
const isFullyUnloaded = (train: ImportTrain) => const isFullyUnloaded = (train: ImportTrain) =>
Boolean(train.fullyUnloaded) || (train.totalBookings > 0 && getPendingUnloadBookings(train) === 0); 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) => const isContainerFreight = (freightType: string | null | undefined) =>
(freightType ?? '').toUpperCase() === 'CONTAINER'; (freightType ?? '').toUpperCase() === 'CONTAINER';
@@ -65,7 +59,7 @@ function isUnloadPending(item: ImportTrainItem) {
} }
function ImportTrainDetailRows({ function ImportTrainDetailRows({
scheduleId, train,
warehouses, warehouses,
yards, yards,
zones, zones,
@@ -73,7 +67,7 @@ function ImportTrainDetailRows({
onAssignmentChange, onAssignmentChange,
onReadyChange, onReadyChange,
}: { }: {
scheduleId: string; train: ImportTrain;
warehouses: Warehouse[]; warehouses: Warehouse[];
yards: WarehouseYard[]; yards: WarehouseYard[];
zones: WarehouseZone[]; zones: WarehouseZone[];
@@ -81,11 +75,61 @@ function ImportTrainDetailRows({
onAssignmentChange: (bookingId: string, draft: AssignmentDraft) => void; onAssignmentChange: (bookingId: string, draft: AssignmentDraft) => void;
onReadyChange: (ready: boolean) => void; onReadyChange: (ready: boolean) => void;
}) { }) {
const { data: items = [], isLoading } = useImportTrainItems(scheduleId); const { data: items = [], isLoading } = useImportTrainItems(train.scheduleId);
const warehouseOptions = useMemo( // A train only ever unloads at the warehouse actually sitting at its
() => warehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })), // destination station — Indode's train never offers Sebeta's warehouse.
[warehouses], 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(() => { useEffect(() => {
const pending = items.filter(isUnloadPending); const pending = items.filter(isUnloadPending);
@@ -135,12 +179,17 @@ function ImportTrainDetailRows({
<Table.Tbody> <Table.Tbody>
{items.map((item: ImportTrainItem) => { {items.map((item: ImportTrainItem) => {
const draft = assignments[item.bookingId] ?? {}; const draft = assignments[item.bookingId] ?? {};
const { yardTypes, zoneTypes } = locationTypesForFreight(item.freightType); const yardOptions = yardsForBooking(yards, {
const yardOptions = yards warehouseId: draft.warehouseId,
.filter((yard) => yard.warehouseId === draft.warehouseId && yardTypes.includes(yard.type)) freightType: item.freightType,
.map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` })); 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 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})` })); .map((zone) => ({ value: zone.id, label: `${zone.name} (${zone.code})` }));
const pending = isUnloadPending(item); const pending = isUnloadPending(item);
@@ -395,7 +444,7 @@ export default function ArrivalQueuePage() {
<Table.Tr> <Table.Tr>
<Table.Td colSpan={10} bg="var(--mantine-color-gray-0)"> <Table.Td colSpan={10} bg="var(--mantine-color-gray-0)">
<ImportTrainDetailRows <ImportTrainDetailRows
scheduleId={train.scheduleId} train={train}
warehouses={warehouses} warehouses={warehouses}
yards={yards} yards={yards}
zones={zones} zones={zones}

View File

@@ -118,12 +118,19 @@ export interface WarehouseZone {
isActive: boolean; isActive: boolean;
} }
/** IMPORT | EXPORT | BOTH | null. Only meaningful for CONTAINER_YARD — everything else takes cargo either way. */
export type WarehouseYardDirection = 'IMPORT' | 'EXPORT' | 'BOTH';
export interface WarehouseYard { export interface WarehouseYard {
id: string; id: string;
warehouseId: string; warehouseId: string;
name: string; name: string;
code: string; code: string;
type: WarehouseYardType; type: WarehouseYardType;
/** For CONTAINER_YARD: which direction this stack serves. BOTH/null on a container yard means "not a customer cargo yard" (service/equipment), not "any direction". */
direction?: WarehouseYardDirection | null;
/** Cargo types this yard accepts. Empty/absent = open to any cargo type of this yard's structural type. */
cargoTypes?: Array<{ id: string; code: string }>;
capacityWeight: number | null; capacityWeight: number | null;
capacityContainers: number | null; capacityContainers: number | null;
maxWeight: number | null; maxWeight: number | null;
@@ -518,6 +525,8 @@ export interface ImportTrain {
route: string | null; route: string | null;
origin: string | null; origin: string | null;
destination: 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; departureTime?: string | null;
arrivalTime: string | null; arrivalTime: string | null;
totalBookings: number; totalBookings: number;
@@ -632,6 +641,8 @@ export interface ImportTrainItem {
freightType: string | null; freightType: string | null;
containerNumber: string | null; containerNumber: string | null;
cargoType: 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; weight: number | null;
arrivalTime: string | null; arrivalTime: string | null;
currentStatus: string | null; currentStatus: string | null;