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
* 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;
}

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;
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

View File

@@ -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[];
}

View File

@@ -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[];
}

View File

@@ -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",

View File

@@ -1141,6 +1141,8 @@ export class WarehouseInventoryService {
async autoUnloadArrived(): Promise<AutoUnloadResult> {
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<string> {

View File

@@ -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<WarehouseYard> {
constructor(@InjectRepository(WarehouseYard) repository: Repository<WarehouseYard>) {
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 { 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<WarehouseYard[]> {
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<WarehouseYard[]> {
return this.yardsRepository.findAll({
where: { warehouseId },
relations: { zones: true },
relations: { zones: true, cargoTypes: true },
order: { code: 'ASC' },
});
}
async findById(id: string): Promise<WarehouseYard> {
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) {

View File

@@ -80,7 +80,7 @@ import { MoveInventoryModal } from './MoveInventoryModal';
import { ReleaseOrderModal } from './ReleaseOrderModal';
import { StoreInventoryModal } from './StoreInventoryModal';
import { WarehouseInquiryTable } from './WarehouseInquiryTable';
import { extractDownloadErrorMessage, extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options';
import { extractDownloadErrorMessage, extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions, warehousesAtStation, yardsForBooking } from './options';
import { openPdfBlob } from './pdf';
import '@/components/overview/overview.css';
@@ -1978,14 +1978,6 @@ function LoadedExportTab({
);
}
const importLocationTypesForFreight = (freightType: string | null | undefined) => {
const normalized = (freightType ?? '').toUpperCase();
if (normalized === 'CONTAINER') {
return { yardTypes: ['CONTAINER_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['CONTAINER_ZONE', 'GENERAL_CARGO_ZONE'] };
}
return { yardTypes: ['BULK_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['BULK_ZONE', 'GENERAL_CARGO_ZONE'] };
};
const isImportContainerFreight = (freightType: string | null | undefined) =>
(freightType ?? '').toUpperCase() === 'CONTAINER';
@@ -2039,10 +2031,60 @@ function ImportTrainDetailTable({
enabled: Boolean(train.scheduleId),
}),
);
const warehouseOptions = useMemo(
() => warehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })),
[warehouses],
// A train only ever unloads at the warehouse actually sitting at its
// destination station — Indode's train never offers Sebeta's warehouse.
const scopedWarehouses = useMemo(
() => warehousesAtStation(warehouses, train.destinationStationId),
[warehouses, train.destinationStationId],
);
const warehouseOptions = useMemo(
() => scopedWarehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })),
[scopedWarehouses],
);
// With exactly one warehouse at the station there is nothing to choose —
// pre-fill it so staff only has to pick yard/zone, not re-discover Indode.
useEffect(() => {
if (scopedWarehouses.length !== 1) return;
const onlyWarehouseId = scopedWarehouses[0].id;
items.filter(isImportUnloadPending).forEach((item) => {
if (!assignments[item.bookingId]?.warehouseId) {
onAssignmentChange(item.bookingId, { ...assignments[item.bookingId], warehouseId: onlyWarehouseId });
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scopedWarehouses, items]);
// Once a booking's warehouse is known, its yard (and then zone) follow from
// what the cargo actually is — a Wheat booking only ever has one candidate
// yard (Dry Bulk) once Indode's real yard layout is configured, so staff
// never see a picker for something that isn't actually a choice.
useEffect(() => {
items.filter(isImportUnloadPending).forEach((item) => {
const draft = assignments[item.bookingId];
if (!draft?.warehouseId) return;
if (!draft.yardId) {
const candidateYards = yardsForBooking(yards, {
warehouseId: draft.warehouseId,
freightType: item.freightType,
tradeDirection: 'IMPORT',
cargoTypeCode: item.cargoTypeCode,
});
if (candidateYards.length === 1) {
onAssignmentChange(item.bookingId, { ...draft, yardId: candidateYards[0].id });
}
return;
}
if (!draft.zoneId) {
const candidateZones = zones.filter((zone) => zone.yardId === draft.yardId);
if (candidateZones.length === 1) {
onAssignmentChange(item.bookingId, { ...draft, zoneId: candidateZones[0].id });
}
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [assignments, items, yards, zones]);
useEffect(() => {
const pending = items.filter(isImportUnloadPending);
@@ -2093,12 +2135,17 @@ function ImportTrainDetailTable({
<Table.Tbody>
{items.map((it: ImportTrainItem) => {
const draft = assignments[it.bookingId] ?? {};
const { yardTypes, zoneTypes } = importLocationTypesForFreight(it.freightType);
const yardOptions = yards
.filter((yard) => yard.warehouseId === draft.warehouseId && yardTypes.includes(yard.type))
.map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` }));
const yardOptions = yardsForBooking(yards, {
warehouseId: draft.warehouseId,
freightType: it.freightType,
tradeDirection: 'IMPORT',
cargoTypeCode: it.cargoTypeCode,
}).map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` }));
// The yard is already scoped to what this cargo can go into — a
// zone's own type always matches its parent yard's purpose (see the
// Indode seed migration), so no separate zone-type filter is needed.
const zoneOptions = zones
.filter((zone) => zone.yardId === draft.yardId && zoneTypes.includes(zone.type))
.filter((zone) => zone.yardId === draft.yardId)
.map((zone) => ({ value: zone.id, label: `${zone.name} (${zone.code})` }));
const pending = isImportUnloadPending(it);

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_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);

View File

@@ -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({
<Table.Tbody>
{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() {
<Table.Tr>
<Table.Td colSpan={10} bg="var(--mantine-color-gray-0)">
<ImportTrainDetailRows
scheduleId={train.scheduleId}
train={train}
warehouses={warehouses}
yards={yards}
zones={zones}

View File

@@ -118,12 +118,19 @@ export interface WarehouseZone {
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 {
id: string;
warehouseId: string;
name: string;
code: string;
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;
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;