mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
40
apps/edr-freight-api/src/common/grn.util.spec.ts
Normal file
40
apps/edr-freight-api/src/common/grn.util.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { warehousesAtStation, yardsForBooking } from "./options";
|
||||||
|
import type { Warehouse, WarehouseYard } from "@/types/warehouse";
|
||||||
|
|
||||||
|
// Mirrors Indode's real 11-yard layout at a reduced scale, so these cases read
|
||||||
|
// against the actual booking-routing decisions staff rely on.
|
||||||
|
const yard = (overrides: Partial<WarehouseYard>): WarehouseYard =>
|
||||||
|
({
|
||||||
|
id: overrides.code,
|
||||||
|
warehouseId: "indode",
|
||||||
|
name: overrides.code,
|
||||||
|
code: overrides.code,
|
||||||
|
type: "GENERAL_CARGO_YARD",
|
||||||
|
capacityWeight: null,
|
||||||
|
capacityContainers: null,
|
||||||
|
maxWeight: null,
|
||||||
|
maxVolume: null,
|
||||||
|
currentWeight: 0,
|
||||||
|
currentContainers: 0,
|
||||||
|
currentVolume: 0,
|
||||||
|
status: "ACTIVE",
|
||||||
|
isActive: true,
|
||||||
|
...overrides,
|
||||||
|
}) as WarehouseYard;
|
||||||
|
|
||||||
|
const YARDS: WarehouseYard[] = [
|
||||||
|
yard({ code: "Y2", type: "GENERAL_CARGO_YARD", cargoTypes: [{ id: "1", code: "STEEL_BILLET" }] }),
|
||||||
|
yard({ code: "Y3", type: "GENERAL_CARGO_YARD", cargoTypes: [{ id: "2", code: "AUTOMOBILE" }, { id: "3", code: "TRUCK" }] }),
|
||||||
|
yard({ code: "Y4", type: "BULK_YARD", status: "INACTIVE", isActive: false, cargoTypes: [{ id: "4", code: "WHEAT" }] }),
|
||||||
|
yard({ code: "Y5", type: "CONTAINER_YARD", direction: "IMPORT" }),
|
||||||
|
yard({ code: "Y6", type: "CONTAINER_YARD", direction: "EXPORT" }),
|
||||||
|
yard({ code: "Y10", type: "CONTAINER_YARD", direction: "BOTH" }), // service yard
|
||||||
|
yard({ code: "Y11", type: "CONTAINER_YARD", direction: "BOTH" }), // equipment yard
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("yardsForBooking", () => {
|
||||||
|
it("container import narrows to exactly the import stack", () => {
|
||||||
|
const result = yardsForBooking(YARDS, {
|
||||||
|
warehouseId: "indode",
|
||||||
|
freightType: "CONTAINER",
|
||||||
|
tradeDirection: "IMPORT",
|
||||||
|
cargoTypeCode: null,
|
||||||
|
});
|
||||||
|
expect(result.map((y) => y.code)).toEqual(["Y5"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("container export narrows to exactly the export stack", () => {
|
||||||
|
const result = yardsForBooking(YARDS, {
|
||||||
|
warehouseId: "indode",
|
||||||
|
freightType: "CONTAINER",
|
||||||
|
tradeDirection: "EXPORT",
|
||||||
|
cargoTypeCode: null,
|
||||||
|
});
|
||||||
|
expect(result.map((y) => y.code)).toEqual(["Y6"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never offers a BOTH-direction container yard (service/equipment) for ordinary cargo", () => {
|
||||||
|
const result = yardsForBooking(YARDS, {
|
||||||
|
warehouseId: "indode",
|
||||||
|
freightType: "CONTAINER",
|
||||||
|
tradeDirection: "IMPORT",
|
||||||
|
cargoTypeCode: null,
|
||||||
|
});
|
||||||
|
expect(result.map((y) => y.code)).not.toContain("Y10");
|
||||||
|
expect(result.map((y) => y.code)).not.toContain("Y11");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bulk cargo narrows to the yard configured for that exact cargo type", () => {
|
||||||
|
const automobile = yardsForBooking(YARDS, {
|
||||||
|
warehouseId: "indode",
|
||||||
|
freightType: "BULK",
|
||||||
|
tradeDirection: "IMPORT",
|
||||||
|
cargoTypeCode: "AUTOMOBILE",
|
||||||
|
});
|
||||||
|
expect(automobile.map((y) => y.code)).toEqual(["Y3"]);
|
||||||
|
|
||||||
|
const steel = yardsForBooking(YARDS, {
|
||||||
|
warehouseId: "indode",
|
||||||
|
freightType: "BULK",
|
||||||
|
tradeDirection: "IMPORT",
|
||||||
|
cargoTypeCode: "STEEL_BILLET",
|
||||||
|
});
|
||||||
|
expect(steel.map((y) => y.code)).toEqual(["Y2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to every non-container yard when the one configured for this cargo type is closed", () => {
|
||||||
|
// Y4 (Dry Bulk, WHEAT) is inactive — never strand staff with an empty
|
||||||
|
// picker just because the ideal yard is closed; same safety net as
|
||||||
|
// warehousesAtStation falling back when a station has no mapped warehouse.
|
||||||
|
const result = yardsForBooking(YARDS, {
|
||||||
|
warehouseId: "indode",
|
||||||
|
freightType: "BULK",
|
||||||
|
tradeDirection: "IMPORT",
|
||||||
|
cargoTypeCode: "WHEAT",
|
||||||
|
});
|
||||||
|
expect(result.map((y) => y.code).sort()).toEqual(["Y2", "Y3"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to every non-container yard when no yard is configured for that cargo type yet", () => {
|
||||||
|
const result = yardsForBooking(YARDS, {
|
||||||
|
warehouseId: "indode",
|
||||||
|
freightType: "BULK",
|
||||||
|
tradeDirection: "IMPORT",
|
||||||
|
cargoTypeCode: "SOMETHING_UNMAPPED",
|
||||||
|
});
|
||||||
|
expect(result.map((y) => y.code).sort()).toEqual(["Y2", "Y3"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a yard with no configured cargo types is open to anything (unconfigured, not restrictive)", () => {
|
||||||
|
const openYard = yard({ code: "GENERIC", type: "BULK_YARD" });
|
||||||
|
const result = yardsForBooking([...YARDS, openYard], {
|
||||||
|
warehouseId: "indode",
|
||||||
|
freightType: "BULK",
|
||||||
|
tradeDirection: "IMPORT",
|
||||||
|
cargoTypeCode: "STEEL_BILLET",
|
||||||
|
});
|
||||||
|
expect(result.map((y) => y.code).sort()).toEqual(["GENERIC", "Y2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only offers yards at the requested warehouse", () => {
|
||||||
|
const otherWarehouseYard = yard({ code: "SEBETA-Y1", warehouseId: "sebeta", type: "GENERAL_CARGO_YARD" });
|
||||||
|
const result = yardsForBooking([...YARDS, otherWarehouseYard], {
|
||||||
|
warehouseId: "indode",
|
||||||
|
freightType: "BULK",
|
||||||
|
tradeDirection: "IMPORT",
|
||||||
|
cargoTypeCode: null,
|
||||||
|
});
|
||||||
|
expect(result.map((y) => y.code)).not.toContain("SEBETA-Y1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("warehousesAtStation", () => {
|
||||||
|
const warehouse = (id: string, stationId: string | null): Warehouse =>
|
||||||
|
({ id, stationId, name: id, code: id } as Warehouse);
|
||||||
|
|
||||||
|
it("restricts to the warehouse at the given station", () => {
|
||||||
|
const warehouses = [warehouse("indode", "station-a"), warehouse("sebeta", "station-b")];
|
||||||
|
const result = warehousesAtStation(warehouses, "station-a");
|
||||||
|
expect(result.map((w) => w.id)).toEqual(["indode"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to every warehouse when the station has no match", () => {
|
||||||
|
const warehouses = [warehouse("indode", "station-a"), warehouse("sebeta", "station-b")];
|
||||||
|
const result = warehousesAtStation(warehouses, "station-unknown");
|
||||||
|
expect(result).toEqual(warehouses);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to every warehouse when the station is null", () => {
|
||||||
|
const warehouses = [warehouse("indode", "station-a")];
|
||||||
|
expect(warehousesAtStation(warehouses, null)).toEqual(warehouses);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
WAREHOUSE_ZONE_TYPES,
|
WAREHOUSE_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);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user