mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 15:25:45 +00:00
@@ -17,9 +17,9 @@ export default registerAs("app", () => ({
|
|||||||
portalBaseUrl: (
|
portalBaseUrl: (
|
||||||
process.env.FREIGHT_PORTAL_URL ?? "http://localhost:5173"
|
process.env.FREIGHT_PORTAL_URL ?? "http://localhost:5173"
|
||||||
).replace(/\/+$/, ""),
|
).replace(/\/+$/, ""),
|
||||||
|
// Train weight/length are not env-configured: they come from locomotive
|
||||||
|
// configuration (see TrainSchedulingService.resolveTrainLimitConfig).
|
||||||
trainScheduling: {
|
trainScheduling: {
|
||||||
maxTrainWeightTons: numberFromEnv("TRAIN_SCHEDULING_MAX_WEIGHT_TONS", 3500),
|
|
||||||
maxTrainLengthMeters: numberFromEnv("TRAIN_SCHEDULING_MAX_LENGTH_METERS", 760),
|
|
||||||
maxWagonsPerTrain: numberFromEnv("TRAIN_SCHEDULING_MAX_WAGONS_PER_TRAIN", 53),
|
maxWagonsPerTrain: numberFromEnv("TRAIN_SCHEDULING_MAX_WAGONS_PER_TRAIN", 53),
|
||||||
},
|
},
|
||||||
// Consumed by @edr/api-common ExchangeModule.forRootAsync (see bookings.module.ts).
|
// Consumed by @edr/api-common ExchangeModule.forRootAsync (see bookings.module.ts).
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
import { RatesService } from '../modules/rule-engine/services/rates.service';
|
import { RatesService } from '../modules/rule-engine/services/rates.service';
|
||||||
import { Rate } from '../modules/rule-engine/entities/rate.entity';
|
import { Rate, isContainerHazardRate } from '../modules/rule-engine/entities/rate.entity';
|
||||||
import {
|
import {
|
||||||
ContractDirection,
|
ContractDirection,
|
||||||
ContractFreight,
|
ContractFreight,
|
||||||
@@ -109,12 +109,21 @@ export class ContractRateScheduleBuilder {
|
|||||||
// Fuel is sold per lane + commodity — only lanes matching the contract's
|
// Fuel is sold per lane + commodity — only lanes matching the contract's
|
||||||
// direction belong on its schedule, labeled with their leg.
|
// direction belong on its schedule, labeled with their leg.
|
||||||
if (rate.trigger === 'FUEL') {
|
if (rate.trigger === 'FUEL') {
|
||||||
if (this.fuelDirectionMatches(rate, direction)) {
|
if (this.laneDirectionMatches(rate, direction)) {
|
||||||
surcharges.push(this.fuelRow(rate));
|
surcharges.push(this.fuelRow(rate));
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The container hazard surcharge is sold per lane (+ box size) — only a
|
||||||
|
// container contract on a matching direction shows it, with its leg.
|
||||||
|
if (isContainerHazardRate(rate.trigger, rate.rateUnit)) {
|
||||||
|
if (freight === 'CON' && this.laneDirectionMatches(rate, direction)) {
|
||||||
|
surcharges.push(this.lanedSurchargeRow(rate));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Everything left is a trigger-based charge (surcharge / demurrage / customs).
|
// Everything left is a trigger-based charge (surcharge / demurrage / customs).
|
||||||
surcharges.push(this.surchargeRow(rate));
|
surcharges.push(this.surchargeRow(rate));
|
||||||
}
|
}
|
||||||
@@ -203,12 +212,28 @@ export class ContractRateScheduleBuilder {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private fuelDirectionMatches(rate: Rate, direction: ContractDirection): boolean {
|
/** Lane-sold surcharges (fuel, container hazard) match on the contract's direction. */
|
||||||
|
private laneDirectionMatches(rate: Rate, direction: ContractDirection): boolean {
|
||||||
const want =
|
const want =
|
||||||
direction === 'IMP' ? 'IMPORT' : direction === 'EXP' ? 'EXPORT' : 'DOMESTIC';
|
direction === 'IMP' ? 'IMPORT' : direction === 'EXP' ? 'EXPORT' : 'DOMESTIC';
|
||||||
return rate.tradeDirection === want;
|
return rate.tradeDirection === want;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A lane-sold surcharge row — the leg rides along in the charge label. */
|
||||||
|
private lanedSurchargeRow(rate: Rate): RateScheduleRow {
|
||||||
|
const origin = rate.originYard?.label ?? rate.originYard?.code ?? '—';
|
||||||
|
const destination =
|
||||||
|
rate.destinationYard?.label ?? rate.destinationYard?.code ?? '—';
|
||||||
|
const label = TRIGGER_ROUTE_LABELS[rate.trigger] ?? this.titleCase(rate.trigger);
|
||||||
|
return {
|
||||||
|
route: `${label} (${origin} → ${destination})`,
|
||||||
|
cargo: this.cargoLabel(rate),
|
||||||
|
currency: rate.currency,
|
||||||
|
amount: this.formatAmount(rate.rateValue),
|
||||||
|
unit: this.unitLabel(rate.rateUnit),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fuel row — the lane matters, so it rides along in the charge label.
|
* Fuel row — the lane matters, so it rides along in the charge label.
|
||||||
* Per-liter collapses to one flat total (base liters × rate value); the
|
* Per-liter collapses to one flat total (base liters × rate value); the
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crew assigned to a train schedule (ITLMS Rolling Stock §1.2, §2).
|
||||||
|
*
|
||||||
|
* `segment` and `duty_role` are per-assignment, not per-roster-member: Case 1
|
||||||
|
* splits four drivers across the Dire Dawa boundary, and a driver who is
|
||||||
|
* Primary on one run is Assistant on the next. `role` is snapshotted so a later
|
||||||
|
* roster edit cannot rewrite the crew of a run that already departed.
|
||||||
|
*
|
||||||
|
* The partial unique index on (schedule, segment, duty_role) applies to drivers
|
||||||
|
* only — one segment cannot have two Primaries, while four federal police on
|
||||||
|
* the same run carry no segment or duty role and are unconstrained by it.
|
||||||
|
*/
|
||||||
|
export class TrainCrewAssignments3860000000000 implements MigrationInterface {
|
||||||
|
name = 'TrainCrewAssignments3860000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.train_crew_assignments (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
train_schedule_id uuid NOT NULL,
|
||||||
|
crew_member_id uuid NOT NULL
|
||||||
|
REFERENCES freight.train_crew_members(id) ON DELETE RESTRICT,
|
||||||
|
role varchar(32) NOT NULL,
|
||||||
|
duty_role varchar(16),
|
||||||
|
segment varchar(24),
|
||||||
|
crewing_case varchar(8),
|
||||||
|
layover_start_at timestamptz,
|
||||||
|
layover_end_at timestamptz,
|
||||||
|
duty_start_at timestamptz,
|
||||||
|
duty_end_at timestamptz,
|
||||||
|
status varchar(16) NOT NULL DEFAULT 'PLANNED',
|
||||||
|
notes text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz,
|
||||||
|
CONSTRAINT chk_crew_assignment_duty_role CHECK (
|
||||||
|
duty_role IS NULL OR duty_role IN ('PRIMARY','ASSISTANT','BENCH_RELIEF')
|
||||||
|
),
|
||||||
|
CONSTRAINT chk_crew_assignment_segment CHECK (
|
||||||
|
segment IS NULL OR segment IN
|
||||||
|
('INDODE_DIRE_DAWA','DIRE_DAWA_NAGAD','FULL_CORRIDOR')
|
||||||
|
),
|
||||||
|
CONSTRAINT chk_crew_assignment_case CHECK (
|
||||||
|
crewing_case IS NULL OR crewing_case IN ('CASE_1','CASE_2')
|
||||||
|
),
|
||||||
|
CONSTRAINT chk_crew_assignment_status CHECK (
|
||||||
|
status IN ('PLANNED','CONFIRMED','COMPLETED','REMOVED')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_crew_assignments_schedule
|
||||||
|
ON freight.train_crew_assignments (train_schedule_id)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_crew_assignments_member
|
||||||
|
ON freight.train_crew_assignments (crew_member_id)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_crew_assignments_status
|
||||||
|
ON freight.train_crew_assignments (status)
|
||||||
|
`);
|
||||||
|
// Nobody holds two seats on one run.
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_crew_assignments_schedule_member
|
||||||
|
ON freight.train_crew_assignments (train_schedule_id, crew_member_id)
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
`);
|
||||||
|
// One Primary (and one Assistant) per segment — drivers only.
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_crew_assignments_driver_slot
|
||||||
|
ON freight.train_crew_assignments (train_schedule_id, segment, duty_role)
|
||||||
|
WHERE deleted_at IS NULL AND segment IS NOT NULL AND duty_role IS NOT NULL
|
||||||
|
`);
|
||||||
|
// Monthly overtime rollups scan a member's duty spans (§3.1).
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_crew_assignments_duty_window
|
||||||
|
ON freight.train_crew_assignments (crew_member_id, duty_start_at)
|
||||||
|
WHERE duty_start_at IS NOT NULL
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_crew_assignments`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop `crewing_case` from train crew assignments.
|
||||||
|
*
|
||||||
|
* The column encoded the two fixed driver pairing cases of ITLMS Rolling Stock
|
||||||
|
* §2 (2+2 Ethiopian/Djiboutian, or 3 Ethiopian). Operations crew each run to
|
||||||
|
* its own need instead — any number of drivers, each carrying their own segment
|
||||||
|
* and duty role — so the case has nothing left to select and the column no
|
||||||
|
* longer has a meaning. The §1.1 territorial boundary is unaffected: it is a
|
||||||
|
* per-driver rule and still enforced.
|
||||||
|
*/
|
||||||
|
export class DropCrewingCase3870000000000 implements MigrationInterface {
|
||||||
|
name = 'DropCrewingCase3870000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.train_crew_assignments
|
||||||
|
DROP CONSTRAINT IF EXISTS chk_crew_assignment_case
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.train_crew_assignments
|
||||||
|
DROP COLUMN IF EXISTS crewing_case
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.train_crew_assignments
|
||||||
|
ADD COLUMN IF NOT EXISTS crewing_case varchar(8)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.train_crew_assignments
|
||||||
|
ADD CONSTRAINT chk_crew_assignment_case CHECK (
|
||||||
|
crewing_case IS NULL OR crewing_case IN ('CASE_1','CASE_2')
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Driver legs become yard-to-yard instead of three fixed corridor segments.
|
||||||
|
*
|
||||||
|
* The old `segment` enum could only express Indode–Dire Dawa, Dire Dawa–Nagad
|
||||||
|
* or the full corridor. Operations hand over at other yards too (Feto, Meiso,
|
||||||
|
* Sebet/Sibra — the very points ITLMS Rolling Stock §2 names as rotation
|
||||||
|
* places), so a leg is now any two yards on the schedule's route.
|
||||||
|
*
|
||||||
|
* `segment` is kept, nullable, so rows written before this still read back; it
|
||||||
|
* is never populated again. The §1.1 territorial boundary is unaffected — it
|
||||||
|
* now derives from yard position rather than the segment name, so a Djibouti
|
||||||
|
* driver is still confined to Dire Dawa and eastward.
|
||||||
|
*
|
||||||
|
* The driver-slot unique index moves with it: one Primary per leg, where a leg
|
||||||
|
* is the (from, to) pair rather than a segment name.
|
||||||
|
*/
|
||||||
|
export class CrewLegYards3880000000000 implements MigrationInterface {
|
||||||
|
name = 'CrewLegYards3880000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.train_crew_assignments
|
||||||
|
ADD COLUMN IF NOT EXISTS from_yard_id uuid REFERENCES freight.yards(id),
|
||||||
|
ADD COLUMN IF NOT EXISTS to_yard_id uuid REFERENCES freight.yards(id)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Backfill the three legacy segments onto real yards so historic rows keep
|
||||||
|
// a usable leg. Matched by code; a deployment missing one simply leaves
|
||||||
|
// those rows with a null leg, which the validator reports as incomplete.
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.train_crew_assignments a
|
||||||
|
SET from_yard_id = f.id, to_yard_id = t.id
|
||||||
|
FROM freight.yards f, freight.yards t
|
||||||
|
WHERE a.segment = 'INDODE_DIRE_DAWA'
|
||||||
|
AND a.from_yard_id IS NULL
|
||||||
|
AND f.code = 'KALITY' AND t.code = 'DIRE_DAWA'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.train_crew_assignments a
|
||||||
|
SET from_yard_id = f.id, to_yard_id = t.id
|
||||||
|
FROM freight.yards f, freight.yards t
|
||||||
|
WHERE a.segment = 'DIRE_DAWA_NAGAD'
|
||||||
|
AND a.from_yard_id IS NULL
|
||||||
|
AND f.code = 'DIRE_DAWA' AND t.code = 'NAGAD'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.train_crew_assignments a
|
||||||
|
SET from_yard_id = f.id, to_yard_id = t.id
|
||||||
|
FROM freight.yards f, freight.yards t
|
||||||
|
WHERE a.segment = 'FULL_CORRIDOR'
|
||||||
|
AND a.from_yard_id IS NULL
|
||||||
|
AND f.code = 'KALITY' AND t.code = 'NAGAD'
|
||||||
|
`);
|
||||||
|
|
||||||
|
// One Primary per leg replaces one Primary per segment.
|
||||||
|
await queryRunner.query(`
|
||||||
|
DROP INDEX IF EXISTS freight.uq_crew_assignments_driver_slot
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_crew_assignments_driver_leg
|
||||||
|
ON freight.train_crew_assignments
|
||||||
|
(train_schedule_id, from_yard_id, to_yard_id, duty_role)
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND from_yard_id IS NOT NULL
|
||||||
|
AND to_yard_id IS NOT NULL
|
||||||
|
AND duty_role IS NOT NULL
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_crew_assignments_leg
|
||||||
|
ON freight.train_crew_assignments (from_yard_id, to_yard_id)
|
||||||
|
WHERE from_yard_id IS NOT NULL
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_crew_assignments_leg`);
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight.uq_crew_assignments_driver_leg`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_crew_assignments_driver_slot
|
||||||
|
ON freight.train_crew_assignments (train_schedule_id, segment, duty_role)
|
||||||
|
WHERE deleted_at IS NULL AND segment IS NOT NULL AND duty_role IS NOT NULL
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.train_crew_assignments
|
||||||
|
DROP COLUMN IF EXISTS from_yard_id,
|
||||||
|
DROP COLUMN IF EXISTS to_yard_id
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the currency-level DJF switch to `manual_payment_settings`.
|
||||||
|
*
|
||||||
|
* Distinct from `djf_enabled`, which governs only the MANUAL rail: this one
|
||||||
|
* says whether DJF may be used as a payment currency at all — offered on the
|
||||||
|
* booking forms and accepted for online payment. Defaults to `true`, the
|
||||||
|
* behaviour before the switch existed.
|
||||||
|
*/
|
||||||
|
export class AddDjfPaymentsEnabled3890000000000 implements MigrationInterface {
|
||||||
|
name = 'AddDjfPaymentsEnabled3890000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.manual_payment_settings
|
||||||
|
ADD COLUMN IF NOT EXISTS djf_payments_enabled boolean NOT NULL DEFAULT true;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.manual_payment_settings DROP COLUMN IF EXISTS djf_payments_enabled;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer-requested validity extension of an EXPIRED contract.
|
||||||
|
*
|
||||||
|
* Flow: the customer asks from the portal (`extension_requested_at` is stamped,
|
||||||
|
* the reason lands in contract_review_notes as EXTENSION_REQUESTED), then staff
|
||||||
|
* add days on the backoffice detail page and the contract returns to the status
|
||||||
|
* it held before it lapsed. Both expiry paths (nightly sweep + lazy flip on
|
||||||
|
* read) now stash that status in `status_before_expiry`, mirroring
|
||||||
|
* `status_before_suspension`; rows expired before this column existed fall
|
||||||
|
* back to the kind's resting status on extension.
|
||||||
|
*
|
||||||
|
* Also seeds `edr_freight_app:contracts:extend`. `FreightPositionsSeeder`
|
||||||
|
* resolves every registry key against `iam.permissions` at boot and throws on
|
||||||
|
* a missing row, so the catalog row must exist wherever the registry ships.
|
||||||
|
* The grant is copied from whoever already holds `contracts:suspend` — the
|
||||||
|
* registry places both keys on the same desk (marketing), and the position
|
||||||
|
* seeder only re-syncs presets when SEED_EDR_ORG is set.
|
||||||
|
*/
|
||||||
|
export class ContractExtensionRequest3920000000000 implements MigrationInterface {
|
||||||
|
name = 'ContractExtensionRequest3920000000000';
|
||||||
|
|
||||||
|
private static readonly KEY = 'edr_freight_app:contracts:extend';
|
||||||
|
private static readonly ID = 'a3000001-0001-4000-8000-00000000001d';
|
||||||
|
private static readonly SIBLING_KEY = 'edr_freight_app:contracts:suspend';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.contracts
|
||||||
|
ADD COLUMN IF NOT EXISTS extension_requested_at timestamptz NULL
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.contracts
|
||||||
|
ADD COLUMN IF NOT EXISTS status_before_expiry varchar(40) NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO iam.permissions (id, key, name, application_id)
|
||||||
|
SELECT $2::uuid,
|
||||||
|
$1::varchar,
|
||||||
|
'{"am": "Extend an expired contract", "en": "Extend an expired contract"}'::jsonb,
|
||||||
|
a.id
|
||||||
|
FROM iam.application a
|
||||||
|
WHERE a.key = 'edr_freight_app'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM iam.permissions p WHERE p.key = $1::varchar)`,
|
||||||
|
[ContractExtensionRequest3920000000000.KEY, ContractExtensionRequest3920000000000.ID],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Grant wherever suspend is already granted (positions and roles alike).
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO iam.position_permissions (position_id, permission_id)
|
||||||
|
SELECT pp.position_id, np.id
|
||||||
|
FROM iam.position_permissions pp
|
||||||
|
JOIN iam.permissions sp ON sp.id = pp.permission_id AND sp.key = $2::varchar
|
||||||
|
JOIN iam.permissions np ON np.key = $1::varchar
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM iam.position_permissions x
|
||||||
|
WHERE x.position_id = pp.position_id AND x.permission_id = np.id
|
||||||
|
)`,
|
||||||
|
[ContractExtensionRequest3920000000000.KEY, ContractExtensionRequest3920000000000.SIBLING_KEY],
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO iam.role_permissions (role_id, permission_id)
|
||||||
|
SELECT rp.role_id, np.id
|
||||||
|
FROM iam.role_permissions rp
|
||||||
|
JOIN iam.permissions sp ON sp.id = rp.permission_id AND sp.key = $2::varchar
|
||||||
|
JOIN iam.permissions np ON np.key = $1::varchar
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM iam.role_permissions x
|
||||||
|
WHERE x.role_id = rp.role_id AND x.permission_id = np.id
|
||||||
|
)`,
|
||||||
|
[ContractExtensionRequest3920000000000.KEY, ContractExtensionRequest3920000000000.SIBLING_KEY],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Grants go first, or the delete trips the permission foreign keys. */
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`DELETE FROM iam.position_permissions
|
||||||
|
WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`,
|
||||||
|
[ContractExtensionRequest3920000000000.KEY],
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`DELETE FROM iam.role_permissions
|
||||||
|
WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`,
|
||||||
|
[ContractExtensionRequest3920000000000.KEY],
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DELETE FROM iam.permissions WHERE key = $1`, [
|
||||||
|
ContractExtensionRequest3920000000000.KEY,
|
||||||
|
]);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.contracts DROP COLUMN IF EXISTS status_before_expiry`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.contracts DROP COLUMN IF EXISTS extension_requested_at`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Links a freight-forwarder company to the transit agent it is registered as.
|
||||||
|
*
|
||||||
|
* An Ethiopian transit agent and a freight forwarder are the same business seen
|
||||||
|
* from two sides: the roster GL uses to assign an officer, and the customer
|
||||||
|
* that signs contracts on other companies' behalf. Until now nothing tied the
|
||||||
|
* two rows together, so a forwarder could onboard under any name and staff had
|
||||||
|
* no way to tell which roster entry it was.
|
||||||
|
*
|
||||||
|
* Two changes:
|
||||||
|
* - `transit_agents.country` — the roster was Djibouti-only, so every existing
|
||||||
|
* row defaults to `DJ`. Only `ET` agents are offered to a forwarder onboarding.
|
||||||
|
* - `companies.transit_agent_id` — nullable: importers and exporters have no
|
||||||
|
* agent, and pre-existing forwarders stay unlinked until they are edited.
|
||||||
|
*/
|
||||||
|
export class CompanyTransitAgentLink3930000000000 implements MigrationInterface {
|
||||||
|
name = 'CompanyTransitAgentLink3930000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.transit_agents
|
||||||
|
ADD COLUMN IF NOT EXISTS country varchar(2) NOT NULL DEFAULT 'DJ'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS "IDX_transit_agents_country"
|
||||||
|
ON freight.transit_agents (country)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.companies
|
||||||
|
ADD COLUMN IF NOT EXISTS transit_agent_id uuid
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS "IDX_companies_transit_agent_id"
|
||||||
|
ON freight.companies (transit_agent_id)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'FK_companies_transit_agent'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE freight.companies
|
||||||
|
ADD CONSTRAINT "FK_companies_transit_agent"
|
||||||
|
FOREIGN KEY (transit_agent_id)
|
||||||
|
REFERENCES freight.transit_agents (id)
|
||||||
|
ON DELETE SET NULL;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.companies
|
||||||
|
DROP CONSTRAINT IF EXISTS "FK_companies_transit_agent"
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
DROP INDEX IF EXISTS freight."IDX_companies_transit_agent_id"
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.companies
|
||||||
|
DROP COLUMN IF EXISTS transit_agent_id
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
DROP INDEX IF EXISTS freight."IDX_transit_agents_country"
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.transit_agents
|
||||||
|
DROP COLUMN IF EXISTS country
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops the transit-agent validity window.
|
||||||
|
*
|
||||||
|
* `valid_from` / `valid_to` gated which officers GL could assign, on top of the
|
||||||
|
* `is_active` switch. In practice the dates were never maintained — an agent
|
||||||
|
* whose window lapsed was simply suspended — and with Ethiopian agents now
|
||||||
|
* doubling as the roster a freight forwarder registers itself against, a
|
||||||
|
* date range that silently hides a live business is worse than no range.
|
||||||
|
* `is_active` is the single switch from here on.
|
||||||
|
*
|
||||||
|
* `down()` re-adds the columns as nullable: the dates themselves are gone.
|
||||||
|
*/
|
||||||
|
export class DropTransitAgentValidity3940000000000 implements MigrationInterface {
|
||||||
|
name = 'DropTransitAgentValidity3940000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.transit_agents
|
||||||
|
DROP COLUMN IF EXISTS valid_from,
|
||||||
|
DROP COLUMN IF EXISTS valid_to
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.transit_agents
|
||||||
|
ADD COLUMN IF NOT EXISTS valid_from date,
|
||||||
|
ADD COLUMN IF NOT EXISTS valid_to date
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reference sequence for the new `transit_agent` operational profile.
|
||||||
|
*
|
||||||
|
* `company_profiles.type` is a plain varchar, so the role itself needs no DDL;
|
||||||
|
* what it needs is its own reference series (`TA-A00001`, …), minted on
|
||||||
|
* approval exactly like IM/EX/FF. Mirrors the baseline's sequences.
|
||||||
|
*/
|
||||||
|
export class TransitAgentProfileSequence3950000000000 implements MigrationInterface {
|
||||||
|
name = "TransitAgentProfileSequence3950000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_ta
|
||||||
|
START WITH 1
|
||||||
|
INCREMENT BY 1
|
||||||
|
NO MINVALUE
|
||||||
|
NO MAXVALUE
|
||||||
|
CACHE 1
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP SEQUENCE IF EXISTS freight.seq_company_profile_ta`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The container hazardous-cargo surcharge (HAZARDOUS billed PER_CONTAINER) is
|
||||||
|
* now sold per trade direction + origin → destination lane, optionally per
|
||||||
|
* container type (20ft / 40ft) — the same shape as the empty-return service.
|
||||||
|
* The per-ton (bulk) hazard rate keeps its global, unscoped shape.
|
||||||
|
*
|
||||||
|
* - CK_rates_yard_scope gains the per-container hazard rate in its
|
||||||
|
* yard-carrying branch. Drop-and-recreate is the established shape for this
|
||||||
|
* constraint — see 3890000000000-EmptyContainerRateScope.
|
||||||
|
* - Existing lane-less per-container hazard rows cannot satisfy the new
|
||||||
|
* branch and no longer match the way the engine prices container hazard
|
||||||
|
* (per lane + size), so they are SUPERSEDED — kept for the audit trail, out
|
||||||
|
* of the unique pattern index and out of pricing. The rates team re-enters
|
||||||
|
* the surcharge per lane; until then a hazardous container booking on that
|
||||||
|
* lane hard-blocks rather than shipping the service for free.
|
||||||
|
*/
|
||||||
|
export class ContainerHazardRateScope3960000000000 implements MigrationInterface {
|
||||||
|
name = "ContainerHazardRateScope3960000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.rates
|
||||||
|
SET status = 'SUPERSEDED'
|
||||||
|
WHERE trigger = 'HAZARDOUS'
|
||||||
|
AND rate_unit = 'PER_CONTAINER'
|
||||||
|
AND (origin_yard_id IS NULL OR destination_yard_id IS NULL)
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
AND status <> 'SUPERSEDED'
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
|
||||||
|
deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
|
||||||
|
CASE
|
||||||
|
WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'EMPTY_CONTAINER', 'INTERCITY'))
|
||||||
|
OR trigger IN ('CUSTOMS_CLEARANCE', 'ETHIOPIAN_CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL')
|
||||||
|
OR (trigger = 'HAZARDOUS' AND rate_unit = 'PER_CONTAINER')
|
||||||
|
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
|
||||||
|
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
|
||||||
|
END
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// Lane-scoped per-container hazard rows have no place under the old
|
||||||
|
// constraint (surcharges carried no yards) — retire them the same way.
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.rates
|
||||||
|
SET status = 'SUPERSEDED'
|
||||||
|
WHERE trigger = 'HAZARDOUS'
|
||||||
|
AND rate_unit = 'PER_CONTAINER'
|
||||||
|
AND (origin_yard_id IS NOT NULL OR destination_yard_id IS NOT NULL)
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
AND status <> 'SUPERSEDED'
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
|
||||||
|
deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
|
||||||
|
CASE
|
||||||
|
WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'EMPTY_CONTAINER', 'INTERCITY'))
|
||||||
|
OR trigger IN ('CUSTOMS_CLEARANCE', 'ETHIOPIAN_CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL')
|
||||||
|
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
|
||||||
|
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
|
||||||
|
END
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,7 +82,7 @@ describe("BillingService.generateInvoice", () => {
|
|||||||
{} as never, // invoiceDocuments
|
{} as never, // invoiceDocuments
|
||||||
{} as never, // files
|
{} as never, // files
|
||||||
{ get: () => undefined } as never, // config
|
{ get: () => undefined } as never, // config
|
||||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings
|
||||||
{ directSend: jest.fn() } as never, // notifications
|
{ directSend: jest.fn() } as never, // notifications
|
||||||
{ notify: jest.fn() } as never, // inbox
|
{ notify: jest.fn() } as never, // inbox
|
||||||
);
|
);
|
||||||
@@ -167,7 +167,7 @@ describe("BillingService.issueMemo", () => {
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{ get: () => undefined } as never,
|
{ get: () => undefined } as never,
|
||||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings
|
||||||
{ directSend: jest.fn() } as never, // notifications
|
{ directSend: jest.fn() } as never, // notifications
|
||||||
{ notify: jest.fn() } as never, // inbox
|
{ notify: jest.fn() } as never, // inbox
|
||||||
);
|
);
|
||||||
@@ -304,7 +304,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
|||||||
{} as never, // invoiceDocuments
|
{} as never, // invoiceDocuments
|
||||||
{} as never, // files
|
{} as never, // files
|
||||||
{ get: () => undefined } as never, // config
|
{ get: () => undefined } as never, // config
|
||||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings
|
||||||
{ directSend: jest.fn() } as never, // notifications
|
{ directSend: jest.fn() } as never, // notifications
|
||||||
{ notify: jest.fn() } as never, // inbox
|
{ notify: jest.fn() } as never, // inbox
|
||||||
);
|
);
|
||||||
@@ -362,7 +362,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
|||||||
{} as never, // invoiceDocuments
|
{} as never, // invoiceDocuments
|
||||||
{} as never, // files
|
{} as never, // files
|
||||||
{ get: () => undefined } as never, // config
|
{ get: () => undefined } as never, // config
|
||||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings
|
||||||
{ directSend: jest.fn() } as never, // notifications
|
{ directSend: jest.fn() } as never, // notifications
|
||||||
{ notify: jest.fn() } as never, // inbox
|
{ notify: jest.fn() } as never, // inbox
|
||||||
);
|
);
|
||||||
@@ -410,7 +410,7 @@ describe("BillingService.settleByPaymentId", () => {
|
|||||||
{} as never, // invoiceDocuments
|
{} as never, // invoiceDocuments
|
||||||
{} as never, // files
|
{} as never, // files
|
||||||
{ get: () => undefined } as never, // config
|
{ get: () => undefined } as never, // config
|
||||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings
|
||||||
{ directSend: jest.fn() } as never, // notifications
|
{ directSend: jest.fn() } as never, // notifications
|
||||||
{ notify: jest.fn() } as never, // inbox
|
{ notify: jest.fn() } as never, // inbox
|
||||||
);
|
);
|
||||||
@@ -526,7 +526,7 @@ describe("BillingService.recordPayment", () => {
|
|||||||
{} as never, // invoiceDocuments
|
{} as never, // invoiceDocuments
|
||||||
{} as never, // files
|
{} as never, // files
|
||||||
{ get: () => undefined } as never, // config
|
{ get: () => undefined } as never, // config
|
||||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings
|
||||||
{ directSend: jest.fn() } as never, // notifications
|
{ directSend: jest.fn() } as never, // notifications
|
||||||
{ notify: jest.fn() } as never, // inbox
|
{ notify: jest.fn() } as never, // inbox
|
||||||
);
|
);
|
||||||
@@ -646,7 +646,7 @@ describe("BillingService.expirePayable — locked write runs in a transaction",
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never, // config
|
{} as never, // config
|
||||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings
|
||||||
{ directSend: jest.fn() } as never, // notifications
|
{ directSend: jest.fn() } as never, // notifications
|
||||||
{ notify: jest.fn() } as never, // inbox
|
{ notify: jest.fn() } as never, // inbox
|
||||||
);
|
);
|
||||||
@@ -722,7 +722,7 @@ describe("BillingService.issuePayable", () => {
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never, // config
|
{} as never, // config
|
||||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings
|
||||||
{ directSend: jest.fn() } as never, // notifications
|
{ directSend: jest.fn() } as never, // notifications
|
||||||
{ notify: jest.fn() } as never, // inbox
|
{ notify: jest.fn() } as never, // inbox
|
||||||
);
|
);
|
||||||
@@ -816,7 +816,7 @@ describe("BillingService — CAC Bank (OTP debit)", () => {
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never, // config
|
{} as never, // config
|
||||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings
|
||||||
{ directSend: jest.fn() } as never, // notifications
|
{ directSend: jest.fn() } as never, // notifications
|
||||||
{ notify: jest.fn() } as never, // inbox
|
{ notify: jest.fn() } as never, // inbox
|
||||||
);
|
);
|
||||||
@@ -902,7 +902,7 @@ describe("BillingService — CBE bill amounts carry cents, never rounded", () =>
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never, // config
|
{} as never, // config
|
||||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings
|
||||||
{ directSend: jest.fn() } as never, // notifications
|
{ directSend: jest.fn() } as never, // notifications
|
||||||
{ notify: jest.fn() } as never, // inbox
|
{ notify: jest.fn() } as never, // inbox
|
||||||
);
|
);
|
||||||
@@ -978,7 +978,7 @@ describe("BillingService.document", () => {
|
|||||||
? { tin: "0053481357", invoice: { sellerVatNumber: "43256663343256663322" } }
|
? { tin: "0053481357", invoice: { sellerVatNumber: "43256663343256663322" } }
|
||||||
: undefined,
|
: undefined,
|
||||||
} as never, // config
|
} as never, // config
|
||||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings
|
||||||
{ directSend: jest.fn() } as never, // notifications
|
{ directSend: jest.fn() } as never, // notifications
|
||||||
{ notify: jest.fn() } as never, // inbox
|
{ notify: jest.fn() } as never, // inbox
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -806,7 +806,7 @@ export class BillingService {
|
|||||||
// settle by hand in a currency whose channel is switched off.
|
// settle by hand in a currency whose channel is switched off.
|
||||||
if (!(await this.manualPaymentSettings.isEnabled(invoice.currency))) {
|
if (!(await this.manualPaymentSettings.isEnabled(invoice.currency))) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Manual payment is disabled for ${invoice.currency ?? "this"} invoices. Enable it in Configuration → Manual payments first.`,
|
`Manual payment is disabled for ${invoice.currency ?? "this"} invoices. Enable it in Configuration → Payments first.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!file) {
|
if (!file) {
|
||||||
@@ -2297,6 +2297,15 @@ export class BillingService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DJF switched off as a payment currency: the online rails (Waafi / CAC
|
||||||
|
// Bank) stop taking it. The invoice itself is untouched — Finance can
|
||||||
|
// still settle it by hand while the DJF manual channel is on.
|
||||||
|
if (!(await this.manualPaymentSettings.isCurrencyOffered(invoice.currency))) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`${invoice.currency} payments are switched off. Enable them in Configuration → Payments first.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// A booking's PREPAID invoice is only payable inside its pay window —
|
// A booking's PREPAID invoice is only payable inside its pay window —
|
||||||
// `dueAt` mirrors booking.paymentDeadline (issuePayable at reserve time).
|
// `dueAt` mirrors booking.paymentDeadline (issuePayable at reserve time).
|
||||||
// Blocking INITIATION here is what makes the deadline real: a payment
|
// Blocking INITIATION here is what makes the deadline real: a payment
|
||||||
|
|||||||
@@ -298,6 +298,42 @@ export class BookingLifecycleNotifierService {
|
|||||||
this.inApp(b, 'Operation request needs changes', msg);
|
this.inApp(b, 'Operation request needs changes', msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operations moved the shipment day (and possibly the train) themselves
|
||||||
|
* instead of asking the customer to. The booking stays under review, so the
|
||||||
|
* customer only needs to know the new day — nothing to resubmit.
|
||||||
|
*/
|
||||||
|
operationRescheduled(b: Booking, previousDay: string | null, note?: string): void {
|
||||||
|
const newDay = b.scheduledDate
|
||||||
|
? b.scheduledDate.toLocaleDateString('en-GB', { timeZone: 'Africa/Addis_Ababa' })
|
||||||
|
: 'a new day';
|
||||||
|
const msg =
|
||||||
|
`Operations moved the shipment day of booking ${b.reference} ` +
|
||||||
|
`${previousDay ? `from ${previousDay} ` : ''}to ${newDay}.` +
|
||||||
|
(note ? ` Note from Operations: ${note}` : '') +
|
||||||
|
' The request stays under review — no action is needed on your side.';
|
||||||
|
if (b.customsClearingEnabled) {
|
||||||
|
this.logger.log(`OPERATION RESCHEDULED (to GL) — ${this.ref(b)}`);
|
||||||
|
void this.inbox.notify({
|
||||||
|
recipients:
|
||||||
|
b.createdByRole === 'GL_ET' && b.createdByUserId
|
||||||
|
? { userIds: [b.createdByUserId] }
|
||||||
|
: CLEARANCE_DESK,
|
||||||
|
audience: NotificationAudience.BACKOFFICE,
|
||||||
|
type: NotificationType.BOOKING_STATUS,
|
||||||
|
title: `Booking ${b.reference} shipment day changed`,
|
||||||
|
body: msg,
|
||||||
|
link: b.contractId
|
||||||
|
? `/dashboard/contracts/clearance/${b.contractId}`
|
||||||
|
: `/dashboard/bookings/${b.id}/clearance`,
|
||||||
|
data: { bookingId: b.id, reference: b.reference, note: note ?? null },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void this.notifyContact(b, msg, 'OPERATION RESCHEDULED');
|
||||||
|
this.inApp(b, 'Shipment day changed by Operations', msg);
|
||||||
|
}
|
||||||
|
|
||||||
/** Operation accepted → invoice ready; await payment / booking window. */
|
/** Operation accepted → invoice ready; await payment / booking window. */
|
||||||
operationAccepted(b: Booking): void {
|
operationAccepted(b: Booking): void {
|
||||||
// No invoice and no pay window for a shipping line — the charge sits on
|
// No invoice and no pay window for a shipping line — the charge sits on
|
||||||
@@ -620,4 +656,67 @@ export class BookingLifecycleNotifierService {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The customer picked a registered transit agent (a freight forwarder on the
|
||||||
|
* platform) to clear this booking. Tell the forwarder company — every one of
|
||||||
|
* its portal users in the bell, plus SMS and email to its contact — so the
|
||||||
|
* job shows up in its Assigned Bookings tab and it can start preparing
|
||||||
|
* documents. The company is found through its transit-agent link; an agent
|
||||||
|
* nobody has registered against gets no message, since there is nobody to
|
||||||
|
* send it to. Never throws: a failed notice must not undo a completed booking.
|
||||||
|
*/
|
||||||
|
async transitAgentAssigned(
|
||||||
|
b: Booking,
|
||||||
|
agent: { id: string; name: string },
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const [forwarder]: Array<{ id: string }> = await this.dataSource.query(
|
||||||
|
`SELECT id FROM freight.companies
|
||||||
|
WHERE transit_agent_id = $1 AND deleted_at IS NULL
|
||||||
|
LIMIT 1`,
|
||||||
|
[agent.id],
|
||||||
|
);
|
||||||
|
if (!forwarder) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Transit agent ${agent.name} has no forwarder company — assignment notice for ${this.ref(b)} not sent`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const title = 'New booking assigned to you';
|
||||||
|
const body = `Booking ${b.reference} has been assigned to ${agent.name} for customs clearance. Open Assigned Bookings in the portal to see it.`;
|
||||||
|
void this.inbox.notify({
|
||||||
|
recipients: { companyId: forwarder.id },
|
||||||
|
audience: NotificationAudience.PORTAL,
|
||||||
|
type: NotificationType.BOOKING_STATUS,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
link: '/forwarder/assigned-bookings',
|
||||||
|
data: { bookingId: b.id, reference: b.reference, transitAgentId: agent.id },
|
||||||
|
});
|
||||||
|
const { phone, email } = await resolveCompanyNotifyContact(
|
||||||
|
this.dataSource,
|
||||||
|
forwarder.id,
|
||||||
|
);
|
||||||
|
const message = `EDR Freight: ${body}`;
|
||||||
|
if (phone) {
|
||||||
|
try {
|
||||||
|
await this.notifications.directSend('sms', phone, message);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Forwarder SMS failed for ${this.ref(b)}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (email) {
|
||||||
|
try {
|
||||||
|
await this.notifications.directSend('email', email, message);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Forwarder email failed for ${this.ref(b)}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`transitAgentAssigned failed for ${this.ref(b)}: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -217,3 +217,170 @@ describe('BookingTransitionService — requestOperation export space gate', () =
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Staff reschedule of an operation request: instead of returning the booking
|
||||||
|
* to the customer, Operations sets the new shipment day (and the export train)
|
||||||
|
* themselves. Same day-pool / export gates as the customer request; the booking
|
||||||
|
* lands (back) at OPERATION_REQUEST_PENDING and the customer is told.
|
||||||
|
*/
|
||||||
|
describe('BookingTransitionService — staff reschedule of an operation request', () => {
|
||||||
|
function makeService(over: {
|
||||||
|
status?: string;
|
||||||
|
tradeDirection?: 'EXPORT' | 'IMPORT';
|
||||||
|
hasDeparture?: boolean;
|
||||||
|
} = {}) {
|
||||||
|
const booking = {
|
||||||
|
id: 'b-1',
|
||||||
|
reference: 'BKG-1',
|
||||||
|
status: over.status ?? 'OPERATION_REQUEST_PENDING',
|
||||||
|
tradeDirection: over.tradeDirection ?? 'IMPORT',
|
||||||
|
originYardId: 'o-1',
|
||||||
|
destinationYardId: 'd-1',
|
||||||
|
totalAmount: 1000,
|
||||||
|
contractId: null,
|
||||||
|
scheduledDate: new Date('2026-07-01T00:00:00.000Z'),
|
||||||
|
requestedTrainScheduleId: null,
|
||||||
|
serviceType: { code: 'RAIL_CONTAINER' },
|
||||||
|
};
|
||||||
|
const bookingsRepository = {
|
||||||
|
update: jest.fn().mockResolvedValue({ id: 'b-1' }),
|
||||||
|
createReviewNote: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const bookingsService = {
|
||||||
|
findById: jest.fn().mockResolvedValue(booking),
|
||||||
|
checkDayCompatibilityForBooking: jest.fn().mockResolvedValue({
|
||||||
|
hasDeparture: over.hasDeparture ?? true,
|
||||||
|
hasCompatible: true,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const bookingBatchService = {
|
||||||
|
pickExportSchedule: jest.fn().mockResolvedValue('sched-1'),
|
||||||
|
};
|
||||||
|
const notifier = { operationRescheduled: jest.fn() };
|
||||||
|
const clearanceEvents = { record: jest.fn() };
|
||||||
|
|
||||||
|
const service = new BookingTransitionService(
|
||||||
|
bookingsRepository as never,
|
||||||
|
{} as never, // ruleEngineService
|
||||||
|
{} as never, // pricingService
|
||||||
|
{} as never, // contractService
|
||||||
|
{} as never, // filesService
|
||||||
|
{} as never, // fileUploadSettingsService
|
||||||
|
bookingBatchService as never,
|
||||||
|
bookingsService as never,
|
||||||
|
{ isPhasedCustomsBooking: () => false } as never,
|
||||||
|
{} as never, // workflowService
|
||||||
|
{} as never, // invoiceService
|
||||||
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
|
notifier as never,
|
||||||
|
clearanceEvents as never,
|
||||||
|
{ emit: jest.fn() } as never, // events
|
||||||
|
);
|
||||||
|
return { service, bookingsRepository, bookingBatchService, notifier, clearanceEvents };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('refuses a booking that has not requested operation', async () => {
|
||||||
|
const { service, bookingsRepository } = makeService({ status: 'CLEARANCE_READY' });
|
||||||
|
await expect(
|
||||||
|
service.rescheduleOperationRequest('b-1', '2026-07-20T00:00:00.000Z', null, 'staff-1'),
|
||||||
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
expect(bookingsRepository.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a day with no departure on the route and changes nothing', async () => {
|
||||||
|
const { service, bookingsRepository } = makeService({ hasDeparture: false });
|
||||||
|
await expect(
|
||||||
|
service.rescheduleOperationRequest('b-1', '2026-07-20T00:00:00.000Z', null, 'staff-1'),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(bookingsRepository.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves an import request to the new day, keeps it pending, logs it and tells the customer', async () => {
|
||||||
|
const { service, bookingsRepository, notifier, clearanceEvents } = makeService();
|
||||||
|
await service.rescheduleOperationRequest(
|
||||||
|
'b-1',
|
||||||
|
'2026-07-20T00:00:00.000Z',
|
||||||
|
'sched-9', // ignored for import — the batch engine assigns the train
|
||||||
|
'staff-1',
|
||||||
|
);
|
||||||
|
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
|
||||||
|
status: 'OPERATION_REQUEST_PENDING',
|
||||||
|
scheduledDate: new Date('2026-07-20T00:00:00.000Z'),
|
||||||
|
requestedTrainScheduleId: null,
|
||||||
|
});
|
||||||
|
expect(bookingsRepository.createReviewNote).not.toHaveBeenCalled();
|
||||||
|
expect(clearanceEvents.record).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
bookingId: 'b-1',
|
||||||
|
action: 'OPERATION_RESCHEDULED',
|
||||||
|
actorType: 'STAFF',
|
||||||
|
actorId: 'staff-1',
|
||||||
|
metadata: expect.objectContaining({
|
||||||
|
previousScheduledDate: '2026-07-01',
|
||||||
|
scheduledDate: '2026-07-20',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(notifier.operationRescheduled).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ id: 'b-1' }),
|
||||||
|
'2026-07-01',
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves a change request staff had raised: back to pending, note kept as a staff note', async () => {
|
||||||
|
const { service, bookingsRepository, notifier } = makeService({
|
||||||
|
status: 'OPERATION_CHANGES_REQUESTED',
|
||||||
|
});
|
||||||
|
await service.rescheduleOperationRequest(
|
||||||
|
'b-1',
|
||||||
|
'2026-07-20T00:00:00.000Z',
|
||||||
|
null,
|
||||||
|
'staff-1',
|
||||||
|
{ note: ' Moved to the Monday train ' },
|
||||||
|
);
|
||||||
|
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||||
|
'b-1',
|
||||||
|
expect.objectContaining({ status: 'OPERATION_REQUEST_PENDING' }),
|
||||||
|
);
|
||||||
|
expect(bookingsRepository.createReviewNote).toHaveBeenCalledWith(
|
||||||
|
'b-1',
|
||||||
|
'Moved to the Monday train',
|
||||||
|
'STAFF_NOTE',
|
||||||
|
'staff-1',
|
||||||
|
);
|
||||||
|
expect(notifier.operationRescheduled).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
'2026-07-01',
|
||||||
|
'Moved to the Monday train',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('export rail: requires the train and persists the pick after the space gate', async () => {
|
||||||
|
const { service, bookingsRepository, bookingBatchService } = makeService({
|
||||||
|
tradeDirection: 'EXPORT',
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
service.rescheduleOperationRequest('b-1', '2026-07-20T00:00:00.000Z', null, 'staff-1'),
|
||||||
|
).rejects.toThrow(/select a train/i);
|
||||||
|
expect(bookingsRepository.update).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await service.rescheduleOperationRequest(
|
||||||
|
'b-1',
|
||||||
|
'2026-07-20T00:00:00.000Z',
|
||||||
|
'sched-9',
|
||||||
|
'staff-1',
|
||||||
|
);
|
||||||
|
expect(bookingBatchService.pickExportSchedule).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ requestedTrainScheduleId: 'sched-9' }),
|
||||||
|
);
|
||||||
|
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||||
|
'b-1',
|
||||||
|
expect.objectContaining({
|
||||||
|
status: 'OPERATION_REQUEST_PENDING',
|
||||||
|
requestedTrainScheduleId: 'sched-9',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1311,6 +1311,45 @@ export class BookingTransitionService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { date, requestedId } = await this.resolveOperationDay(
|
||||||
|
booking,
|
||||||
|
scheduledDate,
|
||||||
|
requestedTrainScheduleId,
|
||||||
|
opts?.bypassDayPool,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.bookingsRepository.update(bookingId, {
|
||||||
|
status: "OPERATION_REQUEST_PENDING",
|
||||||
|
scheduledDate: date,
|
||||||
|
requestedTrainScheduleId: requestedId,
|
||||||
|
} as never);
|
||||||
|
await this.clearanceEvents.record({
|
||||||
|
bookingId,
|
||||||
|
action: 'OPERATION_REQUESTED',
|
||||||
|
label: `Requested operation for shipment day ${scheduledDate}`,
|
||||||
|
actorType: 'CUSTOMER',
|
||||||
|
actorId: opts?.userId ?? null,
|
||||||
|
metadata: { scheduledDate },
|
||||||
|
});
|
||||||
|
const fresh = await this.bookingsService.findById(bookingId);
|
||||||
|
this.notifier.operationRequestedToStaff(fresh);
|
||||||
|
return fresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a shipment day (and, for export rail, the picked train) for a
|
||||||
|
* booking the way the customer's operation request does, and resolve what
|
||||||
|
* gets persisted: the binding `scheduledDate` and the `requestedTrainScheduleId`
|
||||||
|
* (export rail / shipping-line only — import and domestic trains are assigned
|
||||||
|
* by the batch engine, so their pick is dropped). Shared by the customer
|
||||||
|
* request and the staff reschedule so both enforce the same gates.
|
||||||
|
*/
|
||||||
|
private async resolveOperationDay(
|
||||||
|
booking: Booking,
|
||||||
|
scheduledDate: string,
|
||||||
|
requestedTrainScheduleId?: string | null,
|
||||||
|
bypassDayPool?: boolean,
|
||||||
|
): Promise<{ date: Date; requestedId: string | null }> {
|
||||||
const date = new Date(scheduledDate);
|
const date = new Date(scheduledDate);
|
||||||
if (Number.isNaN(date.getTime())) {
|
if (Number.isNaN(date.getTime())) {
|
||||||
throw new BadRequestException("A valid schedule date is required");
|
throw new BadRequestException("A valid schedule date is required");
|
||||||
@@ -1322,7 +1361,7 @@ export class BookingTransitionService {
|
|||||||
// gate; quantity never blocks — oversized bookings get a partial split
|
// gate; quantity never blocks — oversized bookings get a partial split
|
||||||
// offer). The batch engine assigns the specific train within that
|
// offer). The batch engine assigns the specific train within that
|
||||||
// (route, day) pool later.
|
// (route, day) pool later.
|
||||||
if (!opts?.bypassDayPool) {
|
if (!bypassDayPool) {
|
||||||
const { hasDeparture, hasCompatible } =
|
const { hasDeparture, hasCompatible } =
|
||||||
await this.bookingsService.checkDayCompatibilityForBooking(
|
await this.bookingsService.checkDayCompatibilityForBooking(
|
||||||
booking,
|
booking,
|
||||||
@@ -1359,7 +1398,7 @@ export class BookingTransitionService {
|
|||||||
// persisted here the same way an export pick is. Customer import/domestic
|
// persisted here the same way an export pick is. Customer import/domestic
|
||||||
// bookings still never carry one (the batch engine assigns their train).
|
// bookings still never carry one (the batch engine assigns their train).
|
||||||
const requestedId =
|
const requestedId =
|
||||||
isExportTrain || opts?.bypassDayPool
|
isExportTrain || bypassDayPool
|
||||||
? (requestedTrainScheduleId ?? null)
|
? (requestedTrainScheduleId ?? null)
|
||||||
: null;
|
: null;
|
||||||
// Export rail rides the exact train the customer picked — never an
|
// Export rail rides the exact train the customer picked — never an
|
||||||
@@ -1403,21 +1442,76 @@ export class BookingTransitionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { date, requestedId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operations changes the shipment day and/or train of a booking the customer
|
||||||
|
* has already requested operation on — instead of bouncing it back to the
|
||||||
|
* customer with a change request, staff set the new day (and, for export
|
||||||
|
* rail, the train) themselves. The same day-pool / export-space gates as the
|
||||||
|
* customer's own request apply, so staff cannot park a booking on a day with
|
||||||
|
* no departure or a train with no room.
|
||||||
|
*
|
||||||
|
* Allowed at OPERATION_REQUEST_PENDING (staff review) and at
|
||||||
|
* OPERATION_CHANGES_REQUESTED (staff resolve their own change request); either
|
||||||
|
* way the booking lands back at OPERATION_REQUEST_PENDING for the normal
|
||||||
|
* accept. The customer is told the new day, with the staff note when given.
|
||||||
|
*/
|
||||||
|
async rescheduleOperationRequest(
|
||||||
|
bookingId: string,
|
||||||
|
scheduledDate: string,
|
||||||
|
requestedTrainScheduleId: string | null | undefined,
|
||||||
|
actorId: string,
|
||||||
|
options: { note?: string } = {},
|
||||||
|
): Promise<Booking> {
|
||||||
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
|
assertBookingStatus(booking, [
|
||||||
|
"OPERATION_REQUEST_PENDING",
|
||||||
|
"OPERATION_CHANGES_REQUESTED",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { date, requestedId } = await this.resolveOperationDay(
|
||||||
|
booking,
|
||||||
|
scheduledDate,
|
||||||
|
requestedTrainScheduleId,
|
||||||
|
);
|
||||||
|
const previousDay = booking.scheduledDate ? eatDay(booking.scheduledDate) : null;
|
||||||
|
const previousTrainId = booking.requestedTrainScheduleId ?? null;
|
||||||
|
const note = options.note?.trim() || undefined;
|
||||||
|
|
||||||
await this.bookingsRepository.update(bookingId, {
|
await this.bookingsRepository.update(bookingId, {
|
||||||
status: "OPERATION_REQUEST_PENDING",
|
status: "OPERATION_REQUEST_PENDING",
|
||||||
scheduledDate: date,
|
scheduledDate: date,
|
||||||
requestedTrainScheduleId: requestedId,
|
requestedTrainScheduleId: requestedId,
|
||||||
} as never);
|
} as never);
|
||||||
|
if (note) {
|
||||||
|
await this.bookingsRepository.createReviewNote(
|
||||||
|
bookingId,
|
||||||
|
note,
|
||||||
|
"STAFF_NOTE",
|
||||||
|
actorId,
|
||||||
|
);
|
||||||
|
}
|
||||||
await this.clearanceEvents.record({
|
await this.clearanceEvents.record({
|
||||||
bookingId,
|
bookingId,
|
||||||
action: 'OPERATION_REQUESTED',
|
action: "OPERATION_RESCHEDULED",
|
||||||
label: `Requested operation for shipment day ${scheduledDate}`,
|
label:
|
||||||
actorType: 'CUSTOMER',
|
`Operations moved the shipment day ` +
|
||||||
actorId: opts?.userId ?? null,
|
`${previousDay ? `from ${previousDay} ` : ""}to ${eatDay(date)}` +
|
||||||
metadata: { scheduledDate },
|
(requestedId && requestedId !== previousTrainId ? " and changed the train" : ""),
|
||||||
|
actorType: "STAFF",
|
||||||
|
actorId,
|
||||||
|
metadata: {
|
||||||
|
previousScheduledDate: previousDay,
|
||||||
|
scheduledDate: eatDay(date),
|
||||||
|
previousTrainScheduleId: previousTrainId,
|
||||||
|
trainScheduleId: requestedId,
|
||||||
|
note: note ?? null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const fresh = await this.bookingsService.findById(bookingId);
|
const fresh = await this.bookingsService.findById(bookingId);
|
||||||
this.notifier.operationRequestedToStaff(fresh);
|
this.notifier.operationRescheduled(fresh, previousDay, note);
|
||||||
return fresh;
|
return fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ import {
|
|||||||
ReviewDocumentDto,
|
ReviewDocumentDto,
|
||||||
RequestOperationDto,
|
RequestOperationDto,
|
||||||
OperationReviewDto,
|
OperationReviewDto,
|
||||||
|
RescheduleOperationDto,
|
||||||
StaffRejectDto,
|
StaffRejectDto,
|
||||||
} from "./dto/request-changes.dto";
|
} from "./dto/request-changes.dto";
|
||||||
import { ContractViewDto } from "./dto/contract-view.dto";
|
import { ContractViewDto } from "./dto/contract-view.dto";
|
||||||
@@ -1344,6 +1345,29 @@ export class BookingsController {
|
|||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(":id/operation/reschedule")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Operations changes a pending operation request's shipment day and/or " +
|
||||||
|
"train on the customer's behalf (OPERATION_REQUEST_PENDING | " +
|
||||||
|
"OPERATION_CHANGES_REQUESTED → OPERATION_REQUEST_PENDING)",
|
||||||
|
})
|
||||||
|
async rescheduleOperationRequest(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: RescheduleOperationDto,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
const booking = await this.transitionService.rescheduleOperationRequest(
|
||||||
|
id,
|
||||||
|
dto.scheduledDate,
|
||||||
|
dto.trainScheduleId ?? null,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
{ note: dto.note },
|
||||||
|
);
|
||||||
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(":id/clearance/review")
|
@Post(":id/clearance/review")
|
||||||
@BookingStaff(FREIGHT_PERMS.bookings.reviewDocuments)
|
@BookingStaff(FREIGHT_PERMS.bookings.reviewDocuments)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
|
|||||||
@@ -107,6 +107,38 @@ export class RequestOperationDto {
|
|||||||
trainScheduleId?: string;
|
trainScheduleId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operations changes a pending operation request's shipment day and/or train
|
||||||
|
* on the customer's behalf (instead of returning it for changes).
|
||||||
|
*/
|
||||||
|
export class RescheduleOperationDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description:
|
||||||
|
'The new shipment day (train departure day). ISO date — must have an ' +
|
||||||
|
'open departure on the booking route that can carry the cargo.',
|
||||||
|
example: '2026-07-15',
|
||||||
|
})
|
||||||
|
@IsDateString()
|
||||||
|
scheduledDate!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'EXPORT rail only: the train (schedule id) to ride, from ' +
|
||||||
|
'GET /bookings/:id/export-trains for the new day. Required for export ' +
|
||||||
|
'rail; ignored for import/domestic/road bookings.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
trainScheduleId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Optional note to the customer explaining the change.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class OperationReviewDto {
|
export class OperationReviewDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -330,6 +330,7 @@ export class CompaniesController {
|
|||||||
dto.nationality,
|
dto.nationality,
|
||||||
dto.cooperative,
|
dto.cooperative,
|
||||||
dto.investorLicence,
|
dto.investorLicence,
|
||||||
|
dto.transitAgentId,
|
||||||
);
|
);
|
||||||
return new CompanyInfoResponseDto(profile, company);
|
return new CompanyInfoResponseDto(profile, company);
|
||||||
}
|
}
|
||||||
@@ -363,6 +364,7 @@ export class CompaniesController {
|
|||||||
dto.type,
|
dto.type,
|
||||||
dto.businessLicense,
|
dto.businessLicense,
|
||||||
dto.licenceNumber,
|
dto.licenceNumber,
|
||||||
|
dto.transitAgentId,
|
||||||
);
|
);
|
||||||
return new ResponseCompanyProfileDto(profile);
|
return new ResponseCompanyProfileDto(profile);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,9 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
|||||||
|
|
||||||
const company = () => ({
|
const company = () => ({
|
||||||
id: "company-1",
|
id: "company-1",
|
||||||
|
// Already named its roster entry: taking the forwarder role needs one,
|
||||||
|
// and these tests are about the PoA gate, not the transit-agent link.
|
||||||
|
transitAgentId: "ta-et",
|
||||||
status: ctx.status,
|
status: ctx.status,
|
||||||
nationality: ctx.nationality,
|
nationality: ctx.nationality,
|
||||||
attributes: ctx.attributes,
|
attributes: ctx.attributes,
|
||||||
@@ -175,6 +178,14 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
|||||||
deps.companyNotifier as never,
|
deps.companyNotifier as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
deps.verifayda as never,
|
deps.verifayda as never,
|
||||||
|
{
|
||||||
|
findById: jest.fn(async () => ({
|
||||||
|
id: "ta-et",
|
||||||
|
name: "Abyssinia Transit",
|
||||||
|
isActive: true,
|
||||||
|
country: "ET",
|
||||||
|
})),
|
||||||
|
} as never, // transitAgentsRepo
|
||||||
);
|
);
|
||||||
|
|
||||||
jest
|
jest
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ function makeService(company: Record<string, unknown> | null) {
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{} as never,
|
||||||
);
|
);
|
||||||
|
|
||||||
jest
|
jest
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ function makeService(
|
|||||||
{ changeRequestSubmitted: jest.fn() } as never,
|
{ changeRequestSubmitted: jest.fn() } as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{} as never,
|
||||||
);
|
);
|
||||||
|
|
||||||
jest
|
jest
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
|||||||
|
|
||||||
const company = () => ({
|
const company = () => ({
|
||||||
id: "company-1",
|
id: "company-1",
|
||||||
|
// Already named its roster entry: taking the forwarder role needs one,
|
||||||
|
// and these tests are about the PoA gate, not the transit-agent link.
|
||||||
|
transitAgentId: "ta-et",
|
||||||
status: ctx.status,
|
status: ctx.status,
|
||||||
attributes: ctx.attributes,
|
attributes: ctx.attributes,
|
||||||
companyProfiles: ctx.profileTypes.map((type, i) => ({
|
companyProfiles: ctx.profileTypes.map((type, i) => ({
|
||||||
@@ -146,6 +149,14 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
|||||||
deps.companyNotifier as never,
|
deps.companyNotifier as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{
|
||||||
|
findById: jest.fn(async () => ({
|
||||||
|
id: "ta-et",
|
||||||
|
name: "Abyssinia Transit",
|
||||||
|
isActive: true,
|
||||||
|
country: "ET",
|
||||||
|
})),
|
||||||
|
} as never, // transitAgentsRepo
|
||||||
);
|
);
|
||||||
|
|
||||||
// getCompanyInfoByUserId does its own lookups; the stubs above are enough for
|
// getCompanyInfoByUserId does its own lookups; the stubs above are enough for
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ function makeService(status: ProfileStatus) {
|
|||||||
companyNotifier as never,
|
companyNotifier as never,
|
||||||
dataSource as never,
|
dataSource as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{} as never,
|
||||||
);
|
);
|
||||||
|
|
||||||
return { service, profile, written, companyProfilesRepo };
|
return { service, profile, written, companyProfilesRepo };
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ function makeService(attributes: Record<string, unknown> = {}) {
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{} as never,
|
||||||
);
|
);
|
||||||
|
|
||||||
jest
|
jest
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ function makeService(existing: ExistingProfile[]) {
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{} as never,
|
||||||
);
|
);
|
||||||
|
|
||||||
jest
|
jest
|
||||||
|
|||||||
@@ -31,6 +31,31 @@ import {
|
|||||||
POA_DELEGATION_PENDING_CODE,
|
POA_DELEGATION_PENDING_CODE,
|
||||||
} from "../file-upload-settings/poa-delegation.constants";
|
} from "../file-upload-settings/poa-delegation.constants";
|
||||||
import { VerifaydaService } from "../verifayda/verifayda.service";
|
import { VerifaydaService } from "../verifayda/verifayda.service";
|
||||||
|
import {
|
||||||
|
TransitAgent,
|
||||||
|
TransitAgentCountry,
|
||||||
|
} from "../transit-agents/entities/transit-agent.entity";
|
||||||
|
import { TransitAgentsRepository } from "../transit-agents/transit-agents.repository";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The roles that ARE an Ethiopian transit-agent roster entry. Both name the
|
||||||
|
* company's `transitAgentId`; the difference is what else the company does —
|
||||||
|
* a forwarder also trades on other companies' behalf, a plain transit agent
|
||||||
|
* only clears customs for bookings customers assign to it.
|
||||||
|
*/
|
||||||
|
function isAgentRole(type: ProfileType): boolean {
|
||||||
|
// A function rather than a module-level array: the entity module is still
|
||||||
|
// initialising when this file loads (company-profile → company → …), so
|
||||||
|
// reading the enum at load time throws.
|
||||||
|
return (
|
||||||
|
type === ProfileType.freightForwarder || type === ProfileType.transitAgent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the company does nothing but act as a transit agent. */
|
||||||
|
function isTransitAgentOnly(types: ProfileType[]): boolean {
|
||||||
|
return types.length > 0 && types.every((t) => t === ProfileType.transitAgent);
|
||||||
|
}
|
||||||
import {
|
import {
|
||||||
buildCompanyIdentityState,
|
buildCompanyIdentityState,
|
||||||
CompanyIdentityStateDto,
|
CompanyIdentityStateDto,
|
||||||
@@ -204,6 +229,7 @@ export class CompaniesService {
|
|||||||
private readonly companyNotifier: CompanyNotifierService,
|
private readonly companyNotifier: CompanyNotifierService,
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
private readonly verifaydaService: VerifaydaService,
|
private readonly verifaydaService: VerifaydaService,
|
||||||
|
private readonly transitAgentsRepo: TransitAgentsRepository,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -387,21 +413,21 @@ export class CompaniesService {
|
|||||||
nationality?: CompanyNationality,
|
nationality?: CompanyNationality,
|
||||||
cooperative?: boolean,
|
cooperative?: boolean,
|
||||||
investorLicence?: boolean,
|
investorLicence?: boolean,
|
||||||
|
transitAgentId?: string,
|
||||||
): Promise<{ profile: ExternalProfile; company: Company }> {
|
): Promise<{ profile: ExternalProfile; company: Company }> {
|
||||||
|
const needsAgent = roles.some(isAgentRole);
|
||||||
|
// A company that is ONLY a transit agent has nothing else to tell us: its
|
||||||
|
// registration IS the roster entry it picked, so onboarding ends here.
|
||||||
|
const transitAgentOnly = isTransitAgentOnly(roles);
|
||||||
// Already started — reuse the existing draft, just ensure roles exist and
|
// Already started — reuse the existing draft, just ensure roles exist and
|
||||||
// keep the nationality up to date if it was (re)selected.
|
// keep the nationality up to date if it was (re)selected.
|
||||||
const existing = await this.profilesRepo.findByUserId(identity.userId);
|
const existing = await this.profilesRepo.findByUserId(identity.userId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const companyId = existing.company?.id ?? existing.companyId;
|
const companyId = existing.company?.id ?? existing.companyId;
|
||||||
// Only load the row when the answer actually depends on it: to merge the
|
// The stored row decides more than one answer here: the flags the caller
|
||||||
// flag into `attributes`, or to read a stored one the caller didn't send.
|
// didn't send, the transit agent a forwarder already linked, and whether
|
||||||
const needsCompany =
|
// dropping the forwarder role has a link to clear.
|
||||||
cooperative !== undefined ||
|
const current = await this.companiesRepo.findById(companyId);
|
||||||
investorLicence !== undefined ||
|
|
||||||
roles.includes(ProfileType.freightForwarder);
|
|
||||||
const current = needsCompany
|
|
||||||
? await this.companiesRepo.findById(companyId)
|
|
||||||
: null;
|
|
||||||
const isCoop = cooperative ?? isCooperative(current);
|
const isCoop = cooperative ?? isCooperative(current);
|
||||||
const isInvestor = investorLicence ?? hasInvestorLicence(current);
|
const isInvestor = investorLicence ?? hasInvestorLicence(current);
|
||||||
this.assertRolesAllowedForCooperative(isCoop, roles);
|
this.assertRolesAllowedForCooperative(isCoop, roles);
|
||||||
@@ -411,8 +437,38 @@ export class CompaniesService {
|
|||||||
isCoop,
|
isCoop,
|
||||||
nationality ?? current?.nationality ?? undefined,
|
nationality ?? current?.nationality ?? undefined,
|
||||||
);
|
);
|
||||||
|
// A re-run that keeps an agent role may omit the agent it already
|
||||||
|
// picked; one that drops both agent roles drops the link with it, so a
|
||||||
|
// company that later re-adds one is asked again rather than inheriting a
|
||||||
|
// stale answer.
|
||||||
|
const linkedAgent = needsAgent
|
||||||
|
? await this.resolveLinkedTransitAgent(
|
||||||
|
transitAgentId ?? current?.transitAgentId ?? undefined,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
const before = await this.companyProfilesRepo.findByCompanyId(companyId);
|
||||||
|
const wasTransitAgentOnly = isTransitAgentOnly(before.map((p) => p.type));
|
||||||
await this.syncCompanyProfiles(companyId, companyType, roles);
|
await this.syncCompanyProfiles(companyId, companyType, roles);
|
||||||
const updates: Partial<Company> = {};
|
const updates: Partial<Company> = {};
|
||||||
|
if ((linkedAgent?.id ?? null) !== (current?.transitAgentId ?? null)) {
|
||||||
|
updates.transitAgentId = linkedAgent?.id ?? null;
|
||||||
|
}
|
||||||
|
const profilePatch: Partial<ExternalProfile> = {};
|
||||||
|
if (transitAgentOnly && linkedAgent) {
|
||||||
|
// The company is the agent — it gets the roster's name, and there is
|
||||||
|
// no company/owner/documents step left to take.
|
||||||
|
updates.name = linkedAgent.name;
|
||||||
|
if (!existing.onboardingCompleted) {
|
||||||
|
profilePatch.onboardingCompleted = true;
|
||||||
|
profilePatch.onboardingStep = "transit-agent";
|
||||||
|
}
|
||||||
|
} else if (existing.onboardingCompleted && wasTransitAgentOnly) {
|
||||||
|
// Adding a licensed role to a transit-agent-only company reopens the
|
||||||
|
// wizard at the company step: importing or forwarding needs the TIN,
|
||||||
|
// owner, contact and documents the transit agent never had to give.
|
||||||
|
profilePatch.onboardingCompleted = false;
|
||||||
|
profilePatch.onboardingStep = "company";
|
||||||
|
}
|
||||||
if (nationality) updates.nationality = nationality;
|
if (nationality) updates.nationality = nationality;
|
||||||
// Ticking the box on a draft that was saved as foreign has to correct the
|
// Ticking the box on a draft that was saved as foreign has to correct the
|
||||||
// stored nationality too, or the company keeps resolving to the foreign
|
// stored nationality too, or the company keeps resolving to the foreign
|
||||||
@@ -446,10 +502,9 @@ export class CompaniesService {
|
|||||||
if (Object.keys(updates).length > 0) {
|
if (Object.keys(updates).length > 0) {
|
||||||
await this.companiesRepo.update(companyId, updates);
|
await this.companiesRepo.update(companyId, updates);
|
||||||
}
|
}
|
||||||
if (backToEtrade) {
|
if (backToEtrade) profilePatch.onboardingStep = "company";
|
||||||
await this.profilesRepo.update(existing.id, {
|
if (Object.keys(profilePatch).length > 0) {
|
||||||
onboardingStep: "company",
|
await this.profilesRepo.update(existing.id, profilePatch);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return this.getCompanyInfoByUserId(identity.userId);
|
return this.getCompanyInfoByUserId(identity.userId);
|
||||||
}
|
}
|
||||||
@@ -463,16 +518,23 @@ export class CompaniesService {
|
|||||||
);
|
);
|
||||||
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
|
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
|
||||||
const chosenTypes = roles.filter((t) => allowedTypes.includes(t));
|
const chosenTypes = roles.filter((t) => allowedTypes.includes(t));
|
||||||
|
const linkedAgent = needsAgent
|
||||||
|
? await this.resolveLinkedTransitAgent(transitAgentId)
|
||||||
|
: null;
|
||||||
|
|
||||||
const company = await this.companiesRepo.create({
|
const company = await this.companiesRepo.create({
|
||||||
name: identity.firstName
|
name:
|
||||||
? `${identity.firstName}'s company`
|
transitAgentOnly && linkedAgent
|
||||||
: "New company",
|
? linkedAgent.name
|
||||||
|
: identity.firstName
|
||||||
|
? `${identity.firstName}'s company`
|
||||||
|
: "New company",
|
||||||
type: companyType,
|
type: companyType,
|
||||||
tin: await this.generateDraftTin(),
|
tin: await this.generateDraftTin(),
|
||||||
country: "Ethiopia",
|
country: "Ethiopia",
|
||||||
nationality: nationality ?? CompanyNationality.Ethiopian,
|
nationality: nationality ?? CompanyNationality.Ethiopian,
|
||||||
status: CompanyStatus.Pending,
|
status: CompanyStatus.Pending,
|
||||||
|
transitAgentId: linkedAgent?.id ?? null,
|
||||||
...(cooperative || investorLicence
|
...(cooperative || investorLicence
|
||||||
? {
|
? {
|
||||||
attributes: {
|
attributes: {
|
||||||
@@ -489,8 +551,10 @@ export class CompaniesService {
|
|||||||
firstName: identity.firstName,
|
firstName: identity.firstName,
|
||||||
lastName: identity.lastName,
|
lastName: identity.lastName,
|
||||||
isPrimaryContact: true,
|
isPrimaryContact: true,
|
||||||
onboardingStep: "company",
|
// A transit-agent-only company is done the moment it picks its roster
|
||||||
onboardingCompleted: false,
|
// entry — see `transitAgentOnly` above.
|
||||||
|
onboardingStep: transitAgentOnly ? "transit-agent" : "company",
|
||||||
|
onboardingCompleted: transitAgentOnly,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.syncCompanyProfiles(company.id, companyType, chosenTypes);
|
await this.syncCompanyProfiles(company.id, companyType, chosenTypes);
|
||||||
@@ -498,6 +562,75 @@ export class CompaniesService {
|
|||||||
return this.getCompanyInfoByUserId(identity.userId);
|
return this.getCompanyInfoByUserId(identity.userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The transit agent a freight forwarder registers itself as.
|
||||||
|
*
|
||||||
|
* A forwarder and an Ethiopian transit agent are the same business, so the
|
||||||
|
* role cannot be taken without naming which roster entry it is: a company
|
||||||
|
* that is not on the roster asks support to be added first, which is what
|
||||||
|
* the portal's "didn't find my company" note says. Foreign and suspended
|
||||||
|
* entries are refused for the same reason a missing one is — none of them
|
||||||
|
* is a forwarder EDR will assign work to.
|
||||||
|
*/
|
||||||
|
private async resolveLinkedTransitAgent(
|
||||||
|
transitAgentId: string | undefined,
|
||||||
|
): Promise<TransitAgent> {
|
||||||
|
if (!transitAgentId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Select your company from the transit agent list to register as a transit agent or freight forwarder. If it is not listed, contact support to be added.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const agent = await this.transitAgentsRepo.findById(transitAgentId);
|
||||||
|
if (
|
||||||
|
!agent ||
|
||||||
|
!agent.isActive ||
|
||||||
|
agent.country !== TransitAgentCountry.Ethiopia
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"The selected transit agent is not an active Ethiopian transit agent. Pick another one or contact support.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make `agent` the company's transit agent if it is not already. Shared by
|
||||||
|
* every add-role path: the link is per company, so a forwarder that already
|
||||||
|
* picked its roster entry is not asked again when it adds the transit agent
|
||||||
|
* role, and vice versa.
|
||||||
|
*/
|
||||||
|
private async linkTransitAgent(
|
||||||
|
company: Company,
|
||||||
|
transitAgentId: string | undefined,
|
||||||
|
): Promise<void> {
|
||||||
|
const agent = await this.resolveLinkedTransitAgent(
|
||||||
|
transitAgentId ?? company.transitAgentId ?? undefined,
|
||||||
|
);
|
||||||
|
if (company.transitAgentId === agent.id) return;
|
||||||
|
await this.companiesRepo.update(company.id, { transitAgentId: agent.id });
|
||||||
|
company.transitAgentId = agent.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A transit-agent-only company that takes on a licensed role has to go
|
||||||
|
* back through the wizard: importing, exporting or forwarding needs the TIN,
|
||||||
|
* owner, contact and documents the transit agent never had to give. The
|
||||||
|
* portal reopens the wizard at the company step the moment this flips.
|
||||||
|
*/
|
||||||
|
private async reopenOnboardingForLicensedRole(
|
||||||
|
profile: ExternalProfile,
|
||||||
|
profilesBefore: CompanyProfile[],
|
||||||
|
addedTypes: ProfileType[],
|
||||||
|
): Promise<void> {
|
||||||
|
if (!profile.onboardingCompleted) return;
|
||||||
|
if (!isTransitAgentOnly(profilesBefore.map((p) => p.type))) return;
|
||||||
|
if (!addedTypes.some((t) => t !== ProfileType.transitAgent)) return;
|
||||||
|
await this.profilesRepo.update(profile.id, {
|
||||||
|
onboardingCompleted: false,
|
||||||
|
onboardingStep: "company",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A co-operative union or farm cannot hold the freight-forwarder role.
|
* A co-operative union or farm cannot hold the freight-forwarder role.
|
||||||
*
|
*
|
||||||
@@ -517,6 +650,11 @@ export class CompaniesService {
|
|||||||
"A co-operative union or farm cannot register as a freight forwarder — that role requires a business licence.",
|
"A co-operative union or farm cannot register as a freight forwarder — that role requires a business licence.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (roles.includes(ProfileType.transitAgent)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"A co-operative union or farm cannot register as a transit agent — that is licensed customs work.",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -675,6 +813,7 @@ export class CompaniesService {
|
|||||||
// External profiles carry the onboarding flag the backoffice gates
|
// External profiles carry the onboarding flag the backoffice gates
|
||||||
// approval decisions on (see ResponseCompanyDto.onboardingCompleted).
|
// approval decisions on (see ResponseCompanyDto.onboardingCompleted).
|
||||||
company.profiles = await this.profilesRepo.findByCompanyId(id);
|
company.profiles = await this.profilesRepo.findByCompanyId(id);
|
||||||
|
await this.attachTransitAgent(company);
|
||||||
return company;
|
return company;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -715,10 +854,22 @@ export class CompaniesService {
|
|||||||
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(
|
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(
|
||||||
company.id,
|
company.id,
|
||||||
);
|
);
|
||||||
|
await this.attachTransitAgent(company);
|
||||||
|
|
||||||
return { profile, company };
|
return { profile, company };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hang the linked transit agent off the company so responses can name it.
|
||||||
|
* A separate read rather than a relation join: `findById` on the repository
|
||||||
|
* loads no relations, and every other caller of it has no use for the agent.
|
||||||
|
*/
|
||||||
|
private async attachTransitAgent(company: Company): Promise<void> {
|
||||||
|
company.transitAgent = company.transitAgentId
|
||||||
|
? await this.transitAgentsRepo.findById(company.transitAgentId)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dashboard KPIs for the portal home (MyPortalPage), aggregated from the
|
* Dashboard KPIs for the portal home (MyPortalPage), aggregated from the
|
||||||
* current user's company bookings. All figures are scoped to that company.
|
* current user's company bookings. All figures are scoped to that company.
|
||||||
@@ -1799,6 +1950,7 @@ export class CompaniesService {
|
|||||||
ProfileType.importer,
|
ProfileType.importer,
|
||||||
ProfileType.exporter,
|
ProfileType.exporter,
|
||||||
ProfileType.freightForwarder,
|
ProfileType.freightForwarder,
|
||||||
|
ProfileType.transitAgent,
|
||||||
];
|
];
|
||||||
case "freight_forwarder":
|
case "freight_forwarder":
|
||||||
return [ProfileType.freightForwarder];
|
return [ProfileType.freightForwarder];
|
||||||
@@ -2154,7 +2306,11 @@ export class CompaniesService {
|
|||||||
*/
|
*/
|
||||||
async addCompanyProfilesForUser(
|
async addCompanyProfilesForUser(
|
||||||
userId: string,
|
userId: string,
|
||||||
inputs: Array<{ type: ProfileType; licenceNumber?: string }>,
|
inputs: Array<{
|
||||||
|
type: ProfileType;
|
||||||
|
licenceNumber?: string;
|
||||||
|
transitAgentId?: string;
|
||||||
|
}>,
|
||||||
): Promise<CompanyProfile[]> {
|
): Promise<CompanyProfile[]> {
|
||||||
const types = inputs.map((i) => i.type);
|
const types = inputs.map((i) => i.type);
|
||||||
const profile = await this.profilesRepo.findByUserId(userId);
|
const profile = await this.profilesRepo.findByUserId(userId);
|
||||||
@@ -2164,6 +2320,8 @@ export class CompaniesService {
|
|||||||
const companyId = profile.company?.id ?? profile.companyId;
|
const companyId = profile.company?.id ?? profile.companyId;
|
||||||
const company = await this.findCompanyById(companyId);
|
const company = await this.findCompanyById(companyId);
|
||||||
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
|
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
|
||||||
|
const before = company.companyProfiles ?? [];
|
||||||
|
const wasTransitAgentOnly = isTransitAgentOnly(before.map((p) => p.type));
|
||||||
|
|
||||||
for (const type of types) {
|
for (const type of types) {
|
||||||
if (!allowedTypes.includes(type)) {
|
if (!allowedTypes.includes(type)) {
|
||||||
@@ -2191,14 +2349,31 @@ export class CompaniesService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A transit agent or forwarder IS a roster entry — name it, once per
|
||||||
|
// company, before the role exists.
|
||||||
|
if (isAgentRole(type)) {
|
||||||
|
this.assertRolesAllowedForCooperative(isCooperative(company), [type]);
|
||||||
|
await this.linkTransitAgent(
|
||||||
|
company,
|
||||||
|
inputs.find((i) => i.type === type)?.transitAgentId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Which eTrade business this role operates as. Resolved (and rejected if
|
// Which eTrade business this role operates as. Resolved (and rejected if
|
||||||
// absent) BEFORE the row is created, so a role never lands unattached on
|
// absent) BEFORE the row is created, so a role never lands unattached on
|
||||||
// a company that has licences to pick from.
|
// a company that has licences to pick from. A transit agent has none —
|
||||||
const etradeBusiness = await this.resolveProfileBusiness(
|
// and a transit-agent-only company has no eTrade record to pick from
|
||||||
company,
|
// yet: its first licensed role attaches the business on the wizard's
|
||||||
inputs.find((i) => i.type === type)?.licenceNumber,
|
// licence step, once the TIN has been looked up, exactly like a role
|
||||||
type,
|
// picked at onboarding.
|
||||||
);
|
const etradeBusiness =
|
||||||
|
type === ProfileType.transitAgent || wasTransitAgentOnly
|
||||||
|
? null
|
||||||
|
: await this.resolveProfileBusiness(
|
||||||
|
company,
|
||||||
|
inputs.find((i) => i.type === type)?.licenceNumber,
|
||||||
|
type,
|
||||||
|
);
|
||||||
|
|
||||||
// Self-service role adds start Pending and carry no reference — a reference
|
// Self-service role adds start Pending and carry no reference — a reference
|
||||||
// is minted only when a backoffice reviewer approves the role.
|
// is minted only when a backoffice reviewer approves the role.
|
||||||
@@ -2210,6 +2385,7 @@ export class CompaniesService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.reopenOnboardingForLicensedRole(profile, before, types);
|
||||||
return this.companyProfilesRepo.findByCompanyId(companyId);
|
return this.companyProfilesRepo.findByCompanyId(companyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2224,6 +2400,7 @@ export class CompaniesService {
|
|||||||
type: ProfileType,
|
type: ProfileType,
|
||||||
businessLicense?: string,
|
businessLicense?: string,
|
||||||
licenceNumber?: string,
|
licenceNumber?: string,
|
||||||
|
transitAgentId?: string,
|
||||||
): Promise<CompanyProfile> {
|
): Promise<CompanyProfile> {
|
||||||
const profile = await this.profilesRepo.findByUserId(userId);
|
const profile = await this.profilesRepo.findByUserId(userId);
|
||||||
if (!profile)
|
if (!profile)
|
||||||
@@ -2248,12 +2425,18 @@ export class CompaniesService {
|
|||||||
await this.effectivePoaAttributes(company),
|
await this.effectivePoaAttributes(company),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (!created && isAgentRole(type)) {
|
||||||
|
this.assertRolesAllowedForCooperative(isCooperative(company), [type]);
|
||||||
|
await this.linkTransitAgent(company, transitAgentId);
|
||||||
|
}
|
||||||
if (!created) {
|
if (!created) {
|
||||||
const etradeBusiness = await this.resolveProfileBusiness(
|
// See addCompanyProfilesForUser: no business for a transit agent, nor
|
||||||
company,
|
// for a transit-agent-only company's first licensed role.
|
||||||
licenceNumber,
|
const etradeBusiness =
|
||||||
type,
|
type === ProfileType.transitAgent ||
|
||||||
);
|
isTransitAgentOnly((company.companyProfiles ?? []).map((p) => p.type))
|
||||||
|
? null
|
||||||
|
: await this.resolveProfileBusiness(company, licenceNumber, type);
|
||||||
// New self-service roles start Pending (awaiting backoffice approval) and
|
// New self-service roles start Pending (awaiting backoffice approval) and
|
||||||
// carry no reference until approved.
|
// carry no reference until approved.
|
||||||
created = await this.companyProfilesRepo.create({
|
created = await this.companyProfilesRepo.create({
|
||||||
@@ -2263,6 +2446,11 @@ export class CompaniesService {
|
|||||||
etradeBusiness,
|
etradeBusiness,
|
||||||
status: ProfileStatus.Pending,
|
status: ProfileStatus.Pending,
|
||||||
});
|
});
|
||||||
|
await this.reopenOnboardingForLicensedRole(
|
||||||
|
profile,
|
||||||
|
company.companyProfiles ?? [],
|
||||||
|
[type],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return created;
|
return created;
|
||||||
@@ -2331,9 +2519,13 @@ export class CompaniesService {
|
|||||||
}));
|
}));
|
||||||
const missingDocs = documents.filter((d) => d.isRequired && !d.uploaded);
|
const missingDocs = documents.filter((d) => d.isRequired && !d.uploaded);
|
||||||
|
|
||||||
// 3. Per-operational-profile business licenses (FileRecord-backed).
|
// 3. Per-operational-profile business licenses (FileRecord-backed). A
|
||||||
|
// transit agent holds none here — its roster entry is its registration —
|
||||||
|
// so it owes neither a licence nor an eTrade business.
|
||||||
const licenseProfiles = await Promise.all(
|
const licenseProfiles = await Promise.all(
|
||||||
(company.companyProfiles ?? []).map(async (p) => {
|
(company.companyProfiles ?? [])
|
||||||
|
.filter((p) => p.type !== ProfileType.transitAgent)
|
||||||
|
.map(async (p) => {
|
||||||
const records = await this.filesService.findByResource(
|
const records = await this.filesService.findByResource(
|
||||||
p.id,
|
p.id,
|
||||||
LICENSE_RESOURCE,
|
LICENSE_RESOURCE,
|
||||||
@@ -3775,7 +3967,11 @@ export class CompaniesService {
|
|||||||
companyId: string,
|
companyId: string,
|
||||||
tradeDirection: string,
|
tradeDirection: string,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
|
// A transit agent profile never carries a booking — it is the roster
|
||||||
|
// side of the company, not a trade role.
|
||||||
|
const profiles = (
|
||||||
|
await this.companyProfilesRepo.findByCompanyId(companyId)
|
||||||
|
).filter((p) => p.type !== ProfileType.transitAgent);
|
||||||
if (profiles.length === 0) return null;
|
if (profiles.length === 0) return null;
|
||||||
|
|
||||||
const naturalType =
|
const naturalType =
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
import { BadRequestException } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { TransitAgentCountry } from "../transit-agents/entities/transit-agent.entity";
|
||||||
|
import { CompaniesService } from "./companies.service";
|
||||||
|
import { CompanyType } from "./entities/company.entity";
|
||||||
|
import { ProfileStatus, ProfileType } from "./entities/company-profile.entity";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A freight forwarder IS an Ethiopian transit agent, so taking the role means
|
||||||
|
* naming which roster entry the company is. These lock the rule at the door:
|
||||||
|
* no agent → refused; a foreign or suspended one → refused; and the link
|
||||||
|
* follows the role, both on a fresh draft and when a draft is re-run.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface ExistingProfile {
|
||||||
|
id: string;
|
||||||
|
type: ProfileType;
|
||||||
|
status: ProfileStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ETHIOPIAN = {
|
||||||
|
id: "ta-et",
|
||||||
|
name: "Abyssinia Transit",
|
||||||
|
isActive: true,
|
||||||
|
country: TransitAgentCountry.Ethiopia,
|
||||||
|
};
|
||||||
|
const DJIBOUTIAN = {
|
||||||
|
id: "ta-dj",
|
||||||
|
name: "Ahmed Bourhan",
|
||||||
|
isActive: true,
|
||||||
|
country: TransitAgentCountry.Djibouti,
|
||||||
|
};
|
||||||
|
const SUSPENDED = { ...ETHIOPIAN, id: "ta-off", isActive: false };
|
||||||
|
const AGENTS = [ETHIOPIAN, DJIBOUTIAN, SUSPENDED];
|
||||||
|
|
||||||
|
function makeService(opts: {
|
||||||
|
existing?: ExistingProfile[] | null;
|
||||||
|
linkedAgentId?: string | null;
|
||||||
|
/** The draft's owner already submitted onboarding (transit-agent-only). */
|
||||||
|
onboardingCompleted?: boolean;
|
||||||
|
}) {
|
||||||
|
const companyProfilesRepo = {
|
||||||
|
findByCompanyId: jest.fn(async () => opts.existing ?? []),
|
||||||
|
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||||
|
id: "new",
|
||||||
|
...row,
|
||||||
|
})),
|
||||||
|
softDelete: jest.fn(async () => undefined),
|
||||||
|
};
|
||||||
|
const companiesRepo = {
|
||||||
|
update: jest.fn(async () => null),
|
||||||
|
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||||
|
id: "company-1",
|
||||||
|
...row,
|
||||||
|
})),
|
||||||
|
findById: jest.fn(async () => ({
|
||||||
|
id: "company-1",
|
||||||
|
attributes: {},
|
||||||
|
transitAgentId: opts.linkedAgentId ?? null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const profilesRepo = {
|
||||||
|
findByUserId: jest.fn(async () =>
|
||||||
|
opts.existing === null
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
id: "external-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
company: { id: "company-1" },
|
||||||
|
onboardingCompleted: opts.onboardingCompleted ?? false,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||||
|
id: "external-1",
|
||||||
|
...row,
|
||||||
|
})),
|
||||||
|
update: jest.fn(async () => null),
|
||||||
|
};
|
||||||
|
const transitAgentsRepo = {
|
||||||
|
findById: jest.fn(async (id: string) => AGENTS.find((a) => a.id === id) ?? null),
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = new CompaniesService(
|
||||||
|
companiesRepo as never,
|
||||||
|
companyProfilesRepo as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
profilesRepo as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
transitAgentsRepo as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
jest
|
||||||
|
.spyOn(service, "getCompanyInfoByUserId")
|
||||||
|
.mockImplementation(
|
||||||
|
async () =>
|
||||||
|
({ profile: { id: "external-1" }, company: { id: "company-1" } }) as never,
|
||||||
|
);
|
||||||
|
// The draft TIN is random and irrelevant here.
|
||||||
|
jest
|
||||||
|
.spyOn(service as never, "generateDraftTin" as never)
|
||||||
|
.mockImplementation((async () => "D000000001") as never);
|
||||||
|
|
||||||
|
return { service, companiesRepo, companyProfilesRepo, profilesRepo };
|
||||||
|
}
|
||||||
|
|
||||||
|
const identity = { userId: "user-1", firstName: "Abebe", lastName: "K" };
|
||||||
|
|
||||||
|
const start = (
|
||||||
|
service: CompaniesService,
|
||||||
|
roles: ProfileType[],
|
||||||
|
transitAgentId?: string,
|
||||||
|
) =>
|
||||||
|
service.startOnboarding(
|
||||||
|
identity as never,
|
||||||
|
CompanyType.Customer,
|
||||||
|
roles,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
transitAgentId,
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("a freight forwarder must name its transit agent", () => {
|
||||||
|
it("refuses the forwarder role without an agent on a fresh draft", async () => {
|
||||||
|
const { service, companiesRepo } = makeService({ existing: null });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
start(service, [ProfileType.freightForwarder]),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(companiesRepo.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a Djiboutian agent", async () => {
|
||||||
|
const { service } = makeService({ existing: null });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
start(service, [ProfileType.freightForwarder], DJIBOUTIAN.id),
|
||||||
|
).rejects.toThrow(/not an active Ethiopian transit agent/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a suspended agent", async () => {
|
||||||
|
const { service } = makeService({ existing: null });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
start(service, [ProfileType.freightForwarder], SUSPENDED.id),
|
||||||
|
).rejects.toThrow(/not an active Ethiopian transit agent/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores the Ethiopian agent on the new draft company", async () => {
|
||||||
|
const { service, companiesRepo } = makeService({ existing: null });
|
||||||
|
|
||||||
|
await start(
|
||||||
|
service,
|
||||||
|
[ProfileType.importer, ProfileType.freightForwarder],
|
||||||
|
ETHIOPIAN.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(companiesRepo.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ transitAgentId: ETHIOPIAN.id }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not ask an importer for an agent, and stores none", async () => {
|
||||||
|
const { service, companiesRepo } = makeService({ existing: null });
|
||||||
|
|
||||||
|
await start(service, [ProfileType.importer], ETHIOPIAN.id);
|
||||||
|
|
||||||
|
expect(companiesRepo.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ transitAgentId: null }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("re-running role selection keeps the link in step with the role", () => {
|
||||||
|
const importerOnly: ExistingProfile[] = [
|
||||||
|
{ id: "p-imp", type: ProfileType.importer, status: ProfileStatus.Pending },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("links the agent when forwarding is added to an existing draft", async () => {
|
||||||
|
const { service, companiesRepo } = makeService({ existing: importerOnly });
|
||||||
|
|
||||||
|
await start(
|
||||||
|
service,
|
||||||
|
[ProfileType.importer, ProfileType.freightForwarder],
|
||||||
|
ETHIOPIAN.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(companiesRepo.update).toHaveBeenCalledWith(
|
||||||
|
"company-1",
|
||||||
|
expect.objectContaining({ transitAgentId: ETHIOPIAN.id }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the agent already linked when the re-run omits it", async () => {
|
||||||
|
const { service, companiesRepo } = makeService({
|
||||||
|
existing: importerOnly,
|
||||||
|
linkedAgentId: ETHIOPIAN.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await start(service, [ProfileType.importer, ProfileType.freightForwarder]);
|
||||||
|
|
||||||
|
expect(companiesRepo.update).not.toHaveBeenCalledWith(
|
||||||
|
"company-1",
|
||||||
|
expect.objectContaining({ transitAgentId: expect.anything() }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still refuses a re-run that adds forwarding with nothing linked", async () => {
|
||||||
|
const { service } = makeService({ existing: importerOnly });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
start(service, [ProfileType.importer, ProfileType.freightForwarder]),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the link when the forwarder role is dropped", async () => {
|
||||||
|
const { service, companiesRepo } = makeService({
|
||||||
|
existing: importerOnly,
|
||||||
|
linkedAgentId: ETHIOPIAN.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await start(service, [ProfileType.importer]);
|
||||||
|
|
||||||
|
expect(companiesRepo.update).toHaveBeenCalledWith(
|
||||||
|
"company-1",
|
||||||
|
expect.objectContaining({ transitAgentId: null }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A company that is ONLY a transit agent has nothing else to tell us: its
|
||||||
|
* registration is the roster entry it picked, so onboarding ends right there
|
||||||
|
* — and reopens if it later takes on a licensed role.
|
||||||
|
*/
|
||||||
|
describe("a transit-agent-only company finishes onboarding on the roster pick", () => {
|
||||||
|
it("names the draft after the agent and completes onboarding at once", async () => {
|
||||||
|
const { service, companiesRepo, profilesRepo } = makeService({
|
||||||
|
existing: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await start(service, [ProfileType.transitAgent], ETHIOPIAN.id);
|
||||||
|
|
||||||
|
expect(companiesRepo.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: ETHIOPIAN.name,
|
||||||
|
transitAgentId: ETHIOPIAN.id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(profilesRepo.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
onboardingCompleted: true,
|
||||||
|
onboardingStep: "transit-agent",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still refuses the role without an agent", async () => {
|
||||||
|
const { service, companiesRepo } = makeService({ existing: null });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
start(service, [ProfileType.transitAgent]),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(companiesRepo.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the full wizard when a trade role is picked alongside", async () => {
|
||||||
|
const { service, companiesRepo, profilesRepo } = makeService({
|
||||||
|
existing: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await start(
|
||||||
|
service,
|
||||||
|
[ProfileType.transitAgent, ProfileType.importer],
|
||||||
|
ETHIOPIAN.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(companiesRepo.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ transitAgentId: ETHIOPIAN.id }),
|
||||||
|
);
|
||||||
|
expect(companiesRepo.create).not.toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ name: ETHIOPIAN.name }),
|
||||||
|
);
|
||||||
|
expect(profilesRepo.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
onboardingCompleted: false,
|
||||||
|
onboardingStep: "company",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("completes an existing transit-agent-only draft on the pick", async () => {
|
||||||
|
const { service, companiesRepo, profilesRepo } = makeService({
|
||||||
|
existing: [
|
||||||
|
{ id: "p-ta", type: ProfileType.transitAgent, status: ProfileStatus.Pending },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await start(service, [ProfileType.transitAgent], ETHIOPIAN.id);
|
||||||
|
|
||||||
|
expect(companiesRepo.update).toHaveBeenCalledWith(
|
||||||
|
"company-1",
|
||||||
|
expect.objectContaining({
|
||||||
|
transitAgentId: ETHIOPIAN.id,
|
||||||
|
name: ETHIOPIAN.name,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(profilesRepo.update).toHaveBeenCalledWith(
|
||||||
|
"external-1",
|
||||||
|
expect.objectContaining({
|
||||||
|
onboardingCompleted: true,
|
||||||
|
onboardingStep: "transit-agent",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reopens onboarding at the company step when a licensed role is added", async () => {
|
||||||
|
const { service, profilesRepo } = makeService({
|
||||||
|
existing: [
|
||||||
|
{ id: "p-ta", type: ProfileType.transitAgent, status: ProfileStatus.Active },
|
||||||
|
],
|
||||||
|
linkedAgentId: ETHIOPIAN.id,
|
||||||
|
onboardingCompleted: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await start(service, [ProfileType.transitAgent, ProfileType.importer]);
|
||||||
|
|
||||||
|
expect(profilesRepo.update).toHaveBeenCalledWith(
|
||||||
|
"external-1",
|
||||||
|
expect.objectContaining({
|
||||||
|
onboardingCompleted: false,
|
||||||
|
onboardingStep: "company",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ const SEQUENCE_MAP: Record<ProfileType, string> = {
|
|||||||
[ProfileType.freightForwarder]: "seq_company_profile_ffe",
|
[ProfileType.freightForwarder]: "seq_company_profile_ffe",
|
||||||
[ProfileType.djFreightForwarder]: "seq_company_profile_fwj",
|
[ProfileType.djFreightForwarder]: "seq_company_profile_fwj",
|
||||||
[ProfileType.transporter]: "seq_company_profile_tr",
|
[ProfileType.transporter]: "seq_company_profile_tr",
|
||||||
|
[ProfileType.transitAgent]: "seq_company_profile_ta",
|
||||||
};
|
};
|
||||||
|
|
||||||
const PREFIX_MAP: Record<ProfileType, string> = {
|
const PREFIX_MAP: Record<ProfileType, string> = {
|
||||||
@@ -18,6 +19,7 @@ const PREFIX_MAP: Record<ProfileType, string> = {
|
|||||||
[ProfileType.freightForwarder]: "FF",
|
[ProfileType.freightForwarder]: "FF",
|
||||||
[ProfileType.djFreightForwarder]: "FWJ",
|
[ProfileType.djFreightForwarder]: "FWJ",
|
||||||
[ProfileType.transporter]: "TR",
|
[ProfileType.transporter]: "TR",
|
||||||
|
[ProfileType.transitAgent]: "TA",
|
||||||
};
|
};
|
||||||
|
|
||||||
const SERIES_LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
const SERIES_LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||||
|
|||||||
@@ -86,16 +86,6 @@ export class TransitAgentInfoResponseDto {
|
|||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: "Start of the agent's validity window (yyyy-MM-dd)",
|
|
||||||
})
|
|
||||||
validFrom: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: "End of the agent's validity window (yyyy-MM-dd)",
|
|
||||||
})
|
|
||||||
validTo: string;
|
|
||||||
|
|
||||||
/** Always null — see {@link ShippingLineInfoResponseDto.company}. */
|
/** Always null — see {@link ShippingLineInfoResponseDto.company}. */
|
||||||
@ApiProperty({ nullable: true })
|
@ApiProperty({ nullable: true })
|
||||||
company: null = null;
|
company: null = null;
|
||||||
@@ -112,8 +102,6 @@ export class TransitAgentInfoResponseDto {
|
|||||||
this.email = entity.email ?? null;
|
this.email = entity.email ?? null;
|
||||||
this.phoneNumber = entity.phoneNumber ?? null;
|
this.phoneNumber = entity.phoneNumber ?? null;
|
||||||
this.isActive = entity.isActive;
|
this.isActive = entity.isActive;
|
||||||
this.validFrom = entity.validFrom;
|
|
||||||
this.validTo = entity.validTo;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
IsEnum,
|
IsEnum,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
|
IsUUID,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
@@ -26,6 +27,14 @@ export class AddCompanyProfileInputDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(120)
|
@MaxLength(120)
|
||||||
licenceNumber?: string;
|
licenceNumber?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which Ethiopian transit agent the company is. Required for the transit
|
||||||
|
* agent and freight forwarder roles unless the company is already linked.
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
transitAgentId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AddCompanyProfilesDto {
|
export class AddCompanyProfilesDto {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator';
|
import { IsEnum, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
|
||||||
import { ProfileType } from '../entities/company-profile.entity';
|
import { ProfileType } from '../entities/company-profile.entity';
|
||||||
|
|
||||||
export class CreateCompanyProfileDto {
|
export class CreateCompanyProfileDto {
|
||||||
@@ -19,4 +19,12 @@ export class CreateCompanyProfileDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(120)
|
@MaxLength(120)
|
||||||
licenceNumber?: string;
|
licenceNumber?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which Ethiopian transit agent the company is. Required for the transit
|
||||||
|
* agent and freight forwarder roles unless the company is already linked.
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
transitAgentId?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,13 @@ export class ResponseCompanyDto {
|
|||||||
email?: string | null;
|
email?: string | null;
|
||||||
website?: string | null;
|
website?: string | null;
|
||||||
attributes?: Record<string, any> | null;
|
attributes?: Record<string, any> | null;
|
||||||
|
/**
|
||||||
|
* The transit-agent roster entry a freight forwarder registered itself as
|
||||||
|
* (`Company.transitAgentId`). The name rides along when the relation was
|
||||||
|
* loaded, so the portal and backoffice can show it without a second lookup.
|
||||||
|
*/
|
||||||
|
transitAgentId: string | null;
|
||||||
|
transitAgent: { id: string; name: string } | null;
|
||||||
profiles?: ResponseExternalProfileDto[];
|
profiles?: ResponseExternalProfileDto[];
|
||||||
companyProfiles?: ResponseCompanyProfileDto[];
|
companyProfiles?: ResponseCompanyProfileDto[];
|
||||||
/**
|
/**
|
||||||
@@ -144,6 +151,10 @@ export class ResponseCompanyDto {
|
|||||||
this.email = company.email;
|
this.email = company.email;
|
||||||
this.website = company.website;
|
this.website = company.website;
|
||||||
this.attributes = company.attributes;
|
this.attributes = company.attributes;
|
||||||
|
this.transitAgentId = company.transitAgentId ?? null;
|
||||||
|
this.transitAgent = company.transitAgent
|
||||||
|
? { id: company.transitAgent.id, name: company.transitAgent.name }
|
||||||
|
: null;
|
||||||
this.profiles = company.profiles?.map((p) => new ResponseExternalProfileDto(p));
|
this.profiles = company.profiles?.map((p) => new ResponseExternalProfileDto(p));
|
||||||
this.companyProfiles = company.companyProfiles?.map(
|
this.companyProfiles = company.companyProfiles?.map(
|
||||||
(p) => new ResponseCompanyProfileDto(p),
|
(p) => new ResponseCompanyProfileDto(p),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
|
IsUUID,
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
import { CompanyNationality, CompanyType } from "../entities/company.entity";
|
import { CompanyNationality, CompanyType } from "../entities/company.entity";
|
||||||
import { ProfileType } from "../entities/company-profile.entity";
|
import { ProfileType } from "../entities/company-profile.entity";
|
||||||
@@ -42,4 +43,14 @@ export class StartOnboardingDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
investorLicence?: boolean;
|
investorLicence?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which Ethiopian transit agent this company is. Required whenever `roles`
|
||||||
|
* includes the freight forwarder — the two are the same business — and
|
||||||
|
* refused for any other agent (foreign, suspended, or unknown). Ignored when
|
||||||
|
* the forwarder role is not selected.
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
transitAgentId?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ export enum ProfileType {
|
|||||||
freightForwarder = "freight_forwarder",
|
freightForwarder = "freight_forwarder",
|
||||||
djFreightForwarder = "dj_freight_forwarder",
|
djFreightForwarder = "dj_freight_forwarder",
|
||||||
transporter = "transporter",
|
transporter = "transporter",
|
||||||
|
/**
|
||||||
|
* An Ethiopian transit agent registering on the portal as itself — the
|
||||||
|
* business customers pick to clear customs on a booking. Holds no trade
|
||||||
|
* licence or eTrade business here: its identity is the roster entry
|
||||||
|
* (`Company.transitAgentId`), and a company with ONLY this role finishes
|
||||||
|
* onboarding right after picking it.
|
||||||
|
*/
|
||||||
|
transitAgent = "transit_agent",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ProfileStatus {
|
export enum ProfileStatus {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { BaseEntity } from "@edr/api-common";
|
import { BaseEntity } from "@edr/api-common";
|
||||||
import { Column, Entity, Index, OneToMany } from "typeorm";
|
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from "typeorm";
|
||||||
|
import { TransitAgent } from "../../transit-agents/entities/transit-agent.entity";
|
||||||
import { ExternalProfile } from "./external-profile.entity";
|
import { ExternalProfile } from "./external-profile.entity";
|
||||||
import { CompanyProfile } from "./company-profile.entity";
|
import { CompanyProfile } from "./company-profile.entity";
|
||||||
|
|
||||||
@@ -235,6 +236,22 @@ export class Company extends BaseEntity {
|
|||||||
@Column({ name: "etrade_phone", type: "varchar", length: 20, nullable: true })
|
@Column({ name: "etrade_phone", type: "varchar", length: 20, nullable: true })
|
||||||
etradePhone?: string | null;
|
etradePhone?: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The transit-agent roster entry this company IS, when it holds the
|
||||||
|
* freight-forwarder role. An Ethiopian transit agent and a freight forwarder
|
||||||
|
* are one business seen from two sides — the roster GL assigns officers
|
||||||
|
* from, and the customer signing contracts on other companies' behalf — and
|
||||||
|
* this is what ties the two rows together. Required at onboarding for a
|
||||||
|
* forwarder; null for every importer/exporter and for forwarders linked
|
||||||
|
* before the column existed.
|
||||||
|
*/
|
||||||
|
@Column({ name: "transit_agent_id", type: "uuid", nullable: true })
|
||||||
|
transitAgentId?: string | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => TransitAgent, { nullable: true })
|
||||||
|
@JoinColumn({ name: "transit_agent_id" })
|
||||||
|
transitAgent?: TransitAgent | null;
|
||||||
|
|
||||||
@OneToMany(() => ExternalProfile, (profile) => profile.company)
|
@OneToMany(() => ExternalProfile, (profile) => profile.company)
|
||||||
profiles?: ExternalProfile[];
|
profiles?: ExternalProfile[];
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type { Freight } from '@edr/types';
|
import type { Freight } from '@edr/types';
|
||||||
|
|
||||||
|
import { ManualPaymentSettingsService } from '../payment-settings/manual-payment-settings.service';
|
||||||
import { YardScopeService } from '../rule-engine/services/yard-scope.service';
|
import { YardScopeService } from '../rule-engine/services/yard-scope.service';
|
||||||
import { BookingRequestRepository } from './booking-request.repository';
|
import { BookingRequestRepository } from './booking-request.repository';
|
||||||
import { ContractsService } from './contracts.service';
|
import { ContractsService } from './contracts.service';
|
||||||
@@ -30,6 +31,7 @@ export class BookingRequestService {
|
|||||||
private readonly contractBookingService: ContractBookingService,
|
private readonly contractBookingService: ContractBookingService,
|
||||||
private readonly notifier: ContractNotifierService,
|
private readonly notifier: ContractNotifierService,
|
||||||
private readonly yardScope: YardScopeService,
|
private readonly yardScope: YardScopeService,
|
||||||
|
private readonly paymentSettings: ManualPaymentSettingsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -101,6 +103,17 @@ export class BookingRequestService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The currency picker hides a switched-off currency, but a stale tab must
|
||||||
|
// not be able to raise a shipment nobody can pay for.
|
||||||
|
if (
|
||||||
|
dto.paymentCurrency &&
|
||||||
|
!(await this.paymentSettings.isCurrencyOffered(dto.paymentCurrency))
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`${dto.paymentCurrency.toUpperCase()} is not accepted as a billing currency right now. Pick another currency.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await this.contractBookingService.assertRequestWithinCapacity(contract, {
|
await this.contractBookingService.assertRequestWithinCapacity(contract, {
|
||||||
containers: dto.containers,
|
containers: dto.containers,
|
||||||
bulk: dto.bulk,
|
bulk: dto.bulk,
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ describe('ContractBookingService — quantity-cap completion', () => {
|
|||||||
{} as never, // bookingBatchService
|
{} as never, // bookingBatchService
|
||||||
{} as never, // bookingTransitionService
|
{} as never, // bookingTransitionService
|
||||||
{} as never, // consolidationApprovalService
|
{} as never, // consolidationApprovalService
|
||||||
|
{} as never, // transitAgentsRepository
|
||||||
|
{} as never, // transitAssignmentsService
|
||||||
);
|
);
|
||||||
return { service, contractsRepository };
|
return { service, contractsRepository };
|
||||||
}
|
}
|
||||||
@@ -158,7 +160,9 @@ describe('ContractBookingService — quantity-cap completion', () => {
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never, // consolidationApprovalService
|
{} as never, // consolidationApprovalService
|
||||||
);
|
{} as never, // transitAgentsRepository
|
||||||
|
{} as never, // transitAssignmentsService
|
||||||
|
);
|
||||||
return { service, contractsRepository };
|
return { service, contractsRepository };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
|
|||||||
{} as never, // bookingBatchService
|
{} as never, // bookingBatchService
|
||||||
{} as never, // bookingTransitionService
|
{} as never, // bookingTransitionService
|
||||||
{} as never, // consolidationApprovalService
|
{} as never, // consolidationApprovalService
|
||||||
|
{} as never, // transitAgentsRepository
|
||||||
|
{} as never, // transitAssignmentsService
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
service,
|
service,
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ describe('ContractBookingService — customs booking gate', () => {
|
|||||||
{} as never, // bookingBatchService
|
{} as never, // bookingBatchService
|
||||||
{} as never, // bookingTransitionService
|
{} as never, // bookingTransitionService
|
||||||
{} as never, // consolidationApprovalService
|
{} as never, // consolidationApprovalService
|
||||||
|
{} as never, // transitAgentsRepository
|
||||||
|
{} as never, // transitAssignmentsService
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ describe('ContractBookingService — manual odd-20ft consolidation', () => {
|
|||||||
// The pairing is parked for approval rather than going straight to
|
// The pairing is parked for approval rather than going straight to
|
||||||
// Operations; the gate itself is covered by its own spec.
|
// Operations; the gate itself is covered by its own spec.
|
||||||
{ requestApproval: jest.fn().mockResolvedValue({ id: 'ap-1' }) } as never,
|
{ requestApproval: jest.fn().mockResolvedValue({ id: 'ap-1' }) } as never,
|
||||||
|
{} as never, // transitAgentsRepository
|
||||||
|
{} as never, // transitAssignmentsService
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository, dataSource };
|
return { service, bookingsRepository, dataSource };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ describe('ContractBookingService — changes-requested resubmit restating cargo'
|
|||||||
{} as never, // bookingBatchService
|
{} as never, // bookingBatchService
|
||||||
{} as never, // bookingTransitionService
|
{} as never, // bookingTransitionService
|
||||||
{} as never, // consolidationApprovalService
|
{} as never, // consolidationApprovalService
|
||||||
|
{} as never, // transitAgentsRepository
|
||||||
|
{} as never, // transitAssignmentsService
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository, invoiceService };
|
return { service, bookingsRepository, invoiceService };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ import { BookingsRepository } from '../bookings/bookings.repository';
|
|||||||
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
||||||
import { BookingTransitionService } from '../bookings/booking-transition.service';
|
import { BookingTransitionService } from '../bookings/booking-transition.service';
|
||||||
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
|
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
|
||||||
|
import { TransitAgentCountry } from '../transit-agents/entities/transit-agent.entity';
|
||||||
|
import { TransitAgentsRepository } from '../transit-agents/transit-agents.repository';
|
||||||
|
import { TransitAssignmentsService } from '../transit-assignments/transit-assignments.service';
|
||||||
import { ConsolidationService } from '../bookings/consolidation.service';
|
import { ConsolidationService } from '../bookings/consolidation.service';
|
||||||
import { ConsolidationApprovalService } from '../bookings/consolidation-approval.service';
|
import { ConsolidationApprovalService } from '../bookings/consolidation-approval.service';
|
||||||
import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
|
import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
|
||||||
@@ -136,6 +139,8 @@ export class ContractBookingService {
|
|||||||
private readonly bookingTransitionService: BookingTransitionService,
|
private readonly bookingTransitionService: BookingTransitionService,
|
||||||
@Inject(forwardRef(() => ConsolidationApprovalService))
|
@Inject(forwardRef(() => ConsolidationApprovalService))
|
||||||
private readonly consolidationApprovalService: ConsolidationApprovalService,
|
private readonly consolidationApprovalService: ConsolidationApprovalService,
|
||||||
|
private readonly transitAgentsRepository: TransitAgentsRepository,
|
||||||
|
private readonly transitAssignmentsService: TransitAssignmentsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createUnderContract(
|
async createUnderContract(
|
||||||
@@ -857,34 +862,66 @@ export class ContractBookingService {
|
|||||||
if (!dto.scheduledDate) {
|
if (!dto.scheduledDate) {
|
||||||
throw new BadRequestException('A binding shipment day is required');
|
throw new BadRequestException('A binding shipment day is required');
|
||||||
}
|
}
|
||||||
// Without-customs import/export: the customer's own clearing agent (name,
|
// Without-customs import/export: the customer names who clears customs for
|
||||||
// email, phone) is captured per booking at completion. A resubmit may omit
|
// this booking, one of two ways. Either a registered Ethiopian transit
|
||||||
// the fields and keep what the booking already stored. Customs contracts
|
// agent (a freight forwarder on the platform) — the booking is assigned to
|
||||||
// (GL clears) and intercity (no border) never collect an agent.
|
// it and the forwarder is told — or their own clearing agent typed in
|
||||||
|
// (name, email, phone). A resubmit may omit the typed fields and keep what
|
||||||
|
// the booking already stored. Customs contracts (GL clears) and intercity
|
||||||
|
// (no border) never collect an agent.
|
||||||
|
let assignedTransitAgent: { id: string; name: string } | null = null;
|
||||||
if (
|
if (
|
||||||
!contract.customsClearingEnabled &&
|
!contract.customsClearingEnabled &&
|
||||||
contract.tradeDirection !== 'DOMESTIC'
|
contract.tradeDirection !== 'DOMESTIC'
|
||||||
) {
|
) {
|
||||||
const agentName =
|
if (dto.transitAgentId) {
|
||||||
dto.customsClearingAgent?.trim() || booking.customsClearingAgent || null;
|
const agent = await this.transitAgentsRepository.findById(dto.transitAgentId);
|
||||||
const agentEmail =
|
if (
|
||||||
dto.customsClearingAgentEmail?.trim() ||
|
!agent ||
|
||||||
booking.customsClearingAgentEmail ||
|
!agent.isActive ||
|
||||||
null;
|
agent.country !== TransitAgentCountry.Ethiopia
|
||||||
const agentPhone =
|
) {
|
||||||
dto.customsClearingAgentPhone?.trim() ||
|
throw new BadRequestException(
|
||||||
booking.customsClearingAgentPhone ||
|
'The selected transit agent is not an active Ethiopian transit agent — pick another one or enter your clearing agent details.',
|
||||||
null;
|
);
|
||||||
if (!agentName || !agentEmail || !agentPhone) {
|
}
|
||||||
throw new BadRequestException(
|
// The forwarder company's own contact goes on the booking, so the
|
||||||
'Customs clearing agent name, email and phone are required to complete this booking.',
|
// customer sees who to reach; an Ethiopian agent row carries none.
|
||||||
);
|
const [forwarder]: Array<{ email: string | null; phone: string | null }> =
|
||||||
|
await this.dataSource.query(
|
||||||
|
`SELECT email, phone FROM freight.companies
|
||||||
|
WHERE transit_agent_id = $1 AND deleted_at IS NULL
|
||||||
|
LIMIT 1`,
|
||||||
|
[agent.id],
|
||||||
|
);
|
||||||
|
await this.bookingsRepository.update(booking.id, {
|
||||||
|
customsClearingAgent: agent.name,
|
||||||
|
customsClearingAgentEmail: forwarder?.email ?? null,
|
||||||
|
customsClearingAgentPhone: forwarder?.phone ?? null,
|
||||||
|
} as never);
|
||||||
|
assignedTransitAgent = { id: agent.id, name: agent.name };
|
||||||
|
} else {
|
||||||
|
const agentName =
|
||||||
|
dto.customsClearingAgent?.trim() || booking.customsClearingAgent || null;
|
||||||
|
const agentEmail =
|
||||||
|
dto.customsClearingAgentEmail?.trim() ||
|
||||||
|
booking.customsClearingAgentEmail ||
|
||||||
|
null;
|
||||||
|
const agentPhone =
|
||||||
|
dto.customsClearingAgentPhone?.trim() ||
|
||||||
|
booking.customsClearingAgentPhone ||
|
||||||
|
null;
|
||||||
|
if (!agentName || !agentEmail || !agentPhone) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Customs clearing agent name, email and phone are required to complete this booking — or pick a registered transit agent.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.bookingsRepository.update(booking.id, {
|
||||||
|
customsClearingAgent: agentName,
|
||||||
|
customsClearingAgentEmail: agentEmail,
|
||||||
|
customsClearingAgentPhone: agentPhone,
|
||||||
|
} as never);
|
||||||
}
|
}
|
||||||
await this.bookingsRepository.update(booking.id, {
|
|
||||||
customsClearingAgent: agentName,
|
|
||||||
customsClearingAgentEmail: agentEmail,
|
|
||||||
customsClearingAgentPhone: agentPhone,
|
|
||||||
} as never);
|
|
||||||
}
|
}
|
||||||
// No expiry gate here on purpose: this booking was already initiated
|
// No expiry gate here on purpose: this booking was already initiated
|
||||||
// before the contract lapsed (createUnderContract/initiateUnderContract
|
// before the contract lapsed (createUnderContract/initiateUnderContract
|
||||||
@@ -1092,6 +1129,18 @@ export class ContractBookingService {
|
|||||||
dto.scheduledDate,
|
dto.scheduledDate,
|
||||||
dto.trainScheduleId ?? null,
|
dto.trainScheduleId ?? null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The forwarder's work list and its notice come AFTER the booking is
|
||||||
|
// committed: a customer must never be told a forwarder has the job when
|
||||||
|
// the completion itself was refused a line above.
|
||||||
|
if (assignedTransitAgent) {
|
||||||
|
await this.transitAssignmentsService.ensureAssignment(
|
||||||
|
booking.id,
|
||||||
|
assignedTransitAgent.id,
|
||||||
|
(actorPermissions as { id?: string } | undefined)?.id,
|
||||||
|
);
|
||||||
|
void this.bookingNotifier.transitAgentAssigned(completed, assignedTransitAgent);
|
||||||
|
}
|
||||||
return { booking: completed, warnings };
|
return { booking: completed, warnings };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { ContractTransitionService } from './contract-transition.service';
|
||||||
|
import type { Contract } from './entities/contract.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A lapsed contract comes back only on the customer's say-so: they ask once,
|
||||||
|
* staff add days, and the contract lands back where it was before it expired.
|
||||||
|
* Those three rules are the feature.
|
||||||
|
*/
|
||||||
|
describe('ContractTransitionService — extension request / extend', () => {
|
||||||
|
const contract = (over: Partial<Contract> = {}): Contract =>
|
||||||
|
({
|
||||||
|
id: 'c-1',
|
||||||
|
reference: 'CTR-2026-00042',
|
||||||
|
companyId: 'co-1',
|
||||||
|
contractKind: 'GENERAL',
|
||||||
|
status: 'EXPIRED',
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
contractValidUntil: new Date('2026-01-31T21:00:00Z'),
|
||||||
|
statusBeforeExpiry: 'CONTRACT_ACTIVE',
|
||||||
|
extensionRequestedAt: null,
|
||||||
|
...over,
|
||||||
|
}) as Contract;
|
||||||
|
|
||||||
|
let current: Contract;
|
||||||
|
let repo: { update: jest.Mock; createReviewNote: jest.Mock };
|
||||||
|
let notifier: { extended: jest.Mock; extensionRequestedToStaff: jest.Mock };
|
||||||
|
let service: ContractTransitionService;
|
||||||
|
|
||||||
|
/** A staff user holding the extend key — authorization is tested elsewhere. */
|
||||||
|
const staff = {
|
||||||
|
permissions: [{ key: 'edr_freight_app:contracts:extend' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
current = contract();
|
||||||
|
repo = {
|
||||||
|
update: jest.fn().mockImplementation((_id: string, patch: object) => {
|
||||||
|
current = { ...current, ...patch } as Contract;
|
||||||
|
return Promise.resolve(current);
|
||||||
|
}),
|
||||||
|
createReviewNote: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
notifier = { extended: jest.fn(), extensionRequestedToStaff: jest.fn() };
|
||||||
|
service = Object.create(
|
||||||
|
ContractTransitionService.prototype,
|
||||||
|
) as ContractTransitionService;
|
||||||
|
Object.assign(service, {
|
||||||
|
contractsRepository: repo,
|
||||||
|
contractsService: { findById: () => Promise.resolve(current) },
|
||||||
|
notifier,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records the customer request and tells the contract desk', async () => {
|
||||||
|
await service.requestExtension('c-1', ' Two more shipments due ', 'user-1');
|
||||||
|
|
||||||
|
expect(repo.createReviewNote).toHaveBeenCalledWith(
|
||||||
|
'c-1',
|
||||||
|
'Two more shipments due',
|
||||||
|
'EXTENSION_REQUESTED',
|
||||||
|
'user-1',
|
||||||
|
'CUSTOMER',
|
||||||
|
);
|
||||||
|
expect(repo.update).toHaveBeenCalledWith('c-1', {
|
||||||
|
extensionRequestedAt: expect.any(Date),
|
||||||
|
});
|
||||||
|
expect(notifier.extensionRequestedToStaff).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ id: 'c-1' }),
|
||||||
|
'Two more shipments due',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a request on a contract that has not expired', async () => {
|
||||||
|
current = contract({ status: 'CONTRACT_ACTIVE' });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.requestExtension('c-1', undefined, 'user-1'),
|
||||||
|
).rejects.toThrow(/CONTRACT_ACTIVE/);
|
||||||
|
expect(repo.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows one pending request at a time', async () => {
|
||||||
|
current = contract({ extensionRequestedAt: new Date() });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.requestExtension('c-1', undefined, 'user-1'),
|
||||||
|
).rejects.toThrow(/already awaiting/);
|
||||||
|
expect(repo.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to extend before the customer has asked', async () => {
|
||||||
|
await expect(
|
||||||
|
service.extend('c-1', 30, undefined, 'staff-1', staff as never),
|
||||||
|
).rejects.toThrow(/not requested/);
|
||||||
|
expect(repo.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds days from today on a lapsed contract and restores the pre-expiry status', async () => {
|
||||||
|
current = contract({
|
||||||
|
extensionRequestedAt: new Date(),
|
||||||
|
statusBeforeExpiry: 'ACTIVE_SHIPMENT_IN_PROGRESS',
|
||||||
|
});
|
||||||
|
const before = Date.now();
|
||||||
|
|
||||||
|
await service.extend('c-1', 10, 'Approved by desk', 'staff-1', staff as never);
|
||||||
|
|
||||||
|
const patch = repo.update.mock.calls[0][1] as {
|
||||||
|
status: string;
|
||||||
|
statusBeforeExpiry: null;
|
||||||
|
extensionRequestedAt: null;
|
||||||
|
contractValidUntil: Date;
|
||||||
|
};
|
||||||
|
expect(patch.status).toBe('ACTIVE_SHIPMENT_IN_PROGRESS');
|
||||||
|
expect(patch.statusBeforeExpiry).toBeNull();
|
||||||
|
expect(patch.extensionRequestedAt).toBeNull();
|
||||||
|
// The old end (Jan 2026) is in the past, so the ten days count from now.
|
||||||
|
const tenDays = 10 * 86_400_000;
|
||||||
|
expect(patch.contractValidUntil.getTime()).toBeGreaterThanOrEqual(before + tenDays - 1000);
|
||||||
|
expect(patch.contractValidUntil.getTime()).toBeLessThanOrEqual(Date.now() + tenDays + 3_600_000);
|
||||||
|
expect(repo.createReviewNote).toHaveBeenCalledWith(
|
||||||
|
'c-1',
|
||||||
|
expect.stringMatching(/^Extended by 10 days to .*\. Approved by desk$/),
|
||||||
|
'EXTENDED',
|
||||||
|
'staff-1',
|
||||||
|
'STAFF',
|
||||||
|
);
|
||||||
|
expect(notifier.extended).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ id: 'c-1' }),
|
||||||
|
10,
|
||||||
|
patch.contractValidUntil,
|
||||||
|
'Approved by desk',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extends from the current end date when it is still in the future', async () => {
|
||||||
|
const future = new Date(Date.now() + 5 * 86_400_000);
|
||||||
|
current = contract({ extensionRequestedAt: new Date(), contractValidUntil: future });
|
||||||
|
|
||||||
|
await service.extend('c-1', 7, undefined, 'staff-1', staff as never);
|
||||||
|
|
||||||
|
const patch = repo.update.mock.calls[0][1] as { contractValidUntil: Date };
|
||||||
|
const expected = new Date(future);
|
||||||
|
expected.setDate(expected.getDate() + 7);
|
||||||
|
expect(patch.contractValidUntil.getTime()).toBe(expected.getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the resting status for rows expired before it was tracked', async () => {
|
||||||
|
current = contract({
|
||||||
|
extensionRequestedAt: new Date(),
|
||||||
|
statusBeforeExpiry: null,
|
||||||
|
contractKind: 'ONE_TIME',
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.extend('c-1', 1, undefined, 'staff-1', staff as never);
|
||||||
|
|
||||||
|
expect(repo.update).toHaveBeenCalledWith(
|
||||||
|
'c-1',
|
||||||
|
expect.objectContaining({ status: 'FULLY_EXECUTED' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to extend without the extend permission', async () => {
|
||||||
|
current = contract({ extensionRequestedAt: new Date() });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.extend('c-1', 30, undefined, 'staff-1', { permissions: [] } as never),
|
||||||
|
).rejects.toThrow();
|
||||||
|
expect(repo.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -188,6 +188,26 @@ export class ContractNotifierService {
|
|||||||
this.inApp(c, 'Contract cancelled', msg);
|
this.inApp(c, 'Contract cancelled', msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Staff extended the validity of a lapsed contract — it is live again. */
|
||||||
|
extended(c: Contract, days: number, validUntil: Date, note?: string | null): void {
|
||||||
|
const msg =
|
||||||
|
`Your contract ${c.reference} has been extended by ${days} day${days === 1 ? '' : 's'} ` +
|
||||||
|
`and is now valid until ${validUntil.toLocaleDateString('en-GB')}. ` +
|
||||||
|
`You can book shipments under it again.${note ? ` Note: ${note}` : ''}`;
|
||||||
|
void this.notifyContact(c, msg, 'EXTENDED');
|
||||||
|
this.inApp(c, 'Contract extended', msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Customer asked for their expired contract to be extended — staff-side record. */
|
||||||
|
extensionRequestedToStaff(c: Contract, note: string | null): void {
|
||||||
|
this.inAppStaff(
|
||||||
|
c,
|
||||||
|
'Contract extension requested',
|
||||||
|
`The customer asked to extend expired contract ${this.ref(c)}.` +
|
||||||
|
`${note ? ` Reason: ${note}` : ''} Open the contract to add validity days.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Customer cancelled their own contract — staff-side record. */
|
/** Customer cancelled their own contract — staff-side record. */
|
||||||
cancelledByCustomer(c: Contract, reason: string): void {
|
cancelledByCustomer(c: Contract, reason: string): void {
|
||||||
this.inAppStaff(
|
this.inAppStaff(
|
||||||
|
|||||||
@@ -215,9 +215,58 @@ export class ContractPricingService {
|
|||||||
|
|
||||||
// Conditional surcharges — shown only when the contract toggles them on AND
|
// Conditional surcharges — shown only when the contract toggles them on AND
|
||||||
// the rate has a non-zero value (a 0 rate means "no surcharge").
|
// the rate has a non-zero value (a 0 rate means "no surcharge").
|
||||||
if (contract.isHazardous) {
|
if (contract.isHazardous && contract.freightType === 'CONTAINER') {
|
||||||
|
// Container hazard is sold per direction + route + container type, like
|
||||||
|
// the empty-return service — one display line per contract size that has
|
||||||
|
// a configured rate (size-specific wins over the lane's catch-all). A
|
||||||
|
// size with no rate shows nothing here and hard-blocks at booking time.
|
||||||
|
// ponytail: bookings bill the live route rate, not a frozen snapshot.
|
||||||
|
const onLeg = route
|
||||||
|
? liveRates.filter(
|
||||||
|
(r) =>
|
||||||
|
r.rateType === 'HAZARD_SURCHARGE' &&
|
||||||
|
r.rateUnit === 'PER_CONTAINER' &&
|
||||||
|
r.currency === 'USD' &&
|
||||||
|
r.tradeDirection === contract.tradeDirection &&
|
||||||
|
r.originYardId === route.originYardId &&
|
||||||
|
r.destinationYardId === route.destinationYardId,
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
if (onLeg.length > 0) {
|
||||||
|
const sizes = (contract.cargoScope ?? [])
|
||||||
|
.map((c) => c.containerSize)
|
||||||
|
.filter((s): s is string => !!s);
|
||||||
|
const { items: containerTypes } = await this.containerTypesService.findAll({
|
||||||
|
isActive: true,
|
||||||
|
pageSize: 100,
|
||||||
|
});
|
||||||
|
for (const size of sizes) {
|
||||||
|
const sizeFt = size === '40ft' ? 40 : 20;
|
||||||
|
const matchedIds = new Set(
|
||||||
|
containerTypes.filter((ct) => ct.sizeFt === sizeFt).map((ct) => ct.id),
|
||||||
|
);
|
||||||
|
const rate =
|
||||||
|
onLeg.find((r) => r.containerTypeId && matchedIds.has(r.containerTypeId)) ??
|
||||||
|
onLeg.find((r) => !r.containerTypeId);
|
||||||
|
if (!rate || Number(rate.rateValue) <= 0) continue;
|
||||||
|
lineItems.push({
|
||||||
|
code: 'HAZARD_SURCHARGE',
|
||||||
|
label: `Hazardous surcharge (${size})`,
|
||||||
|
unit: toContractUnit(rate.rateUnit),
|
||||||
|
unitPrice: convert(Number(rate.rateValue)),
|
||||||
|
containerSize: size,
|
||||||
|
conditionalOn: 'is_hazardous',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (contract.isHazardous) {
|
||||||
|
// Bulk hazard is the global per-ton rate; the per-container rows belong
|
||||||
|
// to container lanes and must not price a bulk contract.
|
||||||
const hazard = liveRates.find(
|
const hazard = liveRates.find(
|
||||||
(r) => r.rateType === 'HAZARD_SURCHARGE' && r.currency === 'USD',
|
(r) =>
|
||||||
|
r.rateType === 'HAZARD_SURCHARGE' &&
|
||||||
|
r.rateUnit !== 'PER_CONTAINER' &&
|
||||||
|
r.currency === 'USD',
|
||||||
);
|
);
|
||||||
if (hazard && Number(hazard.rateValue) > 0) {
|
if (hazard && Number(hazard.rateValue) > 0) {
|
||||||
lineItems.push({
|
lineItems.push({
|
||||||
|
|||||||
@@ -1502,6 +1502,105 @@ export class ContractTransitionService {
|
|||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer asks EDR to extend the validity of their EXPIRED contract. Only
|
||||||
|
* stamps the request and tells the contract desk — nothing on the contract
|
||||||
|
* moves until staff {@link extend} it. One pending request at a time.
|
||||||
|
*/
|
||||||
|
async requestExtension(
|
||||||
|
contractId: string,
|
||||||
|
note: string | undefined,
|
||||||
|
userId?: string,
|
||||||
|
): Promise<Contract> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
assertContractStatus(contract, ['EXPIRED']);
|
||||||
|
if (contract.extensionRequestedAt) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'An extension request for this contract is already awaiting EDR.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reason = note?.trim() || null;
|
||||||
|
await this.contractsRepository.createReviewNote(
|
||||||
|
contractId,
|
||||||
|
reason ?? 'Customer requested a validity extension.',
|
||||||
|
'EXTENSION_REQUESTED',
|
||||||
|
userId,
|
||||||
|
'CUSTOMER',
|
||||||
|
);
|
||||||
|
await this.contractsRepository.update(contractId, {
|
||||||
|
extensionRequestedAt: new Date(),
|
||||||
|
} as never);
|
||||||
|
const updated = await this.contractsService.findById(contractId);
|
||||||
|
this.notifier.extensionRequestedToStaff(updated, reason);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Staff add validity days to an EXPIRED contract the customer asked to
|
||||||
|
* extend, and the contract returns to the status it held before it lapsed
|
||||||
|
* (stashed in statusBeforeExpiry by both expiry paths). Days count from
|
||||||
|
* today once the contract has lapsed — adding to a date already in the past
|
||||||
|
* could leave it expired — and from the current end date otherwise.
|
||||||
|
*
|
||||||
|
* Gated on the customer's request: the portal button is the only way to set
|
||||||
|
* extensionRequestedAt, so staff cannot silently revive a contract nobody
|
||||||
|
* asked about.
|
||||||
|
*/
|
||||||
|
async extend(
|
||||||
|
contractId: string,
|
||||||
|
days: number,
|
||||||
|
note: string | undefined,
|
||||||
|
actorId: string,
|
||||||
|
user?: TCurrentUser | null,
|
||||||
|
): Promise<Contract> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
assertFreightPermission(user, FREIGHT_PERMS.contracts.extend);
|
||||||
|
assertContractStatus(contract, ['EXPIRED']);
|
||||||
|
if (!contract.extensionRequestedAt) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'The customer has not requested an extension for this contract. A contract is only extended on customer request.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(days) || days < 1) {
|
||||||
|
throw new BadRequestException('An extension must add at least one day.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const currentEnd = contract.contractValidUntil
|
||||||
|
? new Date(contract.contractValidUntil)
|
||||||
|
: null;
|
||||||
|
const base = currentEnd && currentEnd.getTime() > now.getTime() ? currentEnd : now;
|
||||||
|
const validUntil = new Date(base);
|
||||||
|
validUntil.setDate(validUntil.getDate() + days);
|
||||||
|
|
||||||
|
// Rows that lapsed before statusBeforeExpiry existed have nothing to
|
||||||
|
// restore — fall back to the kind's post-signature resting status, the
|
||||||
|
// same default resume() uses.
|
||||||
|
const restored =
|
||||||
|
contract.statusBeforeExpiry ??
|
||||||
|
(contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED');
|
||||||
|
|
||||||
|
const trimmed = note?.trim() || null;
|
||||||
|
await this.contractsRepository.createReviewNote(
|
||||||
|
contractId,
|
||||||
|
`Extended by ${days} day${days === 1 ? '' : 's'} to ${validUntil.toLocaleDateString('en-GB')}.` +
|
||||||
|
(trimmed ? ` ${trimmed}` : ''),
|
||||||
|
'EXTENDED',
|
||||||
|
actorId,
|
||||||
|
'STAFF',
|
||||||
|
);
|
||||||
|
await this.contractsRepository.update(contractId, {
|
||||||
|
status: restored,
|
||||||
|
statusBeforeExpiry: null,
|
||||||
|
extensionRequestedAt: null,
|
||||||
|
contractValidUntil: validUntil,
|
||||||
|
} as never);
|
||||||
|
const updated = await this.contractsService.findById(contractId);
|
||||||
|
this.notifier.extended(updated, days, validUntil, trimmed);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
async renew(contractId: string, userId?: string): Promise<Contract> {
|
async renew(contractId: string, userId?: string): Promise<Contract> {
|
||||||
const source = await this.contractsService.findById(contractId);
|
const source = await this.contractsService.findById(contractId);
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ import {
|
|||||||
import { SignContractDto } from './dto/sign-contract.dto';
|
import { SignContractDto } from './dto/sign-contract.dto';
|
||||||
import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto';
|
import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto';
|
||||||
import { RenewContractDto } from './dto/renew-contract.dto';
|
import { RenewContractDto } from './dto/renew-contract.dto';
|
||||||
|
import {
|
||||||
|
ExtendContractDto,
|
||||||
|
RequestContractExtensionDto,
|
||||||
|
} from './dto/extend-contract.dto';
|
||||||
import {
|
import {
|
||||||
CompleteConsolidatedPairDto,
|
CompleteConsolidatedPairDto,
|
||||||
CreateBookingUnderContractDto,
|
CreateBookingUnderContractDto,
|
||||||
@@ -528,6 +532,52 @@ export class ContractsController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':id/extension-request')
|
||||||
|
@PortalCustomer()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Customer asks EDR to extend the validity of their expired contract',
|
||||||
|
})
|
||||||
|
async requestExtension(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: RequestContractExtensionDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
// Same ownership rule as cancel/renew: staff with bookings.view/contracts.view
|
||||||
|
// pass through, everyone else must own the contract's company.
|
||||||
|
const contract = await this.contractsService.findById(id);
|
||||||
|
if (
|
||||||
|
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
|
||||||
|
!hasFreightPermission(user, FREIGHT_PERMS.contracts.view)
|
||||||
|
) {
|
||||||
|
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
||||||
|
}
|
||||||
|
return this.transitionService.requestExtension(
|
||||||
|
id,
|
||||||
|
dto.note,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/extend')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.extend)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'Staff extend an expired contract the customer asked to extend — it returns to its pre-expiry status',
|
||||||
|
})
|
||||||
|
extend(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: ExtendContractDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
return this.transitionService.extend(
|
||||||
|
id,
|
||||||
|
dto.days,
|
||||||
|
dto.note,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':id/cancel')
|
@Post(':id/cancel')
|
||||||
@PortalCustomer()
|
@PortalCustomer()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
|
|||||||
@@ -135,7 +135,9 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
const result = await this.repository
|
const result = await this.repository
|
||||||
.createQueryBuilder()
|
.createQueryBuilder()
|
||||||
.update(Contract)
|
.update(Contract)
|
||||||
.set({ status: 'EXPIRED' })
|
// SET reads the pre-update row, so status_before_expiry gets the status
|
||||||
|
// being replaced — the value ContractTransitionService.extend restores.
|
||||||
|
.set({ status: 'EXPIRED', statusBeforeExpiry: () => 'status' })
|
||||||
.where('deleted_at IS NULL')
|
.where('deleted_at IS NULL')
|
||||||
.andWhere('status NOT IN (:...terminal)', { terminal: TERMINAL_CONTRACT_STATUSES })
|
.andWhere('status NOT IN (:...terminal)', { terminal: TERMINAL_CONTRACT_STATUSES })
|
||||||
.andWhere('contract_valid_until IS NOT NULL AND contract_valid_until < :now', {
|
.andWhere('contract_valid_until IS NOT NULL AND contract_valid_until < :now', {
|
||||||
@@ -155,7 +157,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
const result = await this.repository
|
const result = await this.repository
|
||||||
.createQueryBuilder()
|
.createQueryBuilder()
|
||||||
.update(Contract)
|
.update(Contract)
|
||||||
.set({ status: 'EXPIRED' })
|
.set({ status: 'EXPIRED', statusBeforeExpiry: () => 'status' })
|
||||||
.where('id = :id', { id })
|
.where('id = :id', { id })
|
||||||
.andWhere('deleted_at IS NULL')
|
.andWhere('deleted_at IS NULL')
|
||||||
.andWhere('status NOT IN (:...terminal)', { terminal: TERMINAL_CONTRACT_STATUSES })
|
.andWhere('status NOT IN (:...terminal)', { terminal: TERMINAL_CONTRACT_STATUSES })
|
||||||
|
|||||||
@@ -953,6 +953,20 @@ export class ContractsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Why the customer wants more time — shown on the staff detail page while
|
||||||
|
// the extension request is pending.
|
||||||
|
if (contract.status === 'EXPIRED' && contract.extensionRequestedAt) {
|
||||||
|
try {
|
||||||
|
const note = await this.contractsRepository.findLatestReviewNote(
|
||||||
|
contract.id,
|
||||||
|
'EXTENSION_REQUESTED',
|
||||||
|
);
|
||||||
|
contract.latestExtensionRequestNote = note?.body ?? null;
|
||||||
|
} catch {
|
||||||
|
contract.latestExtensionRequestNote = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Lets the portal disable "Cancel contract" instead of letting the customer
|
// Lets the portal disable "Cancel contract" instead of letting the customer
|
||||||
// click it and read a 400. The API re-checks on cancel regardless.
|
// click it and read a 400. The API re-checks on cancel regardless.
|
||||||
contract.activeBookingCount =
|
contract.activeBookingCount =
|
||||||
|
|||||||
@@ -256,6 +256,17 @@ export class CreateBookingUnderContractDto {
|
|||||||
@MaxLength(50)
|
@MaxLength(50)
|
||||||
customsClearingAgentPhone?: string;
|
customsClearingAgentPhone?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
format: 'uuid',
|
||||||
|
description:
|
||||||
|
'Instead of typing a clearing agent: a registered Ethiopian transit agent (freight ' +
|
||||||
|
'forwarder). The booking is assigned to it and the forwarder is notified; the typed ' +
|
||||||
|
'agent fields are ignored when this is set.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
transitAgentId?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
|
||||||
|
|
||||||
|
/** Customer asks EDR to extend the validity of their EXPIRED contract. */
|
||||||
|
export class RequestContractExtensionDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Why the customer needs the contract extended' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2000)
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Staff extend an EXPIRED contract that the customer asked to extend. */
|
||||||
|
export class ExtendContractDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description:
|
||||||
|
'Days to add. Counted from today when the contract has already lapsed, otherwise from its current end date.',
|
||||||
|
minimum: 1,
|
||||||
|
maximum: 3650,
|
||||||
|
})
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(3650)
|
||||||
|
days!: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Optional note recorded with the extension and shown to the customer' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2000)
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
@@ -19,6 +19,10 @@ export const CONTRACT_REVIEW_NOTE_TYPES = [
|
|||||||
'SUSPENSION_LIFTED',
|
'SUSPENSION_LIFTED',
|
||||||
/** Customer cancelled their own contract; body is their reason. */
|
/** Customer cancelled their own contract; body is their reason. */
|
||||||
'CANCELLATION',
|
'CANCELLATION',
|
||||||
|
/** Customer asked for an EXPIRED contract's validity to be extended. */
|
||||||
|
'EXTENSION_REQUESTED',
|
||||||
|
/** Staff extended the validity; body records the days added and the new end. */
|
||||||
|
'EXTENDED',
|
||||||
] as const;
|
] as const;
|
||||||
export type ContractReviewNoteType =
|
export type ContractReviewNoteType =
|
||||||
(typeof CONTRACT_REVIEW_NOTE_TYPES)[number];
|
(typeof CONTRACT_REVIEW_NOTE_TYPES)[number];
|
||||||
|
|||||||
@@ -239,6 +239,23 @@ export class Contract extends BaseEntity {
|
|||||||
@Column({ name: 'status_before_suspension', type: 'varchar', length: 40, nullable: true })
|
@Column({ name: 'status_before_suspension', type: 'varchar', length: 40, nullable: true })
|
||||||
statusBeforeSuspension?: string | null;
|
statusBeforeSuspension?: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status the contract held when it lapsed to EXPIRED (stamped by both the
|
||||||
|
* nightly sweep and the lazy flip on read), restored when staff extend the
|
||||||
|
* validity. Null on rows that expired before the column existed — extension
|
||||||
|
* then falls back to the kind's post-signature resting status.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'status_before_expiry', type: 'varchar', length: 40, nullable: true })
|
||||||
|
statusBeforeExpiry?: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the customer asked for the validity of this EXPIRED contract to be
|
||||||
|
* extended. Set by the portal request, cleared when staff extend. Staff
|
||||||
|
* cannot extend a contract the customer has not asked about.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'extension_requested_at', type: 'timestamptz', nullable: true })
|
||||||
|
extensionRequestedAt?: Date | null;
|
||||||
|
|
||||||
@Column({ name: 'clearance_status', type: 'varchar', length: 40, default: 'NOT_APPLICABLE' })
|
@Column({ name: 'clearance_status', type: 'varchar', length: 40, default: 'NOT_APPLICABLE' })
|
||||||
clearanceStatus!: string;
|
clearanceStatus!: string;
|
||||||
|
|
||||||
@@ -373,6 +390,13 @@ export class Contract extends BaseEntity {
|
|||||||
*/
|
*/
|
||||||
latestSuspensionNote?: string | null;
|
latestSuspensionNote?: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body of the most recent EXTENSION_REQUESTED review note, attached by
|
||||||
|
* ContractsService.findById while an extension request is pending so staff
|
||||||
|
* see why the customer wants the contract extended. Not a column.
|
||||||
|
*/
|
||||||
|
latestExtensionRequestNote?: string | null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Count of this contract's non-terminal bookings, attached by
|
* Count of this contract's non-terminal bookings, attached by
|
||||||
* ContractsService.findById. The portal disables customer cancellation while
|
* ContractsService.findById. The portal disables customer cancellation while
|
||||||
|
|||||||
@@ -54,6 +54,33 @@ export interface EmptyReturnQuote {
|
|||||||
unavailableReason: string | null;
|
unavailableReason: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A queue row: the request plus the booking and payer names staff read it by. */
|
||||||
|
export type EmptyReturnRequestRow = Pick<
|
||||||
|
EmptyReturnRequest,
|
||||||
|
| 'id'
|
||||||
|
| 'bookingId'
|
||||||
|
| 'companyId'
|
||||||
|
| 'status'
|
||||||
|
| 'containerNumbers'
|
||||||
|
| 'containerCount'
|
||||||
|
| 'quotedUnitAmount'
|
||||||
|
| 'quotedTotalAmount'
|
||||||
|
| 'currency'
|
||||||
|
| 'invoiceId'
|
||||||
|
| 'paidAt'
|
||||||
|
| 'requestedReturnDate'
|
||||||
|
| 'truckPlateNumber'
|
||||||
|
| 'truckDriverName'
|
||||||
|
| 'truckType'
|
||||||
|
| 'scheduledAt'
|
||||||
|
| 'submittedByUserId'
|
||||||
|
| 'submittedAt'
|
||||||
|
| 'reviewedByStaffId'
|
||||||
|
| 'reviewedAt'
|
||||||
|
| 'rejectionReason'
|
||||||
|
| 'completedAt'
|
||||||
|
> & { bookingReference: string | null; companyName: string | null };
|
||||||
|
|
||||||
export interface EmptyReturnEligibility {
|
export interface EmptyReturnEligibility {
|
||||||
eligible: boolean;
|
eligible: boolean;
|
||||||
/** Why the customer cannot request one, when `eligible` is false. */
|
/** Why the customer cannot request one, when `eligible` is false. */
|
||||||
@@ -82,13 +109,34 @@ export class EmptyReturnRequestsService {
|
|||||||
async findAll(filter: {
|
async findAll(filter: {
|
||||||
status?: EmptyReturnRequestStatus;
|
status?: EmptyReturnRequestStatus;
|
||||||
bookingId?: string;
|
bookingId?: string;
|
||||||
}): Promise<
|
}): Promise<EmptyReturnRequestRow[]> {
|
||||||
Array<EmptyReturnRequest & { bookingReference: string | null; companyName: string | null }>
|
// Raw SQL bypasses the entity mapping, so every column is aliased to the
|
||||||
> {
|
// property name the clients read — `r.*` would hand them snake_case.
|
||||||
return this.dataSource.query(
|
return this.dataSource.query(
|
||||||
`SELECT r.*,
|
`SELECT r.id,
|
||||||
b.reference AS "bookingReference",
|
r.booking_id AS "bookingId",
|
||||||
c.name AS "companyName"
|
r.company_id AS "companyId",
|
||||||
|
r.status,
|
||||||
|
r.container_numbers AS "containerNumbers",
|
||||||
|
r.container_count AS "containerCount",
|
||||||
|
r.quoted_unit_amount::float8 AS "quotedUnitAmount",
|
||||||
|
r.quoted_total_amount::float8 AS "quotedTotalAmount",
|
||||||
|
r.currency,
|
||||||
|
r.invoice_id AS "invoiceId",
|
||||||
|
r.paid_at AS "paidAt",
|
||||||
|
r.requested_return_date AS "requestedReturnDate",
|
||||||
|
r.truck_plate_number AS "truckPlateNumber",
|
||||||
|
r.truck_driver_name AS "truckDriverName",
|
||||||
|
r.truck_type AS "truckType",
|
||||||
|
r.scheduled_at AS "scheduledAt",
|
||||||
|
r.submitted_by_user_id AS "submittedByUserId",
|
||||||
|
r.submitted_at AS "submittedAt",
|
||||||
|
r.reviewed_by_staff_id AS "reviewedByStaffId",
|
||||||
|
r.reviewed_at AS "reviewedAt",
|
||||||
|
r.rejection_reason AS "rejectionReason",
|
||||||
|
r.completed_at AS "completedAt",
|
||||||
|
b.reference AS "bookingReference",
|
||||||
|
c.name AS "companyName"
|
||||||
FROM freight.empty_return_requests r
|
FROM freight.empty_return_requests r
|
||||||
LEFT JOIN freight.bookings b ON b.id = r.booking_id AND b.deleted_at IS NULL
|
LEFT JOIN freight.bookings b ON b.id = r.booking_id AND b.deleted_at IS NULL
|
||||||
LEFT JOIN freight.companies c ON c.id = r.company_id
|
LEFT JOIN freight.companies c ON c.id = r.company_id
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ export const customersDataset: ExportDataset = {
|
|||||||
{ value: 'exporter', label: 'Exporter' },
|
{ value: 'exporter', label: 'Exporter' },
|
||||||
{ value: 'freight_forwarder', label: 'Freight forwarder' },
|
{ value: 'freight_forwarder', label: 'Freight forwarder' },
|
||||||
{ value: 'dj_freight_forwarder', label: 'DJ freight forwarder' },
|
{ value: 'dj_freight_forwarder', label: 'DJ freight forwarder' },
|
||||||
|
{ value: 'transit_agent', label: 'Transit agent' },
|
||||||
{ value: 'transporter', label: 'Transporter' },
|
{ value: 'transporter', label: 'Transporter' },
|
||||||
] },
|
] },
|
||||||
// The list's Status filter folds the review queues in, and sends these two
|
// The list's Status filter folds the review queues in, and sends these two
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { ManualPaymentSettingsService } from "./manual-payment-settings.service";
|
||||||
|
import type { ManualPaymentSetting } from "./entities/manual-payment-setting.entity";
|
||||||
|
|
||||||
|
/** Single-row repository stub: enough for get/update, nothing more. */
|
||||||
|
const repoWith = (row: Partial<ManualPaymentSetting>) => {
|
||||||
|
const stored = { id: "settings-1", ...row } as ManualPaymentSetting;
|
||||||
|
return {
|
||||||
|
findOne: async () => stored,
|
||||||
|
create: (v: Partial<ManualPaymentSetting>) => v as ManualPaymentSetting,
|
||||||
|
save: async (v: ManualPaymentSetting) => v,
|
||||||
|
update: async (_id: string, patch: Partial<ManualPaymentSetting>) => {
|
||||||
|
Object.assign(stored, patch);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const serviceWith = (row: Partial<ManualPaymentSetting>) =>
|
||||||
|
new ManualPaymentSettingsService(repoWith(row) as never);
|
||||||
|
|
||||||
|
describe("DJF currency switch", () => {
|
||||||
|
it("offers DJF, and accepts it, while the switch is on", async () => {
|
||||||
|
const service = serviceWith({ djfPaymentsEnabled: true });
|
||||||
|
|
||||||
|
await expect(service.offeredCurrencies()).resolves.toEqual([
|
||||||
|
"ETB",
|
||||||
|
"USD",
|
||||||
|
"DJF",
|
||||||
|
]);
|
||||||
|
await expect(service.isCurrencyOffered("DJF")).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops DJF from the offered currencies once switched off", async () => {
|
||||||
|
const service = serviceWith({ djfPaymentsEnabled: false });
|
||||||
|
|
||||||
|
await expect(service.offeredCurrencies()).resolves.toEqual(["ETB", "USD"]);
|
||||||
|
await expect(service.isCurrencyOffered("djf")).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never switches off ETB or USD — only DJF has a currency-level switch", async () => {
|
||||||
|
const service = serviceWith({ djfPaymentsEnabled: false });
|
||||||
|
|
||||||
|
await expect(service.isCurrencyOffered("ETB")).resolves.toBe(true);
|
||||||
|
await expect(service.isCurrencyOffered("USD")).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the manual rail alone when the currency switch flips", async () => {
|
||||||
|
const service = serviceWith({ djfEnabled: true, djfPaymentsEnabled: true });
|
||||||
|
|
||||||
|
const updated = await service.update({ djfPaymentsEnabled: false }, "user-1");
|
||||||
|
|
||||||
|
expect(updated.djfPaymentsEnabled).toBe(false);
|
||||||
|
// Existing DJF invoices stay hand-settleable, so nothing is stranded.
|
||||||
|
expect(updated.djfEnabled).toBe(true);
|
||||||
|
await expect(service.isEnabled("DJF")).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,4 +20,12 @@ export class UpdateManualPaymentSettingDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
djfEnabled?: boolean;
|
djfEnabled?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
"Accept DJF as a payment currency at all — booking forms and online payment",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
djfPaymentsEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,17 @@ export class ManualPaymentSetting extends BaseEntity {
|
|||||||
@Column({ name: "djf_enabled", type: "boolean", default: true })
|
@Column({ name: "djf_enabled", type: "boolean", default: true })
|
||||||
djfEnabled!: boolean;
|
djfEnabled!: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether DJF may be used as a payment currency AT ALL — offered on the
|
||||||
|
* booking/shipment forms and accepted for online payment (Waafi / CAC Bank).
|
||||||
|
*
|
||||||
|
* Wider than `djfEnabled`, which only governs the manual rail. Off leaves
|
||||||
|
* existing DJF invoices settleable by hand (while `djfEnabled` is on), so
|
||||||
|
* switching it off strands nothing — it only stops new DJF business.
|
||||||
|
*/
|
||||||
|
@Column({ name: "djf_payments_enabled", type: "boolean", default: true })
|
||||||
|
djfPaymentsEnabled!: boolean;
|
||||||
|
|
||||||
/** IAM user id of the last operator to change either toggle. */
|
/** IAM user id of the last operator to change either toggle. */
|
||||||
@Column({ name: "updated_by_id", type: "uuid", nullable: true })
|
@Column({ name: "updated_by_id", type: "uuid", nullable: true })
|
||||||
updatedById?: string | null;
|
updatedById?: string | null;
|
||||||
|
|||||||
@@ -30,13 +30,26 @@ export class ManualPaymentSettingsController {
|
|||||||
return this.service.get();
|
return this.service.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which currencies may be picked for new bookings and paid online. Read by
|
||||||
|
* the customer portal's booking forms, so it stays open like the other
|
||||||
|
* form-shaping settings reads (file-upload / dropdown settings) — it exposes
|
||||||
|
* nothing beyond what the currency picker already shows.
|
||||||
|
*/
|
||||||
|
@Get("currencies")
|
||||||
|
@ApiOperation({ summary: "Currencies customers may be billed and pay in" })
|
||||||
|
currencies() {
|
||||||
|
return this.service.offeredCurrencies();
|
||||||
|
}
|
||||||
|
|
||||||
@Patch()
|
@Patch()
|
||||||
@BookingStaff([
|
@BookingStaff([
|
||||||
FREIGHT_PERMS.settings.manualPayment.manage,
|
FREIGHT_PERMS.settings.manualPayment.manage,
|
||||||
FREIGHT_PERMS.admin,
|
FREIGHT_PERMS.admin,
|
||||||
])
|
])
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Enable or disable manual invoice settlement for ETB and/or USD",
|
summary:
|
||||||
|
"Enable or disable manual invoice settlement per currency, and whether DJF is accepted at all",
|
||||||
})
|
})
|
||||||
update(
|
update(
|
||||||
@Body() dto: UpdateManualPaymentSettingDto,
|
@Body() dto: UpdateManualPaymentSettingDto,
|
||||||
|
|||||||
@@ -37,7 +37,12 @@ export class ManualPaymentSettingsService {
|
|||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
|
|
||||||
return this.repository.save(
|
return this.repository.save(
|
||||||
this.repository.create({ etbEnabled: false, usdEnabled: true, djfEnabled: true }),
|
this.repository.create({
|
||||||
|
etbEnabled: false,
|
||||||
|
usdEnabled: true,
|
||||||
|
djfEnabled: true,
|
||||||
|
djfPaymentsEnabled: true,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,9 +65,34 @@ export class ManualPaymentSettingsService {
|
|||||||
return setting[field];
|
return setting[field];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Currencies customers may be billed and pay in right now. ETB and USD are
|
||||||
|
* always offered; DJF only while its currency-level switch is on. Read by
|
||||||
|
* the booking forms (portal and backoffice) to decide which options to show.
|
||||||
|
*/
|
||||||
|
async offeredCurrencies(): Promise<ManualPaymentCurrency[]> {
|
||||||
|
const setting = await this.get();
|
||||||
|
return setting.djfPaymentsEnabled ? ["ETB", "USD", "DJF"] : ["ETB", "USD"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a currency may be used for NEW business and online payment — the
|
||||||
|
* currency-level switch, not the manual rail's {@link isEnabled}. Only DJF
|
||||||
|
* is switchable; ETB and USD have no off switch.
|
||||||
|
*/
|
||||||
|
async isCurrencyOffered(currency: string | null | undefined): Promise<boolean> {
|
||||||
|
if (currency?.toUpperCase() !== "DJF") return true;
|
||||||
|
return (await this.get()).djfPaymentsEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
/** Flip any toggle; an omitted field leaves that currency unchanged. */
|
/** Flip any toggle; an omitted field leaves that currency unchanged. */
|
||||||
async update(
|
async update(
|
||||||
patch: { etbEnabled?: boolean; usdEnabled?: boolean; djfEnabled?: boolean },
|
patch: {
|
||||||
|
etbEnabled?: boolean;
|
||||||
|
usdEnabled?: boolean;
|
||||||
|
djfEnabled?: boolean;
|
||||||
|
djfPaymentsEnabled?: boolean;
|
||||||
|
},
|
||||||
updatedById?: string | null,
|
updatedById?: string | null,
|
||||||
): Promise<ManualPaymentSetting> {
|
): Promise<ManualPaymentSetting> {
|
||||||
const current = await this.get();
|
const current = await this.get();
|
||||||
@@ -70,11 +100,14 @@ export class ManualPaymentSettingsService {
|
|||||||
...(patch.etbEnabled === undefined ? {} : { etbEnabled: patch.etbEnabled }),
|
...(patch.etbEnabled === undefined ? {} : { etbEnabled: patch.etbEnabled }),
|
||||||
...(patch.usdEnabled === undefined ? {} : { usdEnabled: patch.usdEnabled }),
|
...(patch.usdEnabled === undefined ? {} : { usdEnabled: patch.usdEnabled }),
|
||||||
...(patch.djfEnabled === undefined ? {} : { djfEnabled: patch.djfEnabled }),
|
...(patch.djfEnabled === undefined ? {} : { djfEnabled: patch.djfEnabled }),
|
||||||
|
...(patch.djfPaymentsEnabled === undefined
|
||||||
|
? {}
|
||||||
|
: { djfPaymentsEnabled: patch.djfPaymentsEnabled }),
|
||||||
updatedById: updatedById ?? null,
|
updatedById: updatedById ?? null,
|
||||||
});
|
});
|
||||||
const updated = await this.get();
|
const updated = await this.get();
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Manual payment channels set to ETB=${updated.etbEnabled} USD=${updated.usdEnabled} DJF=${updated.djfEnabled} by ${updatedById ?? "unknown user"}`,
|
`Manual payment channels set to ETB=${updated.etbEnabled} USD=${updated.usdEnabled} DJF=${updated.djfEnabled} (DJF accepted as a currency: ${updated.djfPaymentsEnabled}) by ${updatedById ?? "unknown user"}`,
|
||||||
);
|
);
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
RATE_UNITS,
|
RATE_UNITS,
|
||||||
} from '../entities/rate.entity';
|
} from '../entities/rate.entity';
|
||||||
|
|
||||||
// DOMESTIC is accepted only for FUEL rates (an intercity fuel lane).
|
// DOMESTIC is accepted only for FUEL and per-container HAZARDOUS rates (an intercity lane).
|
||||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH', 'DOMESTIC'] as const;
|
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH', 'DOMESTIC'] as const;
|
||||||
// ETB is accepted only for last-mile rates; the service forces USD elsewhere.
|
// ETB is accepted only for last-mile rates; the service forces USD elsewhere.
|
||||||
const CURRENCIES = ['USD', 'ETB'] as const;
|
const CURRENCIES = ['USD', 'ETB'] as const;
|
||||||
@@ -26,7 +26,10 @@ export class CreateRateDto {
|
|||||||
@IsIn([...RATE_TRIGGERS])
|
@IsIn([...RATE_TRIGGERS])
|
||||||
trigger!: string;
|
trigger!: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'FK to container_types.id — set for container/intercity-container rates' })
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'FK to container_types.id — set for container/intercity-container rates, and optionally on the per-container HAZARDOUS surcharge (20ft / 40ft price differently; omitted = the lane catch-all)',
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
containerTypeId?: string;
|
containerTypeId?: string;
|
||||||
@@ -61,7 +64,7 @@ export class CreateRateDto {
|
|||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description:
|
description:
|
||||||
'FK to yards.id — origin of the leg this rate prices. Required for base freight (bulk/container/intercity), rejected for surcharges and first/last mile.',
|
'FK to yards.id — origin of the leg this rate prices. Required for base freight (bulk/container/intercity) and the lane-sold surcharges (customs clearance, empty return, fuel, per-container HAZARDOUS); rejected for every other surcharge and first/last mile.',
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
@@ -69,7 +72,7 @@ export class CreateRateDto {
|
|||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description:
|
description:
|
||||||
'FK to yards.id — destination of the leg this rate prices. Required for base freight (bulk/container/intercity), rejected for surcharges and first/last mile.',
|
'FK to yards.id — destination of the leg this rate prices. Required for base freight (bulk/container/intercity) and the lane-sold surcharges (customs clearance, empty return, fuel, per-container HAZARDOUS); rejected for every other surcharge and first/last mile.',
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
|
|||||||
@@ -88,6 +88,10 @@ export type RateAppliesTo = typeof RATE_APPLIES_TO[number];
|
|||||||
*/
|
*/
|
||||||
export const RATE_TRIGGERS = [
|
export const RATE_TRIGGERS = [
|
||||||
'ALWAYS',
|
'ALWAYS',
|
||||||
|
// Hazardous cargo. Two shapes under one trigger, told apart by the unit:
|
||||||
|
// PER_CONTAINER is the container surcharge, sold per direction + lane and
|
||||||
|
// optionally per box size (20ft / 40ft) like the empty-return service;
|
||||||
|
// PER_TON is the bulk surcharge, direction-agnostic and unscoped.
|
||||||
'HAZARDOUS',
|
'HAZARDOUS',
|
||||||
'OVERWEIGHT',
|
'OVERWEIGHT',
|
||||||
'REEFER',
|
'REEFER',
|
||||||
@@ -121,6 +125,16 @@ export type RateTrigger = typeof RATE_TRIGGERS[number];
|
|||||||
export const isCustomsClearanceTrigger = (trigger: string): boolean =>
|
export const isCustomsClearanceTrigger = (trigger: string): boolean =>
|
||||||
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'ETHIOPIAN_CUSTOMS_CLEARANCE';
|
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'ETHIOPIAN_CUSTOMS_CLEARANCE';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The container hazardous-cargo surcharge: HAZARDOUS billed per container.
|
||||||
|
* It is sold per trade direction + origin → destination lane, optionally
|
||||||
|
* narrowed to one container type (20ft / 40ft), and priced by the
|
||||||
|
* route-matched block in RuleEngineService — never by the additive loop.
|
||||||
|
* The per-ton (bulk) hazard rate keeps the old global, unscoped shape.
|
||||||
|
*/
|
||||||
|
export const isContainerHazardRate = (trigger: string, rateUnit: string): boolean =>
|
||||||
|
trigger === 'HAZARDOUS' && rateUnit === 'PER_CONTAINER';
|
||||||
|
|
||||||
@Entity({ schema: 'freight', name: 'rates' })
|
@Entity({ schema: 'freight', name: 'rates' })
|
||||||
@Index(['rateType'])
|
@Index(['rateType'])
|
||||||
@Index(['status'])
|
@Index(['status'])
|
||||||
@@ -159,8 +173,10 @@ export class Rate extends BaseEntity {
|
|||||||
/**
|
/**
|
||||||
* The leg this rate prices. Base freight (trigger = ALWAYS) is quoted per
|
* The leg this rate prices. Base freight (trigger = ALWAYS) is quoted per
|
||||||
* route — "container import, Djibouti → Dire Dawa" — so both yards are
|
* route — "container import, Djibouti → Dire Dawa" — so both yards are
|
||||||
* required for BULK/CONTAINER/INTERCITY and NULL for everything else. The
|
* required for BULK/CONTAINER/EMPTY_CONTAINER/INTERCITY, for the lane-sold
|
||||||
* `CK_rates_yard_scope` DB constraint enforces both halves of that.
|
* surcharges (customs clearance, empty return, fuel, container hazard) and
|
||||||
|
* NULL for everything else. The `CK_rates_yard_scope` DB constraint enforces
|
||||||
|
* both halves of that.
|
||||||
*/
|
*/
|
||||||
@Column({ name: 'origin_yard_id', type: 'uuid', nullable: true })
|
@Column({ name: 'origin_yard_id', type: 'uuid', nullable: true })
|
||||||
originYardId?: string | null;
|
originYardId?: string | null;
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import type { BookingEvaluationInput } from './rule-engine.service';
|
|||||||
import type { Rate } from './entities/rate.entity';
|
import type { Rate } from './entities/rate.entity';
|
||||||
|
|
||||||
describe('RuleEngineService — requested service without a configured surcharge rate', () => {
|
describe('RuleEngineService — requested service without a configured surcharge rate', () => {
|
||||||
|
// The bulk (per-ton) hazard rate — global, no lane. These bookings carry no
|
||||||
|
// containers, so they are bulk-shaped and price off this one.
|
||||||
const hazardRate: Rate = {
|
const hazardRate: Rate = {
|
||||||
id: 'rate-hazard',
|
id: 'rate-hazard',
|
||||||
rateType: 'HAZARD_SURCHARGE',
|
rateType: 'HAZARD_SURCHARGE',
|
||||||
trigger: 'HAZARDOUS',
|
trigger: 'HAZARDOUS',
|
||||||
rateValue: 50,
|
rateValue: 50,
|
||||||
rateUnit: 'PER_CONTAINER',
|
rateUnit: 'PER_TON',
|
||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
status: 'LIVE',
|
status: 'LIVE',
|
||||||
containerTypeId: null,
|
containerTypeId: null,
|
||||||
@@ -650,6 +652,7 @@ describe('RuleEngineService — shipping-line rates override the standard ones',
|
|||||||
shippingLineCompanyId: LINE,
|
shippingLineCompanyId: LINE,
|
||||||
} as Rate;
|
} as Rate;
|
||||||
|
|
||||||
|
// Container hazard is sold per direction + lane (+ optional box size).
|
||||||
const standardHazard: Rate = {
|
const standardHazard: Rate = {
|
||||||
id: 'rate-hazard-standard',
|
id: 'rate-hazard-standard',
|
||||||
rateType: 'HAZARD_SURCHARGE',
|
rateType: 'HAZARD_SURCHARGE',
|
||||||
@@ -661,6 +664,9 @@ describe('RuleEngineService — shipping-line rates override the standard ones',
|
|||||||
containerTypeId: null,
|
containerTypeId: null,
|
||||||
cargoTypeId: null,
|
cargoTypeId: null,
|
||||||
shippingLineCompanyId: null,
|
shippingLineCompanyId: null,
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
originYardId: 'yard-dj',
|
||||||
|
destinationYardId: 'yard-adama',
|
||||||
} as Rate;
|
} as Rate;
|
||||||
|
|
||||||
const lineHazard: Rate = {
|
const lineHazard: Rate = {
|
||||||
@@ -764,3 +770,161 @@ describe('RuleEngineService — shipping-line rates override the standard ones',
|
|||||||
expect(result.hardBlocked[0]).toContain('hazardous');
|
expect(result.hardBlocked[0]).toContain('hazardous');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('RuleEngineService — container hazard surcharge per lane and box size', () => {
|
||||||
|
const lane = {
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
originYardId: 'yard-dj',
|
||||||
|
destinationYardId: 'yard-adama',
|
||||||
|
};
|
||||||
|
const catchAll: Rate = {
|
||||||
|
id: 'rate-hazard-lane',
|
||||||
|
rateType: 'HAZARD_SURCHARGE',
|
||||||
|
trigger: 'HAZARDOUS',
|
||||||
|
rateValue: 50,
|
||||||
|
rateUnit: 'PER_CONTAINER',
|
||||||
|
currency: 'USD',
|
||||||
|
status: 'LIVE',
|
||||||
|
containerTypeId: null,
|
||||||
|
cargoTypeId: null,
|
||||||
|
...lane,
|
||||||
|
} as Rate;
|
||||||
|
const forty: Rate = {
|
||||||
|
...catchAll,
|
||||||
|
id: 'rate-hazard-lane-40',
|
||||||
|
rateValue: 90,
|
||||||
|
containerTypeId: 'ct-40',
|
||||||
|
} as Rate;
|
||||||
|
const bulkHazard: Rate = {
|
||||||
|
...catchAll,
|
||||||
|
id: 'rate-hazard-bulk',
|
||||||
|
rateValue: 3,
|
||||||
|
rateUnit: 'PER_TON',
|
||||||
|
tradeDirection: null,
|
||||||
|
originYardId: null,
|
||||||
|
destinationYardId: null,
|
||||||
|
} as Rate;
|
||||||
|
|
||||||
|
const buildService = (rates: Rate[]) =>
|
||||||
|
new RuleEngineService(
|
||||||
|
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||||
|
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||||
|
{ findActiveByContainerTypeId: jest.fn().mockResolvedValue([]) } as never,
|
||||||
|
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
|
||||||
|
{ findLiveRates: jest.fn().mockResolvedValue(rates) } as never,
|
||||||
|
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
const booking = (overrides: Partial<BookingEvaluationInput> = {}): BookingEvaluationInput => ({
|
||||||
|
serviceTypeId: 'svc-1',
|
||||||
|
paymentCurrency: 'USD',
|
||||||
|
isHazardous: false,
|
||||||
|
totalWagons: 2,
|
||||||
|
...lane,
|
||||||
|
containers: [
|
||||||
|
{ containerTypeId: 'ct-20', quantity: 3, vgmPerUnitTons: 10, totalVgmTons: 30, hazardousQuantity: 2 },
|
||||||
|
{ containerTypeId: 'ct-40', quantity: 1, vgmPerUnitTons: 10, totalVgmTons: 10, hazardousQuantity: 1 },
|
||||||
|
],
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hazardOf = (result: { appliedModifiers: Array<{ surchargeCode: string }> }) =>
|
||||||
|
result.appliedModifiers.filter((m) => m.surchargeCode === 'HAZARD_SURCHARGE');
|
||||||
|
|
||||||
|
it('bills each line off the lane rate for its own box size, catch-all otherwise', async () => {
|
||||||
|
const result = await buildService([catchAll, forty]).evaluate(booking());
|
||||||
|
expect(result.hardBlocked).toHaveLength(0);
|
||||||
|
const lines = hazardOf(result);
|
||||||
|
expect(lines).toHaveLength(2);
|
||||||
|
// 2 hazardous 20ft on the lane catch-all, 1 hazardous 40ft on the 40ft rate.
|
||||||
|
expect(lines).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ rateId: catchAll.id, triggerValue: 2, calculatedAmount: 100, unitPriceUsd: 50, billingUnit: 'PER_CONTAINER' }),
|
||||||
|
expect.objectContaining({ rateId: forty.id, triggerValue: 1, calculatedAmount: 90, unitPriceUsd: 90 }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bills the opted-in count, not the whole line', async () => {
|
||||||
|
const result = await buildService([catchAll]).evaluate(
|
||||||
|
booking({
|
||||||
|
containers: [
|
||||||
|
{ containerTypeId: 'ct-20', quantity: 10, vgmPerUnitTons: 10, totalVgmTons: 100, hazardousQuantity: 4 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(hazardOf(result)).toEqual([
|
||||||
|
expect.objectContaining({ triggerValue: 4, calculatedAmount: 200 }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to every container when only the legacy booking-level flag is set', async () => {
|
||||||
|
const result = await buildService([catchAll]).evaluate(
|
||||||
|
booking({
|
||||||
|
isHazardous: true,
|
||||||
|
containers: [
|
||||||
|
{ containerTypeId: 'ct-20', quantity: 3, vgmPerUnitTons: 10, totalVgmTons: 30 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(hazardOf(result)).toEqual([
|
||||||
|
expect.objectContaining({ triggerValue: 3, calculatedAmount: 150 }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never bills the per-container rate a second time through the additive loop', async () => {
|
||||||
|
const result = await buildService([catchAll]).evaluate(
|
||||||
|
booking({
|
||||||
|
isHazardous: true,
|
||||||
|
containers: [
|
||||||
|
{ containerTypeId: 'ct-20', quantity: 2, vgmPerUnitTons: 10, totalVgmTons: 20 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(hazardOf(result)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hard-blocks when the lane has no per-container hazard rate', async () => {
|
||||||
|
const result = await buildService([catchAll]).evaluate(
|
||||||
|
booking({ destinationYardId: 'yard-elsewhere' }),
|
||||||
|
);
|
||||||
|
expect(hazardOf(result)).toHaveLength(0);
|
||||||
|
expect(result.hardBlocked).toHaveLength(1);
|
||||||
|
expect(result.hardBlocked[0]).toContain('hazardous');
|
||||||
|
expect(result.hardBlocked[0]).toContain('route');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hard-blocks when the rate is for the other direction', async () => {
|
||||||
|
const result = await buildService([catchAll]).evaluate(
|
||||||
|
booking({ tradeDirection: 'EXPORT', originYardId: 'yard-adama', destinationYardId: 'yard-dj' }),
|
||||||
|
);
|
||||||
|
expect(result.hardBlocked).toHaveLength(1);
|
||||||
|
expect(result.hardBlocked[0]).toContain('hazardous');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let the bulk per-ton rate stand in for a container booking', async () => {
|
||||||
|
const result = await buildService([bulkHazard]).evaluate(booking());
|
||||||
|
expect(hazardOf(result)).toHaveLength(0);
|
||||||
|
expect(result.hardBlocked).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bills a bulk booking off the global per-ton rate, untouched by the lane rule', async () => {
|
||||||
|
const result = await buildService([bulkHazard, catchAll]).evaluate(
|
||||||
|
booking({ isHazardous: true, containers: [], bulkTons: 40, totalWagons: 1 }),
|
||||||
|
);
|
||||||
|
expect(result.hardBlocked).toHaveLength(0);
|
||||||
|
expect(hazardOf(result)).toEqual([
|
||||||
|
expect.objectContaining({ rateId: bulkHazard.id, triggerValue: 40, calculatedAmount: 120 }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hard-blocks a hazardous bulk booking when only the container rate exists', async () => {
|
||||||
|
const result = await buildService([catchAll]).evaluate(
|
||||||
|
booking({ isHazardous: true, containers: [], bulkTons: 40, totalWagons: 1 }),
|
||||||
|
);
|
||||||
|
expect(result.hardBlocked).toHaveLength(1);
|
||||||
|
expect(result.hardBlocked[0]).toContain('hazardous');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Inject, Injectable, BadRequestException } from '@nestjs/common';
|
import { Inject, Injectable, BadRequestException } from '@nestjs/common';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.entity';
|
import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.entity';
|
||||||
import { Rate, RateTrigger } from './entities/rate.entity';
|
import { Rate, RateTrigger, isContainerHazardRate } from './entities/rate.entity';
|
||||||
import { isBulkQuantityUnit } from './entities/rate-unit.util';
|
import { isBulkQuantityUnit } from './entities/rate-unit.util';
|
||||||
import {
|
import {
|
||||||
ICargoTypesRepository,
|
ICargoTypesRepository,
|
||||||
@@ -366,9 +366,11 @@ export class RuleEngineService {
|
|||||||
// hard block — pricing would otherwise ship the service for free. System-
|
// hard block — pricing would otherwise ship the service for free. System-
|
||||||
// derived charges (consolidation, overweight, shipping line, lashing) stay
|
// derived charges (consolidation, overweight, shipping line, lashing) stay
|
||||||
// exempt: the customer never opted into those, so they must not block.
|
// exempt: the customer never opted into those, so they must not block.
|
||||||
|
const isContainerBooking = input.containers.length > 0;
|
||||||
const requestedServices: Array<{
|
const requestedServices: Array<{
|
||||||
trigger: RateTrigger;
|
trigger: RateTrigger;
|
||||||
wanted: boolean;
|
wanted: boolean;
|
||||||
|
configured: boolean;
|
||||||
label: string;
|
label: string;
|
||||||
}> = [
|
}> = [
|
||||||
{
|
{
|
||||||
@@ -376,6 +378,15 @@ export class RuleEngineService {
|
|||||||
wanted:
|
wanted:
|
||||||
truthy(input.isHazardous) ||
|
truthy(input.isHazardous) ||
|
||||||
input.containers.some((c) => Number(c.hazardousQuantity ?? 0) > 0),
|
input.containers.some((c) => Number(c.hazardousQuantity ?? 0) > 0),
|
||||||
|
// Container hazard is sold per lane + box size and checked by the
|
||||||
|
// route-matched block below (which blocks per missing lane/type rate);
|
||||||
|
// bulk hazard is the global per-ton rate this loop can vouch for.
|
||||||
|
configured: isContainerBooking
|
||||||
|
? true
|
||||||
|
: surchargeRates.some(
|
||||||
|
(r) =>
|
||||||
|
r.trigger === 'HAZARDOUS' && !isContainerHazardRate(r.trigger, r.rateUnit),
|
||||||
|
),
|
||||||
label: 'hazardous cargo',
|
label: 'hazardous cargo',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -383,11 +394,12 @@ export class RuleEngineService {
|
|||||||
wanted:
|
wanted:
|
||||||
hasReefer ||
|
hasReefer ||
|
||||||
input.containers.some((c) => Number(c.reeferQuantity ?? 0) > 0),
|
input.containers.some((c) => Number(c.reeferQuantity ?? 0) > 0),
|
||||||
|
configured: surchargeRates.some((r) => r.trigger === 'REEFER'),
|
||||||
label: 'refrigerated (reefer) cargo',
|
label: 'refrigerated (reefer) cargo',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
for (const svc of requestedServices) {
|
for (const svc of requestedServices) {
|
||||||
if (svc.wanted && !surchargeRates.some((r) => r.trigger === svc.trigger)) {
|
if (svc.wanted && !svc.configured) {
|
||||||
hardBlocked.push(
|
hardBlocked.push(
|
||||||
`No ${svc.label} surcharge rate is configured — the booking cannot ` +
|
`No ${svc.label} surcharge rate is configured — the booking cannot ` +
|
||||||
`be priced with this service. Remove the ${svc.label} option or ` +
|
`be priced with this service. Remove the ${svc.label} option or ` +
|
||||||
@@ -411,6 +423,10 @@ export class RuleEngineService {
|
|||||||
// Fuel is sold per lane + cargo type — billed by the route-matched
|
// Fuel is sold per lane + cargo type — billed by the route-matched
|
||||||
// block below, never by this route-agnostic loop.
|
// block below, never by this route-agnostic loop.
|
||||||
if (rate.trigger === 'FUEL') continue;
|
if (rate.trigger === 'FUEL') continue;
|
||||||
|
// The container hazard surcharge is sold per lane + box size like the
|
||||||
|
// empty-return service — billed by its own route-matched block below.
|
||||||
|
// The per-ton bulk hazard rate stays additive here.
|
||||||
|
if (isContainerHazardRate(rate.trigger, rate.rateUnit)) continue;
|
||||||
const triggered = this.matchesTrigger(rate.trigger, {
|
const triggered = this.matchesTrigger(rate.trigger, {
|
||||||
isHazardous: input.isHazardous,
|
isHazardous: input.isHazardous,
|
||||||
hasReefer,
|
hasReefer,
|
||||||
@@ -521,6 +537,10 @@ export class RuleEngineService {
|
|||||||
appliedModifiers.push(...withReturn.modifiers);
|
appliedModifiers.push(...withReturn.modifiers);
|
||||||
hardBlocked.push(...withReturn.blocked);
|
hardBlocked.push(...withReturn.blocked);
|
||||||
|
|
||||||
|
const containerHazard = this.containerHazardCharges(input, liveRates);
|
||||||
|
appliedModifiers.push(...containerHazard.modifiers);
|
||||||
|
hardBlocked.push(...containerHazard.blocked);
|
||||||
|
|
||||||
if (hasLashing) {
|
if (hasLashing) {
|
||||||
appliedModifiers.push(...this.lashingCharges(input, liveRates));
|
appliedModifiers.push(...this.lashingCharges(input, liveRates));
|
||||||
}
|
}
|
||||||
@@ -730,6 +750,80 @@ export class RuleEngineService {
|
|||||||
return { modifiers, blocked: [...new Set(blocked)] };
|
return { modifiers, blocked: [...new Set(blocked)] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Container hazardous-cargo surcharge — sold per direction + route, optionally
|
||||||
|
* per container type, exactly like the empty-return service. Each container
|
||||||
|
* line that opted in (hazardousQuantity, or every container when only the
|
||||||
|
* legacy booking-level flag is set) bills the route-matched PER_CONTAINER
|
||||||
|
* HAZARDOUS rate for its own container type, falling back to the lane's
|
||||||
|
* catch-all (no type) rate; a line with no matching rate hard-blocks the
|
||||||
|
* booking instead of shipping the service for free. Bulk bookings never
|
||||||
|
* reach here — their per-ton hazard rate is billed by the additive loop.
|
||||||
|
* ponytail: bills the LIVE route rate, not a frozen contract snapshot — one
|
||||||
|
* HAZARD_SURCHARGE snapshot code can't hold per-size route prices.
|
||||||
|
*/
|
||||||
|
private containerHazardCharges(
|
||||||
|
input: BookingEvaluationInput,
|
||||||
|
liveRates: Rate[],
|
||||||
|
): { modifiers: AppliedCargoModifier[]; blocked: string[] } {
|
||||||
|
const modifiers: AppliedCargoModifier[] = [];
|
||||||
|
const blocked: string[] = [];
|
||||||
|
if (input.containers.length === 0) return { modifiers, blocked };
|
||||||
|
const bookingLevel = truthy(input.isHazardous);
|
||||||
|
const wanted =
|
||||||
|
bookingLevel ||
|
||||||
|
input.containers.some((c) => Number(c.hazardousQuantity ?? 0) > 0);
|
||||||
|
if (!wanted) return { modifiers, blocked };
|
||||||
|
|
||||||
|
const onLeg = liveRates.filter(
|
||||||
|
(r) =>
|
||||||
|
isContainerHazardRate(r.trigger, r.rateUnit) &&
|
||||||
|
r.currency === 'USD' &&
|
||||||
|
r.tradeDirection === input.tradeDirection &&
|
||||||
|
r.originYardId === input.originYardId &&
|
||||||
|
r.destinationYardId === input.destinationYardId,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const container of input.containers) {
|
||||||
|
const qty =
|
||||||
|
Number(container.hazardousQuantity ?? 0) > 0
|
||||||
|
? Number(container.hazardousQuantity)
|
||||||
|
: bookingLevel
|
||||||
|
? Number(container.quantity || 0)
|
||||||
|
: 0;
|
||||||
|
if (!(qty > 0)) continue;
|
||||||
|
|
||||||
|
// The rate scoped to this box size wins over the lane's catch-all.
|
||||||
|
const rate =
|
||||||
|
onLeg.find((r) => r.containerTypeId === container.containerTypeId) ??
|
||||||
|
onLeg.find((r) => !r.containerTypeId);
|
||||||
|
if (!rate) {
|
||||||
|
blocked.push(
|
||||||
|
'No hazardous cargo surcharge rate is configured for this container ' +
|
||||||
|
'type on this route — remove the hazardous option or ask EDR to ' +
|
||||||
|
'configure its per-container rate for this origin → destination.',
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rateValue = Number(rate.rateValue);
|
||||||
|
const amount = qty * rateValue;
|
||||||
|
if (!(amount > 0)) continue;
|
||||||
|
modifiers.push({
|
||||||
|
rateId: rate.id,
|
||||||
|
surchargeCode: this.surchargeCode(rate),
|
||||||
|
triggerValue: qty,
|
||||||
|
calculatedAmount: amount,
|
||||||
|
currency: rate.currency,
|
||||||
|
unitPriceUsd: rateValue,
|
||||||
|
billingUnit: rate.rateUnit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same block deduplicated — several lines missing the rate is one problem.
|
||||||
|
return { modifiers, blocked: [...new Set(blocked)] };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cargo securing / lashing — BULK only, sold per trade direction, optionally
|
* Cargo securing / lashing — BULK only, sold per trade direction, optionally
|
||||||
* narrowed to one leaf commodity (the commodity-scoped rate wins over the
|
* narrowed to one leaf commodity (the commodity-scoped rate wins over the
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ConflictException } from '@nestjs/common';
|
import { BadRequestException, ConflictException } from '@nestjs/common';
|
||||||
|
|
||||||
import { RatesService } from './rates.service';
|
import { RatesService } from './rates.service';
|
||||||
import type { Rate } from '../entities/rate.entity';
|
import type { Rate } from '../entities/rate.entity';
|
||||||
@@ -101,8 +101,9 @@ describe('RatesService — one rate per pattern', () => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Additive surcharges are billed per matching rate, each by its own unit, so
|
* Additive surcharges are billed per matching rate, each by its own unit, so
|
||||||
* hazard is legitimately per-container for boxes AND per-ton for bulk. The
|
* the bulk (per-ton) hazard rate coexists with the lane-sold container one.
|
||||||
* unit stays part of their identity or the second one could never be created.
|
* The unit stays part of their identity or the second one could never be
|
||||||
|
* created.
|
||||||
*/
|
*/
|
||||||
it('keeps the unit in the key for an additive surcharge', async () => {
|
it('keeps the unit in the key for an additive surcharge', async () => {
|
||||||
await service.create(
|
await service.create(
|
||||||
@@ -110,17 +111,44 @@ describe('RatesService — one rate per pattern', () => {
|
|||||||
appliesTo: 'OTHER',
|
appliesTo: 'OTHER',
|
||||||
trigger: 'HAZARDOUS',
|
trigger: 'HAZARDOUS',
|
||||||
rateValue: 300,
|
rateValue: 300,
|
||||||
rateUnit: 'PER_CONTAINER',
|
rateUnit: 'PER_TON',
|
||||||
} as never,
|
} as never,
|
||||||
'staff-1',
|
'staff-1',
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(repository.findByPattern.mock.calls[0][0]).toMatchObject({
|
expect(repository.findByPattern.mock.calls[0][0]).toMatchObject({
|
||||||
rateType: 'HAZARD_SURCHARGE',
|
rateType: 'HAZARD_SURCHARGE',
|
||||||
rateUnit: 'PER_CONTAINER',
|
rateUnit: 'PER_TON',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps the per-ton hazard rate global — direction and lane are dropped', async () => {
|
||||||
|
await service.create(
|
||||||
|
{
|
||||||
|
appliesTo: 'OTHER',
|
||||||
|
trigger: 'HAZARDOUS',
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
originYardId: DJ,
|
||||||
|
destinationYardId: ET,
|
||||||
|
containerTypeId: CT20,
|
||||||
|
rateValue: 5,
|
||||||
|
rateUnit: 'PER_TON',
|
||||||
|
} as never,
|
||||||
|
'staff-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(repository.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
rateType: 'HAZARD_SURCHARGE',
|
||||||
|
rateUnit: 'PER_TON',
|
||||||
|
tradeDirection: null,
|
||||||
|
originYardId: null,
|
||||||
|
destinationYardId: null,
|
||||||
|
containerTypeId: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('treats lashing as singly resolved — one unit per direction', async () => {
|
it('treats lashing as singly resolved — one unit per direction', async () => {
|
||||||
await service.create(
|
await service.create(
|
||||||
{
|
{
|
||||||
@@ -138,3 +166,166 @@ describe('RatesService — one rate per pattern', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The container hazard surcharge (HAZARDOUS per container) is sold per
|
||||||
|
* direction + lane, optionally per box size — the same shape as the
|
||||||
|
* empty-return service. Pricing resolves exactly one rate per lane + size, so
|
||||||
|
* the unit leaves the identity and the lane joins it.
|
||||||
|
*/
|
||||||
|
describe('RatesService — per-container hazard is sold per lane', () => {
|
||||||
|
const DJ = '11111111-1111-4000-8000-000000000001';
|
||||||
|
const ET = '11111111-1111-4000-8000-000000000002';
|
||||||
|
const ET2 = '11111111-1111-4000-8000-000000000004';
|
||||||
|
const CT20 = '11111111-1111-4000-8000-000000000003';
|
||||||
|
|
||||||
|
let repository: { findByPattern: jest.Mock; create: jest.Mock };
|
||||||
|
let service: RatesService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
repository = {
|
||||||
|
findByPattern: jest.fn().mockResolvedValue(null),
|
||||||
|
create: jest.fn(async (r) => ({ id: 'rate-new', ...r })),
|
||||||
|
};
|
||||||
|
service = new RatesService(
|
||||||
|
repository as never,
|
||||||
|
{
|
||||||
|
findById: jest.fn(async (id: string) => ({
|
||||||
|
id,
|
||||||
|
country: id === DJ ? 'Djibouti' : 'Ethiopia',
|
||||||
|
label: id === DJ ? 'Doraleh' : id === ET ? 'Gelan' : 'Dire Dawa',
|
||||||
|
})),
|
||||||
|
} as never,
|
||||||
|
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||||
|
{ findById: jest.fn() } as never,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const containerHazard = {
|
||||||
|
appliesTo: 'OTHER',
|
||||||
|
trigger: 'HAZARDOUS',
|
||||||
|
rateValue: 300,
|
||||||
|
rateUnit: 'PER_CONTAINER',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('requires a trade direction', async () => {
|
||||||
|
await expect(
|
||||||
|
service.create(
|
||||||
|
{ ...containerHazard, originYardId: DJ, destinationYardId: ET } as never,
|
||||||
|
'staff-1',
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(repository.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires both yards of the lane', async () => {
|
||||||
|
await expect(
|
||||||
|
service.create(
|
||||||
|
{ ...containerHazard, tradeDirection: 'IMPORT' } as never,
|
||||||
|
'staff-1',
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(repository.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a lane that contradicts the direction', async () => {
|
||||||
|
// Export runs Ethiopia → Djibouti; this leg is the import shape.
|
||||||
|
await expect(
|
||||||
|
service.create(
|
||||||
|
{
|
||||||
|
...containerHazard,
|
||||||
|
tradeDirection: 'EXPORT',
|
||||||
|
originYardId: DJ,
|
||||||
|
destinationYardId: ET,
|
||||||
|
} as never,
|
||||||
|
'staff-1',
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('files the rate per direction + lane + box size, unit out of the key', async () => {
|
||||||
|
await service.create(
|
||||||
|
{
|
||||||
|
...containerHazard,
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
originYardId: DJ,
|
||||||
|
destinationYardId: ET,
|
||||||
|
containerTypeId: CT20,
|
||||||
|
} as never,
|
||||||
|
'staff-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
const pattern = repository.findByPattern.mock.calls[0][0];
|
||||||
|
expect(pattern).not.toHaveProperty('rateUnit');
|
||||||
|
expect(pattern).toMatchObject({
|
||||||
|
rateType: 'HAZARD_SURCHARGE',
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
originYardId: DJ,
|
||||||
|
destinationYardId: ET,
|
||||||
|
containerTypeId: CT20,
|
||||||
|
});
|
||||||
|
expect(repository.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
rateType: 'HAZARD_SURCHARGE',
|
||||||
|
rateUnit: 'PER_CONTAINER',
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
originYardId: DJ,
|
||||||
|
destinationYardId: ET,
|
||||||
|
containerTypeId: CT20,
|
||||||
|
cargoTypeId: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a lane catch-all with no box size', async () => {
|
||||||
|
await service.create(
|
||||||
|
{
|
||||||
|
...containerHazard,
|
||||||
|
tradeDirection: 'EXPORT',
|
||||||
|
originYardId: ET,
|
||||||
|
destinationYardId: DJ,
|
||||||
|
} as never,
|
||||||
|
'staff-1',
|
||||||
|
);
|
||||||
|
expect(repository.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
tradeDirection: 'EXPORT',
|
||||||
|
originYardId: ET,
|
||||||
|
destinationYardId: DJ,
|
||||||
|
containerTypeId: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a DOMESTIC (intercity) lane inside Ethiopia', async () => {
|
||||||
|
await service.create(
|
||||||
|
{
|
||||||
|
...containerHazard,
|
||||||
|
tradeDirection: 'DOMESTIC',
|
||||||
|
originYardId: ET,
|
||||||
|
destinationYardId: ET2,
|
||||||
|
} as never,
|
||||||
|
'staff-1',
|
||||||
|
);
|
||||||
|
expect(repository.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ tradeDirection: 'DOMESTIC', originYardId: ET, destinationYardId: ET2 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a second rate for the same lane + box size', async () => {
|
||||||
|
repository.findByPattern.mockResolvedValue({ id: 'rate-existing' } as Rate);
|
||||||
|
await expect(
|
||||||
|
service.create(
|
||||||
|
{
|
||||||
|
...containerHazard,
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
originYardId: DJ,
|
||||||
|
destinationYardId: ET,
|
||||||
|
containerTypeId: CT20,
|
||||||
|
} as never,
|
||||||
|
'staff-1',
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { ShippingLineCompaniesService } from '../../shipping-lines/shipping-line
|
|||||||
import { CreateRateDto } from '../dto/create-rate.dto';
|
import { CreateRateDto } from '../dto/create-rate.dto';
|
||||||
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
|
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import { UpdateRateDto } from '../dto/update-rate.dto';
|
import { UpdateRateDto } from '../dto/update-rate.dto';
|
||||||
import { Rate, isCustomsClearanceTrigger } from '../entities/rate.entity';
|
import { Rate, isContainerHazardRate, isCustomsClearanceTrigger } from '../entities/rate.entity';
|
||||||
import { deriveRateType } from '../entities/rate-type.util';
|
import { deriveRateType } from '../entities/rate-type.util';
|
||||||
import { CargoUom, allowedRateUnits, isRateUnitAllowed } from '../entities/rate-unit.util';
|
import { CargoUom, allowedRateUnits, isRateUnitAllowed } from '../entities/rate-unit.util';
|
||||||
import {
|
import {
|
||||||
@@ -39,7 +39,11 @@ const CARGO_KIND_TRIGGERS: readonly Rate['trigger'][] = [
|
|||||||
'ETHIOPIAN_CUSTOMS_CLEARANCE',
|
'ETHIOPIAN_CUSTOMS_CLEARANCE',
|
||||||
'CANCELLATION',
|
'CANCELLATION',
|
||||||
];
|
];
|
||||||
/** Surcharges that keep a trade direction (everything else is direction-agnostic). */
|
/**
|
||||||
|
* Surcharges that keep a trade direction (everything else is direction-agnostic).
|
||||||
|
* Container hazard (HAZARDOUS billed PER_CONTAINER) is directed too, but is
|
||||||
|
* keyed on the unit rather than the trigger — see {@link isDirectedSurcharge}.
|
||||||
|
*/
|
||||||
const DIRECTED_SURCHARGE_TRIGGERS: readonly Rate['trigger'][] = [
|
const DIRECTED_SURCHARGE_TRIGGERS: readonly Rate['trigger'][] = [
|
||||||
'CUSTOMS_CLEARANCE',
|
'CUSTOMS_CLEARANCE',
|
||||||
'ETHIOPIAN_CUSTOMS_CLEARANCE',
|
'ETHIOPIAN_CUSTOMS_CLEARANCE',
|
||||||
@@ -148,18 +152,29 @@ export class RatesService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Rates sold per direction + route. Base freight always; customs clearance,
|
* Rates sold per direction + route. Base freight always; customs clearance,
|
||||||
* empty-container return and fuel are the surcharges that are too — their
|
* empty-container return, fuel and the container hazard surcharge are the
|
||||||
* fee depends on the lane (and, for returns, the container type).
|
* surcharges that are too — their fee depends on the lane (and, for returns
|
||||||
|
* and container hazard, the container type).
|
||||||
*/
|
*/
|
||||||
private isRouteScoped(appliesTo: Rate['appliesTo'], trigger: Rate['trigger']): boolean {
|
private isRouteScoped(
|
||||||
|
appliesTo: Rate['appliesTo'],
|
||||||
|
trigger: Rate['trigger'],
|
||||||
|
rateUnit: Rate['rateUnit'],
|
||||||
|
): boolean {
|
||||||
return (
|
return (
|
||||||
this.isBaseFreight(appliesTo, trigger) ||
|
this.isBaseFreight(appliesTo, trigger) ||
|
||||||
isCustomsClearanceTrigger(trigger) ||
|
isCustomsClearanceTrigger(trigger) ||
|
||||||
trigger === 'WITH_RETURN' ||
|
trigger === 'WITH_RETURN' ||
|
||||||
trigger === 'FUEL'
|
trigger === 'FUEL' ||
|
||||||
|
isContainerHazardRate(trigger, rateUnit)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Surcharges that carry a trade direction; everything else is direction-agnostic. */
|
||||||
|
private isDirectedSurcharge(trigger: Rate['trigger'], rateUnit: Rate['rateUnit']): boolean {
|
||||||
|
return DIRECTED_SURCHARGE_TRIGGERS.includes(trigger) || isContainerHazardRate(trigger, rateUnit);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True when pricing resolves exactly ONE rate for this shape (base freight,
|
* True when pricing resolves exactly ONE rate for this shape (base freight,
|
||||||
* customs clearance, lashing, empty-container return — all `find()`-based
|
* customs clearance, lashing, empty-container return — all `find()`-based
|
||||||
@@ -174,9 +189,10 @@ export class RatesService {
|
|||||||
private resolvesSingleRate(
|
private resolvesSingleRate(
|
||||||
appliesTo: Rate['appliesTo'],
|
appliesTo: Rate['appliesTo'],
|
||||||
trigger: Rate['trigger'],
|
trigger: Rate['trigger'],
|
||||||
|
rateUnit: Rate['rateUnit'],
|
||||||
): boolean {
|
): boolean {
|
||||||
return (
|
return (
|
||||||
this.isRouteScoped(appliesTo, trigger) ||
|
this.isRouteScoped(appliesTo, trigger, rateUnit) ||
|
||||||
trigger === 'LASHING' ||
|
trigger === 'LASHING' ||
|
||||||
trigger === 'CANCELLATION'
|
trigger === 'CANCELLATION'
|
||||||
);
|
);
|
||||||
@@ -191,8 +207,8 @@ export class RatesService {
|
|||||||
appliesTo: Rate['appliesTo'],
|
appliesTo: Rate['appliesTo'],
|
||||||
tradeDirection: string | null,
|
tradeDirection: string | null,
|
||||||
): { origin: YardCountry; destination: YardCountry } {
|
): { origin: YardCountry; destination: YardCountry } {
|
||||||
// DOMESTIC only reaches here on a FUEL rate's intercity lane — it stays
|
// DOMESTIC only reaches here on a FUEL or container-hazard rate's intercity
|
||||||
// inside Ethiopia exactly like intercity base freight.
|
// lane — it stays inside Ethiopia exactly like intercity base freight.
|
||||||
if (appliesTo === 'INTERCITY' || tradeDirection === 'DOMESTIC') {
|
if (appliesTo === 'INTERCITY' || tradeDirection === 'DOMESTIC') {
|
||||||
return { origin: YardCountry.ETHIOPIA, destination: YardCountry.ETHIOPIA };
|
return { origin: YardCountry.ETHIOPIA, destination: YardCountry.ETHIOPIA };
|
||||||
}
|
}
|
||||||
@@ -212,12 +228,13 @@ export class RatesService {
|
|||||||
private async resolveYardScope(input: {
|
private async resolveYardScope(input: {
|
||||||
appliesTo: Rate['appliesTo'];
|
appliesTo: Rate['appliesTo'];
|
||||||
trigger: Rate['trigger'];
|
trigger: Rate['trigger'];
|
||||||
|
rateUnit: Rate['rateUnit'];
|
||||||
tradeDirection: string | null;
|
tradeDirection: string | null;
|
||||||
originYardId?: string | null;
|
originYardId?: string | null;
|
||||||
destinationYardId?: string | null;
|
destinationYardId?: string | null;
|
||||||
}): Promise<YardScope> {
|
}): Promise<YardScope> {
|
||||||
const { appliesTo, trigger, tradeDirection } = input;
|
const { appliesTo, trigger, rateUnit, tradeDirection } = input;
|
||||||
if (!this.isRouteScoped(appliesTo, trigger)) {
|
if (!this.isRouteScoped(appliesTo, trigger, rateUnit)) {
|
||||||
return { originYardId: null, destinationYardId: null };
|
return { originYardId: null, destinationYardId: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,14 +280,36 @@ export class RatesService {
|
|||||||
private assertScopeCoherent(input: {
|
private assertScopeCoherent(input: {
|
||||||
appliesTo: Rate['appliesTo'];
|
appliesTo: Rate['appliesTo'];
|
||||||
trigger: Rate['trigger'];
|
trigger: Rate['trigger'];
|
||||||
|
rateUnit: Rate['rateUnit'];
|
||||||
tradeDirection: string | null;
|
tradeDirection: string | null;
|
||||||
intercityKind: string | null;
|
intercityKind: string | null;
|
||||||
cargoKind: string | null;
|
cargoKind: string | null;
|
||||||
containerTypeId: string | null;
|
containerTypeId: string | null;
|
||||||
cargoTypeId: string | null;
|
cargoTypeId: string | null;
|
||||||
}): void {
|
}): void {
|
||||||
const { appliesTo, trigger, tradeDirection, intercityKind, cargoKind } = input;
|
const { appliesTo, trigger, rateUnit, tradeDirection, intercityKind, cargoKind } = input;
|
||||||
const { containerTypeId, cargoTypeId } = input;
|
const { containerTypeId, cargoTypeId } = input;
|
||||||
|
if (isContainerHazardRate(trigger, rateUnit)) {
|
||||||
|
// The container hazard surcharge is sold per lane like the empty-return
|
||||||
|
// service: the direction says which countries the leg spans (DOMESTIC =
|
||||||
|
// intercity, inside Ethiopia) and the box size may narrow it (a 20ft and
|
||||||
|
// a 40ft hazardous box price differently; no size = the lane's catch-all).
|
||||||
|
if (
|
||||||
|
tradeDirection !== 'IMPORT' &&
|
||||||
|
tradeDirection !== 'EXPORT' &&
|
||||||
|
tradeDirection !== 'DOMESTIC'
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'A per-container hazardous surcharge must say whether it covers IMPORT, EXPORT or DOMESTIC (intercity).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (cargoTypeId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'A per-container hazardous surcharge cannot be scoped to a bulk cargo type.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isCustomsClearanceTrigger(trigger) || trigger === 'CANCELLATION') {
|
if (isCustomsClearanceTrigger(trigger) || trigger === 'CANCELLATION') {
|
||||||
// Both fees are sold per direction + cargo kind + type: customs clearance
|
// Both fees are sold per direction + cargo kind + type: customs clearance
|
||||||
// per lane, the wagon cancellation fee per direction only.
|
// per lane, the wagon cancellation fee per direction only.
|
||||||
@@ -602,18 +641,12 @@ export class RatesService {
|
|||||||
// Surcharges (trigger ≠ ALWAYS) carry no direction/scope — clear them so
|
// Surcharges (trigger ≠ ALWAYS) carry no direction/scope — clear them so
|
||||||
// the engine never accidentally narrows a surcharge by container/direction.
|
// the engine never accidentally narrows a surcharge by container/direction.
|
||||||
// Exceptions: the directed surcharges (customs clearance, cancellation,
|
// Exceptions: the directed surcharges (customs clearance, cancellation,
|
||||||
// empty-container return, lashing, fuel) keep direction + cargo scope.
|
// empty-container return, lashing, fuel, per-container hazard) keep
|
||||||
|
// direction + cargo scope.
|
||||||
const isSurcharge = trigger !== 'ALWAYS';
|
const isSurcharge = trigger !== 'ALWAYS';
|
||||||
const cargoKind = CARGO_KIND_TRIGGERS.includes(trigger)
|
const cargoKind = CARGO_KIND_TRIGGERS.includes(trigger)
|
||||||
? ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? null)
|
? ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? null)
|
||||||
: null;
|
: null;
|
||||||
const containerTypeId =
|
|
||||||
trigger === 'WITH_RETURN' ||
|
|
||||||
(CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'CONTAINER')
|
|
||||||
? (dto.containerTypeId ?? null)
|
|
||||||
: isSurcharge
|
|
||||||
? null
|
|
||||||
: (dto.containerTypeId ?? null);
|
|
||||||
const cargoTypeId =
|
const cargoTypeId =
|
||||||
(CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'BULK') ||
|
(CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'BULK') ||
|
||||||
trigger === 'LASHING' ||
|
trigger === 'LASHING' ||
|
||||||
@@ -622,12 +655,30 @@ export class RatesService {
|
|||||||
: isSurcharge
|
: isSurcharge
|
||||||
? null
|
? null
|
||||||
: (dto.cargoTypeId ?? null);
|
: (dto.cargoTypeId ?? null);
|
||||||
|
// The unit is resolved before the scope because for hazard it IS the shape:
|
||||||
|
// per container is the lane-sold container surcharge (direction + yards +
|
||||||
|
// optional box size), per ton the global bulk one.
|
||||||
|
const rateUnit = await this.resolveRateUnit(
|
||||||
|
appliesTo,
|
||||||
|
trigger,
|
||||||
|
dto.rateUnit as Rate['rateUnit'] | undefined,
|
||||||
|
cargoKind,
|
||||||
|
cargoTypeId,
|
||||||
|
);
|
||||||
|
const containerTypeId =
|
||||||
|
trigger === 'WITH_RETURN' ||
|
||||||
|
isContainerHazardRate(trigger, rateUnit) ||
|
||||||
|
(CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'CONTAINER')
|
||||||
|
? (dto.containerTypeId ?? null)
|
||||||
|
: isSurcharge
|
||||||
|
? null
|
||||||
|
: (dto.containerTypeId ?? null);
|
||||||
// Intercity never leaves Ethiopia, so it has no trade direction to store —
|
// Intercity never leaves Ethiopia, so it has no trade direction to store —
|
||||||
// its yard pair already says where it runs. (Fuel is the exception: its
|
// its yard pair already says where it runs. (Fuel and container hazard are
|
||||||
// intercity lane is stored as DOMESTIC, since appliesTo = OTHER says
|
// the exception: their intercity lane is stored as DOMESTIC, since
|
||||||
// nothing about the direction.)
|
// appliesTo = OTHER says nothing about the direction.)
|
||||||
const tradeDirection =
|
const tradeDirection =
|
||||||
DIRECTED_SURCHARGE_TRIGGERS.includes(trigger)
|
this.isDirectedSurcharge(trigger, rateUnit)
|
||||||
? (dto.tradeDirection ?? null)
|
? (dto.tradeDirection ?? null)
|
||||||
: isSurcharge || appliesTo === 'INTERCITY'
|
: isSurcharge || appliesTo === 'INTERCITY'
|
||||||
? null
|
? null
|
||||||
@@ -637,6 +688,7 @@ export class RatesService {
|
|||||||
this.assertScopeCoherent({
|
this.assertScopeCoherent({
|
||||||
appliesTo,
|
appliesTo,
|
||||||
trigger,
|
trigger,
|
||||||
|
rateUnit,
|
||||||
tradeDirection,
|
tradeDirection,
|
||||||
intercityKind,
|
intercityKind,
|
||||||
cargoKind,
|
cargoKind,
|
||||||
@@ -646,6 +698,7 @@ export class RatesService {
|
|||||||
const { originYardId, destinationYardId } = await this.resolveYardScope({
|
const { originYardId, destinationYardId } = await this.resolveYardScope({
|
||||||
appliesTo,
|
appliesTo,
|
||||||
trigger,
|
trigger,
|
||||||
|
rateUnit,
|
||||||
tradeDirection,
|
tradeDirection,
|
||||||
originYardId: dto.originYardId,
|
originYardId: dto.originYardId,
|
||||||
destinationYardId: dto.destinationYardId,
|
destinationYardId: dto.destinationYardId,
|
||||||
@@ -662,13 +715,6 @@ export class RatesService {
|
|||||||
tradeDirection,
|
tradeDirection,
|
||||||
isBulk: this.resolvesToBulk(appliesTo, intercityKind),
|
isBulk: this.resolvesToBulk(appliesTo, intercityKind),
|
||||||
});
|
});
|
||||||
const rateUnit = await this.resolveRateUnit(
|
|
||||||
appliesTo,
|
|
||||||
trigger,
|
|
||||||
dto.rateUnit as Rate['rateUnit'] | undefined,
|
|
||||||
cargoKind,
|
|
||||||
cargoTypeId,
|
|
||||||
);
|
|
||||||
|
|
||||||
const { minKm, maxKm } = this.resolveLastMileBand({
|
const { minKm, maxKm } = this.resolveLastMileBand({
|
||||||
appliesTo,
|
appliesTo,
|
||||||
@@ -689,7 +735,7 @@ export class RatesService {
|
|||||||
|
|
||||||
await this.assertNoDuplicatePattern({
|
await this.assertNoDuplicatePattern({
|
||||||
rateType,
|
rateType,
|
||||||
...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }),
|
...(this.resolvesSingleRate(appliesTo, trigger, rateUnit) ? {} : { rateUnit }),
|
||||||
shippingLineCompanyId,
|
shippingLineCompanyId,
|
||||||
containerTypeId,
|
containerTypeId,
|
||||||
cargoTypeId,
|
cargoTypeId,
|
||||||
@@ -826,15 +872,6 @@ export class RatesService {
|
|||||||
: ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ??
|
: ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ??
|
||||||
(existing.containerTypeId ? 'CONTAINER' : 'BULK'));
|
(existing.containerTypeId ? 'CONTAINER' : 'BULK'));
|
||||||
|
|
||||||
const keepsContainerType =
|
|
||||||
!isSurcharge ||
|
|
||||||
trigger === 'WITH_RETURN' ||
|
|
||||||
(CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'CONTAINER');
|
|
||||||
const containerTypeId = !keepsContainerType
|
|
||||||
? null
|
|
||||||
: dto.containerTypeId !== undefined
|
|
||||||
? dto.containerTypeId
|
|
||||||
: existing.containerTypeId;
|
|
||||||
const keepsCargoType =
|
const keepsCargoType =
|
||||||
!isSurcharge ||
|
!isSurcharge ||
|
||||||
(CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'BULK') ||
|
(CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'BULK') ||
|
||||||
@@ -845,8 +882,32 @@ export class RatesService {
|
|||||||
: dto.cargoTypeId !== undefined
|
: dto.cargoTypeId !== undefined
|
||||||
? dto.cargoTypeId
|
? dto.cargoTypeId
|
||||||
: existing.cargoTypeId;
|
: existing.cargoTypeId;
|
||||||
|
// Re-validate the unit against the (possibly changed) shape before the
|
||||||
|
// scope is settled: for hazard the unit decides whether the rate is the
|
||||||
|
// lane-sold container surcharge or the global bulk one. Overweight is
|
||||||
|
// forced to PER_TON.
|
||||||
|
const requestedUnit = (dto.rateUnit as Rate['rateUnit']) ?? existing.rateUnit;
|
||||||
|
const rateUnit = await this.resolveRateUnit(
|
||||||
|
appliesTo,
|
||||||
|
trigger,
|
||||||
|
requestedUnit,
|
||||||
|
cargoKind,
|
||||||
|
cargoTypeId ?? null,
|
||||||
|
);
|
||||||
|
updates.rateUnit = rateUnit;
|
||||||
|
|
||||||
|
const keepsContainerType =
|
||||||
|
!isSurcharge ||
|
||||||
|
trigger === 'WITH_RETURN' ||
|
||||||
|
isContainerHazardRate(trigger, rateUnit) ||
|
||||||
|
(CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'CONTAINER');
|
||||||
|
const containerTypeId = !keepsContainerType
|
||||||
|
? null
|
||||||
|
: dto.containerTypeId !== undefined
|
||||||
|
? dto.containerTypeId
|
||||||
|
: existing.containerTypeId;
|
||||||
const tradeDirection =
|
const tradeDirection =
|
||||||
DIRECTED_SURCHARGE_TRIGGERS.includes(trigger)
|
this.isDirectedSurcharge(trigger, rateUnit)
|
||||||
? dto.tradeDirection !== undefined
|
? dto.tradeDirection !== undefined
|
||||||
? dto.tradeDirection
|
? dto.tradeDirection
|
||||||
: existing.tradeDirection
|
: existing.tradeDirection
|
||||||
@@ -868,6 +929,7 @@ export class RatesService {
|
|||||||
this.assertScopeCoherent({
|
this.assertScopeCoherent({
|
||||||
appliesTo,
|
appliesTo,
|
||||||
trigger,
|
trigger,
|
||||||
|
rateUnit,
|
||||||
tradeDirection: updates.tradeDirection,
|
tradeDirection: updates.tradeDirection,
|
||||||
intercityKind,
|
intercityKind,
|
||||||
cargoKind,
|
cargoKind,
|
||||||
@@ -879,6 +941,7 @@ export class RatesService {
|
|||||||
const yardScope = await this.resolveYardScope({
|
const yardScope = await this.resolveYardScope({
|
||||||
appliesTo,
|
appliesTo,
|
||||||
trigger,
|
trigger,
|
||||||
|
rateUnit,
|
||||||
tradeDirection: updates.tradeDirection,
|
tradeDirection: updates.tradeDirection,
|
||||||
originYardId:
|
originYardId:
|
||||||
dto.originYardId !== undefined ? dto.originYardId : existing.originYardId,
|
dto.originYardId !== undefined ? dto.originYardId : existing.originYardId,
|
||||||
@@ -910,18 +973,6 @@ export class RatesService {
|
|||||||
});
|
});
|
||||||
updates.rateType = rateType;
|
updates.rateType = rateType;
|
||||||
|
|
||||||
// Re-validate the unit against the (possibly changed) shape; overweight is
|
|
||||||
// forced to PER_TON.
|
|
||||||
const requestedUnit = (dto.rateUnit as Rate['rateUnit']) ?? existing.rateUnit;
|
|
||||||
const rateUnit = await this.resolveRateUnit(
|
|
||||||
appliesTo,
|
|
||||||
trigger,
|
|
||||||
requestedUnit,
|
|
||||||
cargoKind,
|
|
||||||
updates.cargoTypeId,
|
|
||||||
);
|
|
||||||
updates.rateUnit = rateUnit;
|
|
||||||
|
|
||||||
const { minKm, maxKm } = this.resolveLastMileBand({
|
const { minKm, maxKm } = this.resolveLastMileBand({
|
||||||
appliesTo,
|
appliesTo,
|
||||||
rateUnit,
|
rateUnit,
|
||||||
@@ -948,7 +999,7 @@ export class RatesService {
|
|||||||
// Guard the pattern uniqueness for the new identity, ignoring this row.
|
// Guard the pattern uniqueness for the new identity, ignoring this row.
|
||||||
await this.assertNoDuplicatePattern({
|
await this.assertNoDuplicatePattern({
|
||||||
rateType,
|
rateType,
|
||||||
...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }),
|
...(this.resolvesSingleRate(appliesTo, trigger, rateUnit) ? {} : { rateUnit }),
|
||||||
shippingLineCompanyId,
|
shippingLineCompanyId,
|
||||||
containerTypeId: updates.containerTypeId,
|
containerTypeId: updates.containerTypeId,
|
||||||
cargoTypeId: updates.cargoTypeId,
|
cargoTypeId: updates.cargoTypeId,
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
import { CrewDutyRole } from './entities/train-crew-assignment.entity';
|
||||||
|
import { TrainCrewRole } from './entities/train-crew-member.entity';
|
||||||
|
import {
|
||||||
|
AssignmentFacts,
|
||||||
|
CorridorContext,
|
||||||
|
CorridorYard,
|
||||||
|
CrewDemandInput,
|
||||||
|
legAllowsNationality,
|
||||||
|
overtimeHours,
|
||||||
|
specializedRequirements,
|
||||||
|
technicianRequirement,
|
||||||
|
validateCrewComposition,
|
||||||
|
} from './crew-composition.rules';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A slice of the real corridor, using the production display_order values:
|
||||||
|
* GMP 3, Feto 7, Meiso 10, Dire Dawa 12, Nagad 19.
|
||||||
|
*/
|
||||||
|
const YARD: Record<string, CorridorYard> = {
|
||||||
|
GMP: { id: 'y-gmp', label: 'GMP', country: 'Ethiopia', displayOrder: 3 },
|
||||||
|
FETO: { id: 'y-feto', label: 'Feto', country: 'Ethiopia', displayOrder: 7 },
|
||||||
|
MEISO: { id: 'y-meiso', label: 'Meiso', country: 'Ethiopia', displayOrder: 10 },
|
||||||
|
DIRE_DAWA: { id: 'y-dd', label: 'Dire Dawa', country: 'Ethiopia', displayOrder: 12 },
|
||||||
|
NAGAD: { id: 'y-nagad', label: 'Nagad', country: 'Djibouti', displayOrder: 19 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const CORRIDOR: CorridorContext = {
|
||||||
|
yards: new Map(Object.values(YARD).map((y) => [y.id, y])),
|
||||||
|
originOrder: YARD.GMP.displayOrder,
|
||||||
|
destinationOrder: YARD.NAGAD.displayOrder,
|
||||||
|
direDawaOrder: YARD.DIRE_DAWA.displayOrder,
|
||||||
|
};
|
||||||
|
|
||||||
|
const NO_DEMAND: CrewDemandInput = {
|
||||||
|
hasBadOrderWagon: false,
|
||||||
|
badOrderWagonLabels: [],
|
||||||
|
hasReeferCargo: false,
|
||||||
|
reeferSources: [],
|
||||||
|
hasHazmatCargo: false,
|
||||||
|
hazmatSources: [],
|
||||||
|
hasBreakBulkCargo: false,
|
||||||
|
breakBulkSources: [],
|
||||||
|
hasLivestockCargo: false,
|
||||||
|
livestockSources: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
let seq = 0;
|
||||||
|
const driver = (
|
||||||
|
nationality: 'ETHIOPIAN' | 'DJIBOUTIAN',
|
||||||
|
from: CorridorYard,
|
||||||
|
to: CorridorYard,
|
||||||
|
dutyRole: CrewDutyRole,
|
||||||
|
): AssignmentFacts => ({
|
||||||
|
crewMemberId: `driver-${++seq}`,
|
||||||
|
role: TrainCrewRole.TRAIN_DRIVER,
|
||||||
|
dutyRole,
|
||||||
|
fromYardId: from.id,
|
||||||
|
toYardId: to.id,
|
||||||
|
nationality,
|
||||||
|
memberName: `Driver ${seq}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const crewOfRole = (role: TrainCrewRole, count: number): AssignmentFacts[] =>
|
||||||
|
Array.from({ length: count }, () => ({
|
||||||
|
crewMemberId: `member-${++seq}`,
|
||||||
|
role,
|
||||||
|
nationality: 'ETHIOPIAN',
|
||||||
|
memberName: `Member ${seq}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const codes = (result: { issues: Array<{ code: string }> }) =>
|
||||||
|
result.issues.map((i) => i.code);
|
||||||
|
|
||||||
|
describe('crew composition rules (ITLMS Rolling Stock)', () => {
|
||||||
|
const validate = (
|
||||||
|
assignments: AssignmentFacts[],
|
||||||
|
demand: CrewDemandInput = NO_DEMAND,
|
||||||
|
) => validateCrewComposition(assignments, demand, CORRIDOR);
|
||||||
|
|
||||||
|
/** One Ethiopian Primary over the whole route — the minimum viable crew. */
|
||||||
|
const soloPrimary = () =>
|
||||||
|
driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.PRIMARY);
|
||||||
|
|
||||||
|
describe('free-form crew sizing', () => {
|
||||||
|
it('accepts a single driver working the whole corridor', () => {
|
||||||
|
const result = validate([soloPrimary()]);
|
||||||
|
expect(result.issues).toEqual([]);
|
||||||
|
expect(result.complete).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([1, 3, 4, 6, 8])('accepts a crew of %i drivers on one leg', (count) => {
|
||||||
|
const drivers = [
|
||||||
|
soloPrimary(),
|
||||||
|
...Array.from({ length: count - 1 }, () =>
|
||||||
|
driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.ASSISTANT),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
expect(validate(drivers).complete).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts any number of federal police, including none', () => {
|
||||||
|
for (const count of [0, 1, 4, 9]) {
|
||||||
|
const result = validate([
|
||||||
|
soloPrimary(),
|
||||||
|
...crewOfRole(TrainCrewRole.FEDERAL_POLICE, count),
|
||||||
|
]);
|
||||||
|
expect(result.complete).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires at least one driver', () => {
|
||||||
|
const result = validate(crewOfRole(TrainCrewRole.FEDERAL_POLICE, 4));
|
||||||
|
expect(codes(result)).toContain('DRIVER_COUNT');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('yard-to-yard legs', () => {
|
||||||
|
it('lets staff hand over at any intermediate yard', () => {
|
||||||
|
// Three legs the old fixed segments could not express: GMP–Feto,
|
||||||
|
// Feto–Meiso, Meiso–Nagad.
|
||||||
|
const result = validate([
|
||||||
|
driver('ETHIOPIAN', YARD.GMP, YARD.FETO, CrewDutyRole.PRIMARY),
|
||||||
|
driver('ETHIOPIAN', YARD.FETO, YARD.MEISO, CrewDutyRole.PRIMARY),
|
||||||
|
driver('ETHIOPIAN', YARD.MEISO, YARD.NAGAD, CrewDutyRole.PRIMARY),
|
||||||
|
]);
|
||||||
|
expect(result.issues).toEqual([]);
|
||||||
|
expect(result.complete).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a leg with the same yard at both ends', () => {
|
||||||
|
const result = validate([
|
||||||
|
driver('ETHIOPIAN', YARD.FETO, YARD.FETO, CrewDutyRole.PRIMARY),
|
||||||
|
]);
|
||||||
|
expect(codes(result)).toContain('DRIVER_LEG_EMPTY');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a yard outside the schedule route', () => {
|
||||||
|
const outside: CorridorYard = {
|
||||||
|
id: 'y-sebeta',
|
||||||
|
label: 'Sebeta',
|
||||||
|
country: 'Ethiopia',
|
||||||
|
displayOrder: 1, // before the GMP origin
|
||||||
|
};
|
||||||
|
const corridor: CorridorContext = {
|
||||||
|
...CORRIDOR,
|
||||||
|
yards: new Map([...(CORRIDOR.yards ?? []), [outside.id, outside]]),
|
||||||
|
};
|
||||||
|
const result = validateCrewComposition(
|
||||||
|
[driver('ETHIOPIAN', outside, YARD.NAGAD, CrewDutyRole.PRIMARY)],
|
||||||
|
NO_DEMAND,
|
||||||
|
corridor,
|
||||||
|
);
|
||||||
|
expect(codes(result)).toContain('LEG_OUTSIDE_ROUTE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires a from-yard, to-yard and duty role on every driver', () => {
|
||||||
|
const result = validate([
|
||||||
|
{
|
||||||
|
crewMemberId: 'd1',
|
||||||
|
role: TrainCrewRole.TRAIN_DRIVER,
|
||||||
|
nationality: 'ETHIOPIAN',
|
||||||
|
memberName: 'Unslotted Driver',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(codes(result)).toContain('DRIVER_SLOT_INCOMPLETE');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('§1.1 territorial boundary', () => {
|
||||||
|
const dd = YARD.DIRE_DAWA.displayOrder;
|
||||||
|
|
||||||
|
it('lets a Djibouti driver work at or beyond Dire Dawa', () => {
|
||||||
|
expect(legAllowsNationality(YARD.DIRE_DAWA, YARD.NAGAD, 'DJIBOUTIAN', dd)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bars a Djibouti driver from any leg west of Dire Dawa', () => {
|
||||||
|
expect(legAllowsNationality(YARD.GMP, YARD.DIRE_DAWA, 'DJIBOUTIAN', dd)).toBe(false);
|
||||||
|
expect(legAllowsNationality(YARD.FETO, YARD.MEISO, 'DJIBOUTIAN', dd)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves Ethiopian drivers unrestricted', () => {
|
||||||
|
expect(legAllowsNationality(YARD.GMP, YARD.NAGAD, 'ETHIOPIAN', dd)).toBe(true);
|
||||||
|
expect(legAllowsNationality(YARD.DIRE_DAWA, YARD.NAGAD, 'ETHIOPIAN', dd)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags a Djibouti driver placed on a western leg', () => {
|
||||||
|
const result = validate([
|
||||||
|
driver('DJIBOUTIAN', YARD.GMP, YARD.FETO, CrewDutyRole.PRIMARY),
|
||||||
|
]);
|
||||||
|
expect(codes(result)).toContain('TERRITORIAL_BOUNDARY');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts the documented split: Ethiopians west, Djiboutians east', () => {
|
||||||
|
const result = validate([
|
||||||
|
driver('ETHIOPIAN', YARD.GMP, YARD.DIRE_DAWA, CrewDutyRole.PRIMARY),
|
||||||
|
driver('ETHIOPIAN', YARD.GMP, YARD.DIRE_DAWA, CrewDutyRole.ASSISTANT),
|
||||||
|
driver('DJIBOUTIAN', YARD.DIRE_DAWA, YARD.NAGAD, CrewDutyRole.PRIMARY),
|
||||||
|
driver('DJIBOUTIAN', YARD.DIRE_DAWA, YARD.NAGAD, CrewDutyRole.ASSISTANT),
|
||||||
|
]);
|
||||||
|
expect(result.issues).toEqual([]);
|
||||||
|
expect(result.runType).toBe('LONG_RUN');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('one Primary per leg', () => {
|
||||||
|
it('rejects two Primaries on the same leg', () => {
|
||||||
|
const result = validate([
|
||||||
|
driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.PRIMARY),
|
||||||
|
driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.PRIMARY),
|
||||||
|
]);
|
||||||
|
expect(codes(result)).toContain('DUPLICATE_PRIMARY');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a Primary on each of two different legs', () => {
|
||||||
|
const result = validate([
|
||||||
|
driver('ETHIOPIAN', YARD.GMP, YARD.DIRE_DAWA, CrewDutyRole.PRIMARY),
|
||||||
|
driver('ETHIOPIAN', YARD.DIRE_DAWA, YARD.NAGAD, CrewDutyRole.PRIMARY),
|
||||||
|
]);
|
||||||
|
expect(result.complete).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows many Assistants alongside one Primary', () => {
|
||||||
|
const result = validate([
|
||||||
|
driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.PRIMARY),
|
||||||
|
driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.ASSISTANT),
|
||||||
|
driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.ASSISTANT),
|
||||||
|
driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.BENCH_RELIEF),
|
||||||
|
]);
|
||||||
|
expect(result.complete).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires a Primary on every covered leg', () => {
|
||||||
|
const result = validate([
|
||||||
|
driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.ASSISTANT),
|
||||||
|
]);
|
||||||
|
expect(codes(result)).toContain('PRIMARY_MISSING');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('§1.1 run type', () => {
|
||||||
|
it('is a long run when the legs span the whole route', () => {
|
||||||
|
const result = validate([
|
||||||
|
driver('ETHIOPIAN', YARD.GMP, YARD.DIRE_DAWA, CrewDutyRole.PRIMARY),
|
||||||
|
driver('DJIBOUTIAN', YARD.DIRE_DAWA, YARD.NAGAD, CrewDutyRole.PRIMARY),
|
||||||
|
]);
|
||||||
|
expect(result.runType).toBe('LONG_RUN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a short run when the legs cover only part of the route', () => {
|
||||||
|
const result = validate([
|
||||||
|
driver('DJIBOUTIAN', YARD.DIRE_DAWA, YARD.NAGAD, CrewDutyRole.PRIMARY),
|
||||||
|
]);
|
||||||
|
expect(result.runType).toBe('SHORT_RUN');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('§1.2 technical maintenance crew', () => {
|
||||||
|
it('requires no technician when no bad-order wagon is attached', () => {
|
||||||
|
expect(technicianRequirement(NO_DEMAND).min).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forces one technician when a bad-order wagon is attached', () => {
|
||||||
|
const demand = {
|
||||||
|
...NO_DEMAND,
|
||||||
|
hasBadOrderWagon: true,
|
||||||
|
badOrderWagonLabels: ['WG-1042'],
|
||||||
|
};
|
||||||
|
expect(technicianRequirement(demand).min).toBe(1);
|
||||||
|
|
||||||
|
const result = validate([soloPrimary()], demand);
|
||||||
|
expect(codes(result)).toContain('TECHNICIAN_REQUIRED');
|
||||||
|
// The wagon that forced it is named, so the demand is explicable.
|
||||||
|
expect(result.issues.find((i) => i.code === 'TECHNICIAN_REQUIRED')?.message)
|
||||||
|
.toContain('WG-1042');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('§1.2 specialized cargo crew', () => {
|
||||||
|
it('asks for nothing when no specialized cargo is aboard', () => {
|
||||||
|
expect(specializedRequirements(NO_DEMAND)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires a reefer technician only when reefer cargo is aboard', () => {
|
||||||
|
const demand = { ...NO_DEMAND, hasReeferCargo: true, reeferSources: ['BK-1'] };
|
||||||
|
const rules = specializedRequirements(demand);
|
||||||
|
expect(rules).toHaveLength(1);
|
||||||
|
expect(rules[0].role).toBe(TrainCrewRole.REEFER_TECHNICIAN);
|
||||||
|
expect(rules[0].min).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks a hazmat run with no escort assigned', () => {
|
||||||
|
const result = validate([soloPrimary()], {
|
||||||
|
...NO_DEMAND,
|
||||||
|
hasHazmatCargo: true,
|
||||||
|
hazmatSources: ['BK-2024-0891'],
|
||||||
|
});
|
||||||
|
expect(codes(result)).toContain('SPECIALIZED_REQUIRED');
|
||||||
|
expect(result.complete).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes once the escort is assigned, at any count', () => {
|
||||||
|
for (const escorts of [1, 2, 5]) {
|
||||||
|
const result = validate(
|
||||||
|
[soloPrimary(), ...crewOfRole(TrainCrewRole.HAZMAT_ESCORT, escorts)],
|
||||||
|
{ ...NO_DEMAND, hasHazmatCargo: true, hazmatSources: ['BK-2024-0891'] },
|
||||||
|
);
|
||||||
|
expect(result.complete).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('duplicate seats', () => {
|
||||||
|
it('flags a member assigned twice on one run', () => {
|
||||||
|
const twice = crewOfRole(TrainCrewRole.FEDERAL_POLICE, 1)[0];
|
||||||
|
const result = validate([soloPrimary(), twice, twice]);
|
||||||
|
expect(codes(result)).toContain('DUPLICATE_MEMBER');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('§3.2 overtime hours', () => {
|
||||||
|
it('reproduces the documented worked example', () => {
|
||||||
|
// PDF: 500h worked against a 240h standard => 260h variance,
|
||||||
|
// split 156h at the 1.5x tier and 104h at the 1.75x tier.
|
||||||
|
expect(overtimeHours(500)).toEqual({
|
||||||
|
variance: 260,
|
||||||
|
tier1Hours: 156,
|
||||||
|
tier2Hours: 104,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports no overtime below the monthly standard', () => {
|
||||||
|
expect(overtimeHours(200)).toEqual({
|
||||||
|
variance: 0,
|
||||||
|
tier1Hours: 0,
|
||||||
|
tier2Hours: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('splits the variance 60/40 as a flat convention', () => {
|
||||||
|
const { tier1Hours, tier2Hours, variance } = overtimeHours(340);
|
||||||
|
expect(variance).toBe(100);
|
||||||
|
expect(tier1Hours).toBe(60);
|
||||||
|
expect(tier2Hours).toBe(40);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
import { TrainCrewRole } from './entities/train-crew-member.entity';
|
||||||
|
import {
|
||||||
|
CrewDutyRole,
|
||||||
|
CrewSegment,
|
||||||
|
} from './entities/train-crew-assignment.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ITLMS Rolling Stock §1.2 / §2 composition rules.
|
||||||
|
*
|
||||||
|
* One module, used by BOTH the assignment API and the dispatch guard, so the
|
||||||
|
* wizard and the departure gate can never disagree about whether a crew is
|
||||||
|
* complete. Pure functions over plain data — no repository access — so the
|
||||||
|
* caller decides what to load and this stays unit-testable.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Security-detail size (§1.2 names 4 federal police).
|
||||||
|
*
|
||||||
|
* Operations asked for free-form crewing, so the document's numbers are treated
|
||||||
|
* as the usual shape rather than a hard limit — any count is accepted and the
|
||||||
|
* typical value is surfaced as a hint in the UI.
|
||||||
|
*/
|
||||||
|
export const FEDERAL_POLICE_TYPICAL = 4;
|
||||||
|
|
||||||
|
/** Government monthly working-hour baseline (§3.1). */
|
||||||
|
export const MONTHLY_STANDARD_HOURS = 240;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* §3.2 tier split. The document fixes the day/night division as a flat 60/40 of
|
||||||
|
* the variance regardless of when the hours fell, and that is implemented as
|
||||||
|
* written rather than derived from real clock hours.
|
||||||
|
*/
|
||||||
|
export const OT_TIER_1_SHARE = 0.6;
|
||||||
|
export const OT_TIER_2_SHARE = 0.4;
|
||||||
|
export const OT_TIER_1_FACTOR = 1.5;
|
||||||
|
export const OT_TIER_2_FACTOR = 1.75;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Driving-crew size (§1.2 "3 or 4 Drivers").
|
||||||
|
*
|
||||||
|
* Operations asked for a free-form crew rather than the two fixed pairing cases
|
||||||
|
* of §2, so the document's 3-or-4 is treated as the usual shape, not a limit:
|
||||||
|
* any count within these bounds is accepted and each driver carries their own
|
||||||
|
* segment and duty role. MIN stays at 1 so a partially built crew still saves.
|
||||||
|
*/
|
||||||
|
export const DRIVER_COUNT_MIN = 1;
|
||||||
|
|
||||||
|
/** Typical driving-crew size per §1.2 — a hint in the UI, never enforced. */
|
||||||
|
export const DRIVER_COUNT_TYPICAL = [3, 4];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A corridor yard as the rules see it.
|
||||||
|
*
|
||||||
|
* `displayOrder` is the yard's place along the corridor (Sebeta 1 … DCT/SGTD
|
||||||
|
* 22), which is what makes "is this leg inside the schedule's span" and "does
|
||||||
|
* this leg cross into Djibouti" answerable without hard-coding station names.
|
||||||
|
*/
|
||||||
|
export interface CorridorYard {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
country: string;
|
||||||
|
displayOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dire Dawa is the handover point §1.1 draws the territorial line at. */
|
||||||
|
export const DIRE_DAWA_CODE = 'DIRE_DAWA';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The corridor a schedule runs on, as the rules need to see it: every yard by
|
||||||
|
* id, where the schedule starts and ends, and where Dire Dawa sits. Supplied by
|
||||||
|
* the caller so these functions stay pure and unit-testable.
|
||||||
|
*/
|
||||||
|
export interface CorridorContext {
|
||||||
|
yards?: Map<string, CorridorYard>;
|
||||||
|
originOrder?: number;
|
||||||
|
destinationOrder?: number;
|
||||||
|
direDawaOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* §1.1 territorial boundary: Djiboutian drivers work the Dire Dawa – Nagad
|
||||||
|
* corridor segment exclusively.
|
||||||
|
*
|
||||||
|
* Expressed against yards rather than a fixed segment name: a leg is open to a
|
||||||
|
* Djiboutian driver when it stays at or beyond Dire Dawa, so any handover point
|
||||||
|
* east of it works without naming the pair in code. The restriction is
|
||||||
|
* asymmetric on purpose — the document confines Djiboutian drivers but never
|
||||||
|
* bars Ethiopians from that stretch.
|
||||||
|
*/
|
||||||
|
export const legAllowsNationality = (
|
||||||
|
from: CorridorYard | undefined,
|
||||||
|
to: CorridorYard | undefined,
|
||||||
|
nationality: string,
|
||||||
|
direDawaOrder: number,
|
||||||
|
): boolean => {
|
||||||
|
if (nationality !== 'DJIBOUTIAN') return true;
|
||||||
|
if (!from || !to) return true; // Incomplete leg — a separate rule reports it.
|
||||||
|
// Both ends must sit at or beyond Dire Dawa, whichever way the train runs.
|
||||||
|
return Math.min(from.displayOrder, to.displayOrder) >= direDawaOrder;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Specialized-crew rules (§1.2), each keyed to what the train is carrying. */
|
||||||
|
export interface SpecializedRequirement {
|
||||||
|
role: TrainCrewRole;
|
||||||
|
/** Hard floor — 0 unless the cargo or consist forces someone aboard. */
|
||||||
|
min: number;
|
||||||
|
/** The count §1.2 suggests. A hint for the UI; nothing enforces it. */
|
||||||
|
typical: number;
|
||||||
|
/** Why this is required — surfaced verbatim so the demand is explicable. */
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the consist and its cargo demand, as detected from the schedule. */
|
||||||
|
export interface CrewDemandInput {
|
||||||
|
/** A defective / bad-order wagon is attached (§1.2 forces 1 technician). */
|
||||||
|
hasBadOrderWagon: boolean;
|
||||||
|
badOrderWagonLabels: string[];
|
||||||
|
hasReeferCargo: boolean;
|
||||||
|
reeferSources: string[];
|
||||||
|
hasHazmatCargo: boolean;
|
||||||
|
hazmatSources: string[];
|
||||||
|
hasBreakBulkCargo: boolean;
|
||||||
|
breakBulkSources: string[];
|
||||||
|
hasLivestockCargo: boolean;
|
||||||
|
livestockSources: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const listSources = (sources: string[]): string =>
|
||||||
|
sources.length ? ` (${sources.slice(0, 3).join(', ')}${sources.length > 3 ? '…' : ''})` : '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn detected cargo/consist facts into the crew the run must carry.
|
||||||
|
* Only triggered rows appear, so staff are never asked about cargo not aboard.
|
||||||
|
*/
|
||||||
|
export const specializedRequirements = (
|
||||||
|
demand: CrewDemandInput,
|
||||||
|
): SpecializedRequirement[] => {
|
||||||
|
const required: SpecializedRequirement[] = [];
|
||||||
|
if (demand.hasReeferCargo) {
|
||||||
|
required.push({
|
||||||
|
role: TrainCrewRole.REEFER_TECHNICIAN,
|
||||||
|
min: 1,
|
||||||
|
typical: 2,
|
||||||
|
reason: `Reefer cargo on board${listSources(demand.reeferSources)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (demand.hasHazmatCargo) {
|
||||||
|
required.push({
|
||||||
|
role: TrainCrewRole.HAZMAT_ESCORT,
|
||||||
|
min: 1,
|
||||||
|
typical: 2,
|
||||||
|
reason: `Dangerous / flammable cargo on board${listSources(demand.hazmatSources)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (demand.hasBreakBulkCargo) {
|
||||||
|
required.push({
|
||||||
|
role: TrainCrewRole.LASHING_INSPECTOR,
|
||||||
|
min: 1,
|
||||||
|
typical: 2,
|
||||||
|
reason: `Break-bulk cargo requiring lashing inspection${listSources(demand.breakBulkSources)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (demand.hasLivestockCargo) {
|
||||||
|
required.push({
|
||||||
|
role: TrainCrewRole.LIVESTOCK_HANDLER,
|
||||||
|
min: 1,
|
||||||
|
typical: 3,
|
||||||
|
reason: `Livestock shipment on board${listSources(demand.livestockSources)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return required;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Technician floor: 1 is mandatory only when a bad-order wagon is attached. */
|
||||||
|
export const technicianRequirement = (
|
||||||
|
demand: CrewDemandInput,
|
||||||
|
): SpecializedRequirement => ({
|
||||||
|
role: TrainCrewRole.TECHNICIAN,
|
||||||
|
min: demand.hasBadOrderWagon ? 1 : 0,
|
||||||
|
typical: 3,
|
||||||
|
reason: demand.hasBadOrderWagon
|
||||||
|
? `Defective / bad-order wagon attached${listSources(demand.badOrderWagonLabels)}`
|
||||||
|
: 'Optional technical maintenance crew',
|
||||||
|
});
|
||||||
|
|
||||||
|
/** One assignment, reduced to what the rules actually read. */
|
||||||
|
export interface AssignmentFacts {
|
||||||
|
crewMemberId: string;
|
||||||
|
role: TrainCrewRole;
|
||||||
|
dutyRole?: CrewDutyRole | null;
|
||||||
|
/** The leg this driver works, as two corridor yards. */
|
||||||
|
fromYardId?: string | null;
|
||||||
|
toYardId?: string | null;
|
||||||
|
nationality: string;
|
||||||
|
memberName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CrewValidationIssue {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CrewValidationResult {
|
||||||
|
/** True when every mandatory rule passes — the dispatch gate reads this. */
|
||||||
|
complete: boolean;
|
||||||
|
issues: CrewValidationIssue[];
|
||||||
|
/** Derived, never entered: one segment covered = short run, both = long run (§1.1). */
|
||||||
|
runType: 'SHORT_RUN' | 'LONG_RUN' | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a schedule's crew against §1.1 and §1.2.
|
||||||
|
*
|
||||||
|
* Returns issues rather than throwing: the wizard renders them as a live
|
||||||
|
* checklist while a partial crew is still being built, and only the dispatch
|
||||||
|
* guard treats a non-empty list as fatal.
|
||||||
|
*/
|
||||||
|
export const validateCrewComposition = (
|
||||||
|
assignments: AssignmentFacts[],
|
||||||
|
demand: CrewDemandInput,
|
||||||
|
corridor: CorridorContext = {},
|
||||||
|
): CrewValidationResult => {
|
||||||
|
const yards = corridor.yards ?? new Map<string, CorridorYard>();
|
||||||
|
const direDawaOrder = corridor.direDawaOrder ?? Number.POSITIVE_INFINITY;
|
||||||
|
const issues: CrewValidationIssue[] = [];
|
||||||
|
|
||||||
|
const drivers = assignments.filter((a) => a.role === TrainCrewRole.TRAIN_DRIVER);
|
||||||
|
|
||||||
|
if (drivers.length < DRIVER_COUNT_MIN) {
|
||||||
|
issues.push({
|
||||||
|
code: 'DRIVER_COUNT',
|
||||||
|
message: 'At least one driver must be assigned',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every driver needs a leg and a duty role — without them the run has no
|
||||||
|
// record of who drove which part of the corridor.
|
||||||
|
for (const driver of drivers) {
|
||||||
|
if (!driver.fromYardId || !driver.toYardId || !driver.dutyRole) {
|
||||||
|
issues.push({
|
||||||
|
code: 'DRIVER_SLOT_INCOMPLETE',
|
||||||
|
message: `${driver.memberName} needs a from-yard, a to-yard and a duty role`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (driver.fromYardId === driver.toYardId) {
|
||||||
|
issues.push({
|
||||||
|
code: 'DRIVER_LEG_EMPTY',
|
||||||
|
message: `${driver.memberName} has the same yard at both ends of their leg`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// A leg outside the schedule's own span would put a driver on track this
|
||||||
|
// train never runs.
|
||||||
|
if (corridor.originOrder !== undefined && corridor.destinationOrder !== undefined) {
|
||||||
|
const low = Math.min(corridor.originOrder, corridor.destinationOrder);
|
||||||
|
const high = Math.max(corridor.originOrder, corridor.destinationOrder);
|
||||||
|
const from = yards.get(driver.fromYardId);
|
||||||
|
const to = yards.get(driver.toYardId);
|
||||||
|
for (const yard of [from, to]) {
|
||||||
|
if (yard && (yard.displayOrder < low || yard.displayOrder > high)) {
|
||||||
|
issues.push({
|
||||||
|
code: 'LEG_OUTSIDE_ROUTE',
|
||||||
|
message: `${yard.label} is outside this schedule's route — ${driver.memberName}'s leg must stay between the origin and destination`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A leg cannot have two Primaries — someone must be in charge of each stretch
|
||||||
|
// and only one person can be. Assistants and relief drivers are unconstrained.
|
||||||
|
const legKey = (d: AssignmentFacts) => `${d.fromYardId}>${d.toYardId}`;
|
||||||
|
const legLabel = (d: AssignmentFacts) => {
|
||||||
|
const from = d.fromYardId ? yards.get(d.fromYardId)?.label : undefined;
|
||||||
|
const to = d.toYardId ? yards.get(d.toYardId)?.label : undefined;
|
||||||
|
return from && to ? `${from} – ${to}` : 'this leg';
|
||||||
|
};
|
||||||
|
|
||||||
|
const primariesByLeg = new Map<string, { names: string[]; label: string }>();
|
||||||
|
for (const driver of drivers) {
|
||||||
|
if (driver.dutyRole === CrewDutyRole.PRIMARY && driver.fromYardId && driver.toYardId) {
|
||||||
|
const key = legKey(driver);
|
||||||
|
const entry = primariesByLeg.get(key) ?? { names: [], label: legLabel(driver) };
|
||||||
|
entry.names.push(driver.memberName);
|
||||||
|
primariesByLeg.set(key, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [, entry] of primariesByLeg) {
|
||||||
|
if (entry.names.length > 1) {
|
||||||
|
issues.push({
|
||||||
|
code: 'DUPLICATE_PRIMARY',
|
||||||
|
message: `${entry.label} has more than one Primary Driver (${entry.names.join(', ')})`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each covered leg needs a Primary — an Assistant alone cannot run it.
|
||||||
|
const coveredLegs = new Map<string, string>();
|
||||||
|
for (const driver of drivers) {
|
||||||
|
if (driver.fromYardId && driver.toYardId) {
|
||||||
|
coveredLegs.set(legKey(driver), legLabel(driver));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [key, label] of coveredLegs) {
|
||||||
|
if (!primariesByLeg.has(key)) {
|
||||||
|
issues.push({
|
||||||
|
code: 'PRIMARY_MISSING',
|
||||||
|
message: `${label} has no Primary Driver assigned`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// §1.1 territorial boundary — Djibouti drivers stay at or beyond Dire Dawa.
|
||||||
|
for (const driver of drivers) {
|
||||||
|
const from = driver.fromYardId ? yards.get(driver.fromYardId) : undefined;
|
||||||
|
const to = driver.toYardId ? yards.get(driver.toYardId) : undefined;
|
||||||
|
if (!legAllowsNationality(from, to, driver.nationality, direDawaOrder)) {
|
||||||
|
issues.push({
|
||||||
|
code: 'TERRITORIAL_BOUNDARY',
|
||||||
|
message: `${driver.memberName} is a Djibouti driver and may only work legs from Dire Dawa eastward`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// §1.2 names 4 federal police, 1-3 technicians and so on. Those counts are
|
||||||
|
// no longer enforced: operations crew each run to its own need, so any number
|
||||||
|
// of any role is accepted. What still holds is what makes a run coherent —
|
||||||
|
// a driver with a segment and duty role, one Primary per segment, and the
|
||||||
|
// specialized crew the cargo actually demands.
|
||||||
|
|
||||||
|
// §1.2 technical maintenance crew: a bad-order wagon still forces at least
|
||||||
|
// one technician — that rule is about safety, not crew sizing, so it stays.
|
||||||
|
const technicianRule = technicianRequirement(demand);
|
||||||
|
const technicians = assignments.filter((a) => a.role === TrainCrewRole.TECHNICIAN).length;
|
||||||
|
if (technicians < technicianRule.min) {
|
||||||
|
issues.push({
|
||||||
|
code: 'TECHNICIAN_REQUIRED',
|
||||||
|
message: `At least ${technicianRule.min} technician required — ${technicianRule.reason}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// §1.2 specialized cargo crew: the floor stays (hazmat aboard means an escort
|
||||||
|
// rides along) but the upper bound is gone — how many is operations' call.
|
||||||
|
for (const rule of specializedRequirements(demand)) {
|
||||||
|
const count = assignments.filter((a) => a.role === rule.role).length;
|
||||||
|
if (count < rule.min) {
|
||||||
|
issues.push({
|
||||||
|
code: 'SPECIALIZED_REQUIRED',
|
||||||
|
message: `At least ${rule.min} ${labelRole(rule.role)} required — ${rule.reason}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nobody may hold two seats on the same run.
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const a of assignments) {
|
||||||
|
if (seen.has(a.crewMemberId)) {
|
||||||
|
issues.push({
|
||||||
|
code: 'DUPLICATE_MEMBER',
|
||||||
|
message: `${a.memberName} is assigned more than once on this run`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
seen.add(a.crewMemberId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
complete: issues.length === 0,
|
||||||
|
issues,
|
||||||
|
runType: deriveRunType(drivers, corridor, yards),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* §1.1 run type. A crew whose legs together span the schedule's whole route is
|
||||||
|
* a long run; anything shorter is a short run.
|
||||||
|
*/
|
||||||
|
const deriveRunType = (
|
||||||
|
drivers: AssignmentFacts[],
|
||||||
|
corridor: CorridorContext,
|
||||||
|
yards: Map<string, CorridorYard>,
|
||||||
|
): 'SHORT_RUN' | 'LONG_RUN' | null => {
|
||||||
|
const orders = drivers
|
||||||
|
.flatMap((d) => [d.fromYardId, d.toYardId])
|
||||||
|
.map((id) => (id ? yards.get(id)?.displayOrder : undefined))
|
||||||
|
.filter((o): o is number => o !== undefined);
|
||||||
|
if (!orders.length) return null;
|
||||||
|
if (corridor.originOrder === undefined || corridor.destinationOrder === undefined) {
|
||||||
|
return 'SHORT_RUN';
|
||||||
|
}
|
||||||
|
const routeLow = Math.min(corridor.originOrder, corridor.destinationOrder);
|
||||||
|
const routeHigh = Math.max(corridor.originOrder, corridor.destinationOrder);
|
||||||
|
const covered = Math.min(...orders) <= routeLow && Math.max(...orders) >= routeHigh;
|
||||||
|
return covered ? 'LONG_RUN' : 'SHORT_RUN';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const labelSegment = (segment: CrewSegment): string =>
|
||||||
|
({
|
||||||
|
[CrewSegment.INDODE_DIRE_DAWA]: 'Indode/GMP – Dire Dawa',
|
||||||
|
[CrewSegment.DIRE_DAWA_NAGAD]: 'Dire Dawa – Nagad',
|
||||||
|
[CrewSegment.FULL_CORRIDOR]: 'Full corridor',
|
||||||
|
})[segment];
|
||||||
|
|
||||||
|
export const labelDutyRole = (dutyRole: CrewDutyRole): string =>
|
||||||
|
({
|
||||||
|
[CrewDutyRole.PRIMARY]: 'Primary Driver',
|
||||||
|
[CrewDutyRole.ASSISTANT]: 'Assistant Driver',
|
||||||
|
[CrewDutyRole.BENCH_RELIEF]: 'Bench/Relief Driver',
|
||||||
|
})[dutyRole];
|
||||||
|
|
||||||
|
export const labelRole = (role: TrainCrewRole): string =>
|
||||||
|
({
|
||||||
|
[TrainCrewRole.TRAIN_DRIVER]: 'train driver',
|
||||||
|
[TrainCrewRole.FEDERAL_POLICE]: 'federal police',
|
||||||
|
[TrainCrewRole.TECHNICIAN]: 'technician',
|
||||||
|
[TrainCrewRole.REEFER_TECHNICIAN]: 'reefer technician',
|
||||||
|
[TrainCrewRole.HAZMAT_ESCORT]: 'HAZMAT escort',
|
||||||
|
[TrainCrewRole.LASHING_INSPECTOR]: 'lashing inspector',
|
||||||
|
[TrainCrewRole.LIVESTOCK_HANDLER]: 'livestock handler',
|
||||||
|
})[role];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* §3.2 overtime hours for one driver's month.
|
||||||
|
*
|
||||||
|
* Hours only, by design: no salary is stored anywhere in the platform, so the
|
||||||
|
* output stops at the two tier totals and finance applies the rates.
|
||||||
|
*/
|
||||||
|
export const overtimeHours = (
|
||||||
|
workedHours: number,
|
||||||
|
standardHours: number = MONTHLY_STANDARD_HOURS,
|
||||||
|
): { variance: number; tier1Hours: number; tier2Hours: number } => {
|
||||||
|
const variance = Math.max(0, workedHours - standardHours);
|
||||||
|
return {
|
||||||
|
variance,
|
||||||
|
tier1Hours: variance * OT_TIER_1_SHARE,
|
||||||
|
tier2Hours: variance * OT_TIER_2_SHARE,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsEnum,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
import { CrewDutyRole } from '../entities/train-crew-assignment.entity';
|
||||||
|
import { TrainCrewRole } from '../entities/train-crew-member.entity';
|
||||||
|
|
||||||
|
export class CrewAssignmentRowDto {
|
||||||
|
@IsUUID()
|
||||||
|
crewMemberId!: string;
|
||||||
|
|
||||||
|
@IsEnum(TrainCrewRole)
|
||||||
|
role!: TrainCrewRole;
|
||||||
|
|
||||||
|
/** Required for drivers, rejected as incomplete without it. */
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(CrewDutyRole)
|
||||||
|
dutyRole?: CrewDutyRole;
|
||||||
|
|
||||||
|
/** The leg this driver works — any two yards on the schedule's route. */
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
fromYardId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
toYardId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SaveCrewAssignmentsDto {
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CrewAssignmentRowDto)
|
||||||
|
assignments!: CrewAssignmentRowDto[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
|
||||||
|
import { TrainCrewMember, TrainCrewRole } from './train-crew-member.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy fixed corridor segments.
|
||||||
|
*
|
||||||
|
* Kept only so historic rows written before segments became yard-to-yard still
|
||||||
|
* read back. New assignments carry `fromYardId`/`toYardId` instead: staff pick
|
||||||
|
* any two yards on the corridor, so a leg is no longer limited to the three
|
||||||
|
* spans the original design hard-coded.
|
||||||
|
*/
|
||||||
|
export enum CrewSegment {
|
||||||
|
INDODE_DIRE_DAWA = 'INDODE_DIRE_DAWA',
|
||||||
|
DIRE_DAWA_NAGAD = 'DIRE_DAWA_NAGAD',
|
||||||
|
FULL_CORRIDOR = 'FULL_CORRIDOR',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Driver duty role for one run (§2). Null for non-driving crew. */
|
||||||
|
export enum CrewDutyRole {
|
||||||
|
PRIMARY = 'PRIMARY',
|
||||||
|
ASSISTANT = 'ASSISTANT',
|
||||||
|
BENCH_RELIEF = 'BENCH_RELIEF',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum CrewAssignmentStatus {
|
||||||
|
PLANNED = 'PLANNED',
|
||||||
|
CONFIRMED = 'CONFIRMED',
|
||||||
|
COMPLETED = 'COMPLETED',
|
||||||
|
REMOVED = 'REMOVED',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One roster member assigned to one train schedule.
|
||||||
|
*
|
||||||
|
* `role` is snapshotted from the roster at assignment time: a member who later
|
||||||
|
* changes role must not silently rewrite the crew of a run that already
|
||||||
|
* departed. `dutyRole` and `segment` live here rather than on the roster
|
||||||
|
* because they are properties of THIS run — a driver who is Primary on one
|
||||||
|
* trip is Assistant on the next. Crew sizes are free-form: operations size each
|
||||||
|
* run to its own need rather than to a fixed pairing case.
|
||||||
|
*
|
||||||
|
* Duty stamps feed the §3 monthly overtime totals. Per the agreed scope the
|
||||||
|
* platform reports OT hours only; no salary is stored anywhere, and the payroll
|
||||||
|
* conversion stays with finance.
|
||||||
|
*/
|
||||||
|
@Entity({ schema: 'freight', name: 'train_crew_assignments' })
|
||||||
|
@Index(['trainScheduleId'])
|
||||||
|
@Index(['crewMemberId'])
|
||||||
|
@Index(['status'])
|
||||||
|
export class TrainCrewAssignment extends BaseEntity {
|
||||||
|
@Column({ name: 'train_schedule_id', type: 'uuid' })
|
||||||
|
trainScheduleId!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'crew_member_id', type: 'uuid' })
|
||||||
|
crewMemberId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => TrainCrewMember, { onDelete: 'RESTRICT' })
|
||||||
|
@JoinColumn({ name: 'crew_member_id' })
|
||||||
|
crewMember?: TrainCrewMember;
|
||||||
|
|
||||||
|
@Column({ name: 'role', type: 'varchar', length: 32 })
|
||||||
|
role!: TrainCrewRole;
|
||||||
|
|
||||||
|
@Column({ name: 'duty_role', type: 'varchar', length: 16, nullable: true })
|
||||||
|
dutyRole?: CrewDutyRole | null;
|
||||||
|
|
||||||
|
/** Legacy fixed segment — null on every assignment written since yard legs. */
|
||||||
|
@Column({ name: 'segment', type: 'varchar', length: 24, nullable: true })
|
||||||
|
segment?: CrewSegment | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The leg this driver works, as two yards on the corridor.
|
||||||
|
*
|
||||||
|
* Free-form on purpose: operations pick any yard as a handover point, so a
|
||||||
|
* crew change at Meiso or Feto is expressible without a code change. The
|
||||||
|
* schedule's own origin and destination bound what staff may choose.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'from_yard_id', type: 'uuid', nullable: true })
|
||||||
|
fromYardId?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'to_yard_id', type: 'uuid', nullable: true })
|
||||||
|
toYardId?: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mandatory off-duty layover at Dire Dawa (§1.3). The document gives ~5 hours
|
||||||
|
* as a typical duration, not a rule, so nothing here enforces a length — the
|
||||||
|
* stamps are recorded and reported.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'layover_start_at', type: 'timestamptz', nullable: true })
|
||||||
|
layoverStartAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'layover_end_at', type: 'timestamptz', nullable: true })
|
||||||
|
layoverEndAt?: Date | null;
|
||||||
|
|
||||||
|
/** Worked span for this run — accumulated monthly for the §3 OT calculation. */
|
||||||
|
@Column({ name: 'duty_start_at', type: 'timestamptz', nullable: true })
|
||||||
|
dutyStartAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'duty_end_at', type: 'timestamptz', nullable: true })
|
||||||
|
dutyEndAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'status',
|
||||||
|
type: 'varchar',
|
||||||
|
length: 16,
|
||||||
|
default: CrewAssignmentStatus.PLANNED,
|
||||||
|
})
|
||||||
|
status!: CrewAssignmentStatus;
|
||||||
|
|
||||||
|
@Column({ name: 'notes', type: 'text', nullable: true })
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { Body, Controller, Get, Param, ParseUUIDPipe, Put, Query } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { BookingStaff } from '../../common/booking-guards';
|
||||||
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
|
import { SaveCrewAssignmentsDto } from './dto/save-crew-assignments.dto';
|
||||||
|
import { TrainCrewAssignmentService } from './train-crew-assignment.service';
|
||||||
|
|
||||||
|
@ApiTags('train-crew-assignments')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('train-schedules/:scheduleId/crew')
|
||||||
|
// Class gate lists every key its routes use: Nest runs class AND method
|
||||||
|
// guards, so a key missing here would deny before the route's own key runs.
|
||||||
|
@BookingStaff([FREIGHT_PERMS.trainCrew.view, FREIGHT_PERMS.trainCrew.assign])
|
||||||
|
export class TrainCrewAssignmentController {
|
||||||
|
constructor(private readonly service: TrainCrewAssignmentService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "A schedule's crew, the cargo-driven requirements, and rule validation",
|
||||||
|
})
|
||||||
|
getCrew(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) {
|
||||||
|
return this.service.getScheduleCrew(scheduleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('eligible-drivers')
|
||||||
|
@ApiOperation({ summary: 'Roster drivers eligible for a leg between two yards' })
|
||||||
|
eligibleDrivers(
|
||||||
|
@Param('scheduleId', ParseUUIDPipe) scheduleId: string,
|
||||||
|
@Query('fromYardId') fromYardId?: string,
|
||||||
|
@Query('toYardId') toYardId?: string,
|
||||||
|
) {
|
||||||
|
return this.service.eligibleDrivers(scheduleId, fromYardId, toYardId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('corridor-yards')
|
||||||
|
@ApiOperation({ summary: "Yards a driver leg may use on this schedule's route" })
|
||||||
|
corridorYards(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) {
|
||||||
|
return this.service.corridorYards(scheduleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put()
|
||||||
|
@BookingStaff(FREIGHT_PERMS.trainCrew.assign)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Replace a schedule's crew (an incomplete crew saves; dispatch is what blocks)",
|
||||||
|
})
|
||||||
|
save(
|
||||||
|
@Param('scheduleId', ParseUUIDPipe) scheduleId: string,
|
||||||
|
@Body() dto: SaveCrewAssignmentsDto,
|
||||||
|
) {
|
||||||
|
return this.service.saveAssignments(scheduleId, dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { DataSource, In, Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CrewAssignmentStatus,
|
||||||
|
TrainCrewAssignment,
|
||||||
|
} from './entities/train-crew-assignment.entity';
|
||||||
|
import {
|
||||||
|
TrainCrewMember,
|
||||||
|
TrainCrewRole,
|
||||||
|
TrainCrewStatus,
|
||||||
|
} from './entities/train-crew-member.entity';
|
||||||
|
import {
|
||||||
|
AssignmentFacts,
|
||||||
|
CorridorContext,
|
||||||
|
CorridorYard,
|
||||||
|
CrewDemandInput,
|
||||||
|
CrewValidationResult,
|
||||||
|
DIRE_DAWA_CODE,
|
||||||
|
labelRole,
|
||||||
|
legAllowsNationality,
|
||||||
|
specializedRequirements,
|
||||||
|
technicianRequirement,
|
||||||
|
validateCrewComposition,
|
||||||
|
} from './crew-composition.rules';
|
||||||
|
import { SaveCrewAssignmentsDto } from './dto/save-crew-assignments.dto';
|
||||||
|
|
||||||
|
/** Wagon statuses that mean "defective / bad order" for §1.2. */
|
||||||
|
const BAD_ORDER_WAGON_STATUSES = ['MAINTENANCE', 'DETAINED', 'OUT_OF_SERVICE'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cargo-type name fragments that mark a livestock shipment. Matched on the
|
||||||
|
* cargo type's name because no boolean flag for livestock exists yet — unlike
|
||||||
|
* reefer and hazardous, which bookings carry explicitly.
|
||||||
|
*/
|
||||||
|
const LIVESTOCK_NAME_HINTS = ['livestock', 'cattle', 'animal', 'poultry'];
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TrainCrewAssignmentService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(TrainCrewAssignment)
|
||||||
|
private readonly assignmentRepo: Repository<TrainCrewAssignment>,
|
||||||
|
@InjectRepository(TrainCrewMember)
|
||||||
|
private readonly memberRepo: Repository<TrainCrewMember>,
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Every assignment on a schedule, with the roster member joined. */
|
||||||
|
async listForSchedule(scheduleId: string): Promise<TrainCrewAssignment[]> {
|
||||||
|
return this.assignmentRepo.find({
|
||||||
|
where: {
|
||||||
|
trainScheduleId: scheduleId,
|
||||||
|
status: In([
|
||||||
|
CrewAssignmentStatus.PLANNED,
|
||||||
|
CrewAssignmentStatus.CONFIRMED,
|
||||||
|
CrewAssignmentStatus.COMPLETED,
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
relations: { crewMember: true },
|
||||||
|
order: { createdAt: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What this schedule's consist and cargo demand (§1.2).
|
||||||
|
*
|
||||||
|
* Read straight from the train set and its allocations rather than asked of
|
||||||
|
* the user: the wagons and bookings already say whether a bad-order wagon is
|
||||||
|
* attached and whether reefer, hazardous, break-bulk or livestock cargo is
|
||||||
|
* aboard, so the requirement is derived and every row can name its trigger.
|
||||||
|
*/
|
||||||
|
async detectDemand(scheduleId: string): Promise<CrewDemandInput> {
|
||||||
|
const badOrder: Array<{ label: string }> = await this.dataSource.query(
|
||||||
|
`
|
||||||
|
SELECT COALESCE(w.wagon_number, tsw.id::text) AS label
|
||||||
|
FROM freight.train_schedules ts
|
||||||
|
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
||||||
|
JOIN freight.train_set_wagons tsw ON tsw.train_set_id = tset.id
|
||||||
|
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
|
||||||
|
WHERE ts.id = $1
|
||||||
|
AND w.status = ANY($2)
|
||||||
|
`,
|
||||||
|
[scheduleId, BAD_ORDER_WAGON_STATUSES],
|
||||||
|
);
|
||||||
|
|
||||||
|
const cargo: Array<{
|
||||||
|
reference: string | null;
|
||||||
|
is_reefer: boolean;
|
||||||
|
is_hazardous: boolean;
|
||||||
|
load_type: string | null;
|
||||||
|
cargo_type_name: string | null;
|
||||||
|
}> = await this.dataSource.query(
|
||||||
|
`
|
||||||
|
SELECT DISTINCT
|
||||||
|
b.reference,
|
||||||
|
b.is_reefer,
|
||||||
|
b.is_hazardous,
|
||||||
|
wba.load_type,
|
||||||
|
ct.cargo_type_name
|
||||||
|
FROM freight.train_schedules ts
|
||||||
|
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
||||||
|
JOIN freight.train_set_wagons tsw ON tsw.train_set_id = tset.id
|
||||||
|
JOIN freight.wagon_booking_allocations wba ON wba.train_set_wagon_id = tsw.id
|
||||||
|
JOIN freight.bookings b ON b.id = wba.booking_id
|
||||||
|
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
|
||||||
|
WHERE ts.id = $1
|
||||||
|
`,
|
||||||
|
[scheduleId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const label = (row: { reference: string | null }) => row.reference ?? 'a booking';
|
||||||
|
const isLivestock = (name: string | null) =>
|
||||||
|
Boolean(name) &&
|
||||||
|
LIVESTOCK_NAME_HINTS.some((hint) => name!.toLowerCase().includes(hint));
|
||||||
|
|
||||||
|
const reefer = cargo.filter((c) => c.is_reefer);
|
||||||
|
const hazmat = cargo.filter((c) => c.is_hazardous);
|
||||||
|
// Break-bulk rides as a bulk allocation rather than a container.
|
||||||
|
const breakBulk = cargo.filter((c) => c.load_type === 'BULK');
|
||||||
|
const livestock = cargo.filter((c) => isLivestock(c.cargo_type_name));
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasBadOrderWagon: badOrder.length > 0,
|
||||||
|
badOrderWagonLabels: badOrder.map((w) => w.label),
|
||||||
|
hasReeferCargo: reefer.length > 0,
|
||||||
|
reeferSources: reefer.map(label),
|
||||||
|
hasHazmatCargo: hazmat.length > 0,
|
||||||
|
hazmatSources: hazmat.map(label),
|
||||||
|
hasBreakBulkCargo: breakBulk.length > 0,
|
||||||
|
breakBulkSources: breakBulk.map(label),
|
||||||
|
hasLivestockCargo: livestock.length > 0,
|
||||||
|
livestockSources: livestock.map(label),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The corridor this schedule runs on: every active yard by id, plus where the
|
||||||
|
* schedule starts, ends, and where Dire Dawa sits. `display_order` is the
|
||||||
|
* yard's place along the line, which is what lets the rules answer "is this
|
||||||
|
* leg inside the route" and "does it cross the territorial boundary" without
|
||||||
|
* hard-coding station names.
|
||||||
|
*/
|
||||||
|
async loadCorridor(scheduleId: string): Promise<CorridorContext> {
|
||||||
|
const rows: Array<{
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
country: string;
|
||||||
|
display_order: number;
|
||||||
|
}> = await this.dataSource.query(
|
||||||
|
`SELECT id, code, label, country, display_order
|
||||||
|
FROM freight.yards
|
||||||
|
WHERE is_active = true
|
||||||
|
ORDER BY display_order ASC`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const yards = new Map<string, CorridorYard>(
|
||||||
|
rows.map((r) => [
|
||||||
|
r.id,
|
||||||
|
{
|
||||||
|
id: r.id,
|
||||||
|
label: r.label,
|
||||||
|
country: r.country,
|
||||||
|
displayOrder: Number(r.display_order),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [schedule]: Array<{
|
||||||
|
origin_station_id: string | null;
|
||||||
|
destination_station_id: string | null;
|
||||||
|
}> = await this.dataSource.query(
|
||||||
|
`SELECT origin_station_id, destination_station_id
|
||||||
|
FROM freight.train_schedules WHERE id = $1`,
|
||||||
|
[scheduleId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const orderOf = (id: string | null | undefined) =>
|
||||||
|
id ? yards.get(id)?.displayOrder : undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
yards,
|
||||||
|
originOrder: orderOf(schedule?.origin_station_id),
|
||||||
|
destinationOrder: orderOf(schedule?.destination_station_id),
|
||||||
|
direDawaOrder: rows.find((r) => r.code === DIRE_DAWA_CODE)
|
||||||
|
? Number(rows.find((r) => r.code === DIRE_DAWA_CODE)!.display_order)
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Yards a driver leg may use — every yard between origin and destination. */
|
||||||
|
async corridorYards(scheduleId: string): Promise<CorridorYard[]> {
|
||||||
|
const corridor = await this.loadCorridor(scheduleId);
|
||||||
|
const all = [...(corridor.yards?.values() ?? [])].sort(
|
||||||
|
(a, b) => a.displayOrder - b.displayOrder,
|
||||||
|
);
|
||||||
|
if (corridor.originOrder === undefined || corridor.destinationOrder === undefined) {
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
const low = Math.min(corridor.originOrder, corridor.destinationOrder);
|
||||||
|
const high = Math.max(corridor.originOrder, corridor.destinationOrder);
|
||||||
|
return all.filter((y) => y.displayOrder >= low && y.displayOrder <= high);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full picture for one schedule: who is assigned, what the cargo demands, and
|
||||||
|
* which composition rules currently fail. The wizard renders this directly.
|
||||||
|
*/
|
||||||
|
async getScheduleCrew(scheduleId: string) {
|
||||||
|
const [assignments, demand, corridor] = await Promise.all([
|
||||||
|
this.listForSchedule(scheduleId),
|
||||||
|
this.detectDemand(scheduleId),
|
||||||
|
this.loadCorridor(scheduleId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const validation = validateCrewComposition(
|
||||||
|
assignments.map(toFacts),
|
||||||
|
demand,
|
||||||
|
corridor,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
scheduleId,
|
||||||
|
assignments,
|
||||||
|
corridorYards: [...(corridor.yards?.values() ?? [])]
|
||||||
|
.filter((y) => {
|
||||||
|
if (corridor.originOrder === undefined || corridor.destinationOrder === undefined) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const low = Math.min(corridor.originOrder, corridor.destinationOrder);
|
||||||
|
const high = Math.max(corridor.originOrder, corridor.destinationOrder);
|
||||||
|
return y.displayOrder >= low && y.displayOrder <= high;
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.displayOrder - b.displayOrder),
|
||||||
|
demand,
|
||||||
|
requirements: {
|
||||||
|
technician: technicianRequirement(demand),
|
||||||
|
specialized: specializedRequirements(demand),
|
||||||
|
},
|
||||||
|
validation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace a schedule's crew in one transaction.
|
||||||
|
*
|
||||||
|
* A whole-set replace rather than per-row edits: the wizard submits the
|
||||||
|
* finished crew, and composition rules are only meaningful over the complete
|
||||||
|
* set. Saving an INCOMPLETE crew is allowed on purpose — ops build a roster
|
||||||
|
* over days, and §1.2 places the hard gate at departure, not at save time.
|
||||||
|
* Only structural errors (unknown member, wrong role, territorial breach)
|
||||||
|
* reject here; the rest surface as issues and block dispatch.
|
||||||
|
*/
|
||||||
|
async saveAssignments(
|
||||||
|
scheduleId: string,
|
||||||
|
dto: SaveCrewAssignmentsDto,
|
||||||
|
): Promise<CrewValidationResult> {
|
||||||
|
const rows = dto.assignments ?? [];
|
||||||
|
const memberIds = rows.map((r) => r.crewMemberId);
|
||||||
|
|
||||||
|
const corridor = await this.loadCorridor(scheduleId);
|
||||||
|
const members = memberIds.length
|
||||||
|
? await this.memberRepo.find({ where: { id: In(memberIds) } })
|
||||||
|
: [];
|
||||||
|
const byId = new Map(members.map((m) => [m.id, m]));
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const member = byId.get(row.crewMemberId);
|
||||||
|
if (!member) {
|
||||||
|
throw new NotFoundException(`Crew member ${row.crewMemberId} not found`);
|
||||||
|
}
|
||||||
|
if (member.status !== TrainCrewStatus.ACTIVE || !member.isActive) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`${member.firstName} ${member.lastName} is ${member.status} and cannot be assigned`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (row.role !== member.role) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`${member.firstName} ${member.lastName} is a ${labelRole(member.role)}, not a ${labelRole(row.role)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (member.role === TrainCrewRole.TRAIN_DRIVER) {
|
||||||
|
if (!row.fromYardId || !row.toYardId || !row.dutyRole) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Driver ${member.firstName} ${member.lastName} needs a from-yard, a to-yard and a duty role`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// §1.1 territorial boundary is structural — never persist a breach.
|
||||||
|
const from = corridor.yards?.get(row.fromYardId);
|
||||||
|
const to = corridor.yards?.get(row.toYardId);
|
||||||
|
if (
|
||||||
|
!legAllowsNationality(
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
member.nationality,
|
||||||
|
corridor.direDawaOrder ?? Number.POSITIVE_INFINITY,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`${member.firstName} ${member.lastName} is a Djibouti driver and may only work legs from Dire Dawa eastward`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
const repo = manager.getRepository(TrainCrewAssignment);
|
||||||
|
await repo.delete({ trainScheduleId: scheduleId });
|
||||||
|
if (rows.length) {
|
||||||
|
await repo.insert(
|
||||||
|
rows.map((row) => ({
|
||||||
|
trainScheduleId: scheduleId,
|
||||||
|
crewMemberId: row.crewMemberId,
|
||||||
|
role: row.role,
|
||||||
|
dutyRole: row.dutyRole ?? null,
|
||||||
|
fromYardId: row.fromYardId ?? null,
|
||||||
|
toYardId: row.toYardId ?? null,
|
||||||
|
status: CrewAssignmentStatus.PLANNED,
|
||||||
|
notes: row.notes ?? null,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const demand = await this.detectDemand(scheduleId);
|
||||||
|
const saved = await this.listForSchedule(scheduleId);
|
||||||
|
return validateCrewComposition(saved.map(toFacts), demand, corridor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch gate (§1.2 "prior to departure"). Throws with every unmet rule
|
||||||
|
* listed, so staff see the whole gap at once rather than one error per retry.
|
||||||
|
*/
|
||||||
|
async assertCrewReadyForDispatch(scheduleId: string): Promise<void> {
|
||||||
|
const { validation } = await this.getScheduleCrew(scheduleId);
|
||||||
|
if (!validation.complete) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Train crew is incomplete: ${validation.issues.map((i) => i.message).join('; ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Roster drivers eligible for a leg between two yards (§1.1). */
|
||||||
|
async eligibleDrivers(
|
||||||
|
scheduleId: string,
|
||||||
|
fromYardId?: string,
|
||||||
|
toYardId?: string,
|
||||||
|
): Promise<TrainCrewMember[]> {
|
||||||
|
const drivers = await this.memberRepo.find({
|
||||||
|
where: {
|
||||||
|
role: TrainCrewRole.TRAIN_DRIVER,
|
||||||
|
status: TrainCrewStatus.ACTIVE,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
order: { firstName: 'ASC' },
|
||||||
|
});
|
||||||
|
if (!fromYardId || !toYardId) return drivers;
|
||||||
|
|
||||||
|
const corridor = await this.loadCorridor(scheduleId);
|
||||||
|
const from = corridor.yards?.get(fromYardId);
|
||||||
|
const to = corridor.yards?.get(toYardId);
|
||||||
|
return drivers.filter((d) =>
|
||||||
|
legAllowsNationality(
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
d.nationality,
|
||||||
|
corridor.direDawaOrder ?? Number.POSITIVE_INFINITY,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reduce a persisted assignment to the facts the rules read. */
|
||||||
|
const toFacts = (a: TrainCrewAssignment): AssignmentFacts => ({
|
||||||
|
crewMemberId: a.crewMemberId,
|
||||||
|
role: a.role,
|
||||||
|
dutyRole: a.dutyRole,
|
||||||
|
fromYardId: a.fromYardId,
|
||||||
|
toYardId: a.toYardId,
|
||||||
|
nationality: a.crewMember?.nationality ?? '',
|
||||||
|
memberName: a.crewMember
|
||||||
|
? `${a.crewMember.firstName} ${a.crewMember.lastName}`
|
||||||
|
: 'A crew member',
|
||||||
|
});
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
import { TrainCrewAssignment } from './entities/train-crew-assignment.entity';
|
||||||
import { TrainCrewMember } from './entities/train-crew-member.entity';
|
import { TrainCrewMember } from './entities/train-crew-member.entity';
|
||||||
|
import { TrainCrewAssignmentController } from './train-crew-assignment.controller';
|
||||||
|
import { TrainCrewAssignmentService } from './train-crew-assignment.service';
|
||||||
import { TrainCrewController } from './train-crew.controller';
|
import { TrainCrewController } from './train-crew.controller';
|
||||||
import { TrainCrewService } from './train-crew.service';
|
import { TrainCrewService } from './train-crew.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([TrainCrewMember])],
|
imports: [TypeOrmModule.forFeature([TrainCrewMember, TrainCrewAssignment])],
|
||||||
providers: [TrainCrewService],
|
providers: [TrainCrewService, TrainCrewAssignmentService],
|
||||||
controllers: [TrainCrewController],
|
controllers: [TrainCrewController, TrainCrewAssignmentController],
|
||||||
exports: [TrainCrewService],
|
exports: [TrainCrewService, TrainCrewAssignmentService],
|
||||||
})
|
})
|
||||||
export class TrainCrewModule {}
|
export class TrainCrewModule {}
|
||||||
|
|||||||
@@ -289,6 +289,99 @@ export function bookingCloseCutoff(
|
|||||||
return new Date(departure.getTime() - offsetMinutes * 60_000);
|
return new Date(departure.getTime() - offsetMinutes * 60_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The schedule fields the close-offset reopen guard reads. */
|
||||||
|
export interface CloseOffsetReopenSchedule {
|
||||||
|
status: string;
|
||||||
|
direction?: string | null;
|
||||||
|
windowPhase?: string | null;
|
||||||
|
bookingWindowStatus?: string | null;
|
||||||
|
scheduledDepartureDate?: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CloseOffsetReopenCheck {
|
||||||
|
/** True when shortening the close offset is the one thing that reopens booking. */
|
||||||
|
eligible: boolean;
|
||||||
|
/** Why the schedule is not eligible; null when it is. */
|
||||||
|
reason: string | null;
|
||||||
|
/** Minutes before departure this schedule currently stops taking bookings. */
|
||||||
|
offsetMinutes: number | null;
|
||||||
|
/** The cutoff that shut booking (departure − offset); null without an offset. */
|
||||||
|
cutoffAt: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is this schedule's booking shut ONLY because of its close offset? That is the
|
||||||
|
* one case staff may fix from the board by shortening the offset (3 days → 1
|
||||||
|
* day, 2 hours, …) so the desk reopens before departure. Every other way a
|
||||||
|
* window ends stays closed: the train departed, it is full, it never had an
|
||||||
|
* offset (booking ran until departure), or the window is still mid-cycle.
|
||||||
|
*
|
||||||
|
* The last guard — "a cycle would fit before departure with no offset at all" —
|
||||||
|
* is what makes the offset the ONLY problem: when the desk's next opening lands
|
||||||
|
* after the train leaves, no offset change can help.
|
||||||
|
*/
|
||||||
|
export function closeOffsetReopenCheck(
|
||||||
|
schedule: CloseOffsetReopenSchedule,
|
||||||
|
cfg: {
|
||||||
|
importCloseOffsetMinutes?: number | null;
|
||||||
|
exportCloseOffsetMinutes?: number | null;
|
||||||
|
windowOpenHour: number;
|
||||||
|
windowCloseHour: number;
|
||||||
|
},
|
||||||
|
now: Date,
|
||||||
|
): CloseOffsetReopenCheck {
|
||||||
|
const departure = schedule.scheduledDepartureDate ?? null;
|
||||||
|
const offsetRaw =
|
||||||
|
schedule.direction === 'EXPORT'
|
||||||
|
? cfg.exportCloseOffsetMinutes
|
||||||
|
: cfg.importCloseOffsetMinutes;
|
||||||
|
const offsetMinutes = offsetRaw != null && offsetRaw > 0 ? offsetRaw : null;
|
||||||
|
const cutoffAt =
|
||||||
|
departure && offsetMinutes != null
|
||||||
|
? bookingCloseCutoff(departure, schedule.direction, cfg)
|
||||||
|
: null;
|
||||||
|
const no = (reason: string): CloseOffsetReopenCheck => ({
|
||||||
|
eligible: false,
|
||||||
|
reason,
|
||||||
|
offsetMinutes,
|
||||||
|
cutoffAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (schedule.status !== 'DRAFT' && schedule.status !== 'SCHEDULED') {
|
||||||
|
return no(`A ${schedule.status.toLowerCase()} train cannot reopen booking.`);
|
||||||
|
}
|
||||||
|
if (!departure || departure.getTime() <= now.getTime()) {
|
||||||
|
return no('This train has already departed (or has no departure date).');
|
||||||
|
}
|
||||||
|
if (offsetMinutes == null) {
|
||||||
|
return no(
|
||||||
|
'This train has no close offset — booking ran until departure, so there is nothing to shorten.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (schedule.windowPhase !== 'DONE') {
|
||||||
|
return no(
|
||||||
|
schedule.windowPhase == null
|
||||||
|
? 'This train does not run a managed booking window.'
|
||||||
|
: `Booking is not closed yet — the window is in its ${schedule.windowPhase} phase.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (schedule.bookingWindowStatus === 'FULL') {
|
||||||
|
return no(
|
||||||
|
'Booking closed because the train is full, not because of the close offset.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const hours: OfficeHours = {
|
||||||
|
windowOpenHour: cfg.windowOpenHour,
|
||||||
|
windowCloseHour: cfg.windowCloseHour,
|
||||||
|
};
|
||||||
|
if (nextCycleOpensAt(now, hours, departure) == null) {
|
||||||
|
return no(
|
||||||
|
'The desk would not reopen before departure even with no close offset — the offset is not what is blocking booking.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { eligible: true, reason: null, offsetMinutes, cutoffAt };
|
||||||
|
}
|
||||||
|
|
||||||
export interface InitialWindowTimes {
|
export interface InitialWindowTimes {
|
||||||
windowOpensAt: Date;
|
windowOpensAt: Date;
|
||||||
windowClosesAt: Date;
|
windowClosesAt: Date;
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ import { AvailableDaysQueryDto } from "../dto/available-days-query.dto";
|
|||||||
import { AvailableDaysForCargoQueryDto } from "../dto/available-days-for-cargo-query.dto";
|
import { AvailableDaysForCargoQueryDto } from "../dto/available-days-for-cargo-query.dto";
|
||||||
import { UpdateTrainSchedulingGlobalRulesDto } from "../dto/update-train-scheduling-global-rules.dto";
|
import { UpdateTrainSchedulingGlobalRulesDto } from "../dto/update-train-scheduling-global-rules.dto";
|
||||||
import { UpdateScheduleWindowRuleDto } from "../dto/update-schedule-window-rule.dto";
|
import { UpdateScheduleWindowRuleDto } from "../dto/update-schedule-window-rule.dto";
|
||||||
|
import { ReduceScheduleCloseOffsetDto } from "../dto/reduce-schedule-close-offset.dto";
|
||||||
import { UpdateScheduleDateDto } from "../dto/update-schedule-date.dto";
|
import { UpdateScheduleDateDto } from "../dto/update-schedule-date.dto";
|
||||||
import { MergeScheduleTrainDto } from "../dto/merge-schedule-train.dto";
|
import { MergeScheduleTrainDto } from "../dto/merge-schedule-train.dto";
|
||||||
import { UpdateScheduleTrainNumberDto } from "../dto/update-schedule-train-number.dto";
|
import { UpdateScheduleTrainNumberDto } from "../dto/update-schedule-train-number.dto";
|
||||||
@@ -871,7 +872,7 @@ export class TrainSchedulingController {
|
|||||||
@TrainSchedulingView()
|
@TrainSchedulingView()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
"Download the schedule's wagon list as an Excel workbook (one row per container: wagon, container, VGM, route, customer)",
|
"Download the schedule's wagon list as an Excel workbook (containers grouped by customer: wagon, container, size, route, company, transitor)",
|
||||||
})
|
})
|
||||||
async scheduleWagonListExport(
|
async scheduleWagonListExport(
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@@ -985,6 +986,20 @@ export class TrainSchedulingController {
|
|||||||
return this.trainSchedulingService.getContainerTrainScheduleById(id);
|
return this.trainSchedulingService.getContainerTrainScheduleById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch("schedules/:id/close-offset")
|
||||||
|
@TrainSchedulingUpdate()
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Shorten the booking-close offset of a schedule whose booking shut only because of that offset, so its window reopens before departure",
|
||||||
|
})
|
||||||
|
async reduceScheduleCloseOffset(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: ReduceScheduleCloseOffsetDto,
|
||||||
|
) {
|
||||||
|
await this.trainSchedulingService.reduceScheduleCloseOffset(id, dto);
|
||||||
|
return this.trainSchedulingService.getContainerTrainScheduleById(id);
|
||||||
|
}
|
||||||
|
|
||||||
@Patch("schedules/:id/schedule-date")
|
@Patch("schedules/:id/schedule-date")
|
||||||
@TrainSchedulingUpdate()
|
@TrainSchedulingUpdate()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsInt, Min } from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shorten the booking-close offset of ONE schedule whose booking shut only
|
||||||
|
* because of that offset (staff action on the ops board). The value replaces the
|
||||||
|
* schedule's frozen offset; 0 means "close at departure".
|
||||||
|
*/
|
||||||
|
export class ReduceScheduleCloseOffsetDto {
|
||||||
|
@ApiProperty({
|
||||||
|
example: 120,
|
||||||
|
description:
|
||||||
|
'New minutes-before-departure at which booking closes. Must be shorter than the current offset; 0 = close at departure.',
|
||||||
|
})
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
closeOffsetMinutes!: number;
|
||||||
|
}
|
||||||
@@ -3,6 +3,13 @@ import { Column, Entity } from 'typeorm';
|
|||||||
|
|
||||||
@Entity({ schema: 'freight', name: 'train_scheduling_global_rules' })
|
@Entity({ schema: 'freight', name: 'train_scheduling_global_rules' })
|
||||||
export class TrainSchedulingGlobalRules extends BaseEntity {
|
export class TrainSchedulingGlobalRules extends BaseEntity {
|
||||||
|
/**
|
||||||
|
* LEGACY — `max_train_length_meters`, `max_train_weight_tons` and
|
||||||
|
* `max_20ft_container_weight_tons` are no longer read by planning: train
|
||||||
|
* weight/length come from locomotive configuration and per-box ceilings from
|
||||||
|
* the rule engine's weight limit rules (`max_capacity_tons`). Kept only so
|
||||||
|
* existing rows keep loading.
|
||||||
|
*/
|
||||||
@Column({
|
@Column({
|
||||||
name: 'max_train_length_meters',
|
name: 'max_train_length_meters',
|
||||||
type: 'numeric',
|
type: 'numeric',
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { closeOffsetReopenCheck } from './batch-window.util';
|
||||||
|
import { TrainSchedulingService } from './services/train-scheduling.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Reopen booking by shortening the close offset": a train whose booking shut
|
||||||
|
* ONLY because of its close offset (3 days → cut to 1 day / 2 hours) gets its
|
||||||
|
* window re-armed. Every other closed state is refused. Pure guard first, then
|
||||||
|
* the service against stub repositories.
|
||||||
|
*/
|
||||||
|
describe('close-offset reopen', () => {
|
||||||
|
// Wednesday 2026-09-09 10:00 EAT (07:00Z). A 3-day offset closes Sunday 10:00 EAT.
|
||||||
|
const DEPARTURE = new Date('2026-09-09T07:00:00.000Z');
|
||||||
|
// Monday 2026-09-07 09:00 EAT — inside the desk day, past the 3-day cutoff.
|
||||||
|
const NOW = new Date('2026-09-07T06:00:00.000Z');
|
||||||
|
const THREE_DAYS = 3 * 1_440;
|
||||||
|
|
||||||
|
const cfg = {
|
||||||
|
importWindowLeadDays: 3,
|
||||||
|
exportBookingLeadHours: 24,
|
||||||
|
windowOpenHour: 8,
|
||||||
|
windowCloseHour: 17,
|
||||||
|
windowDurationHours: 3,
|
||||||
|
docReviewMinutes: 30,
|
||||||
|
paymentWindowMinutes: 60,
|
||||||
|
exportPaymentWindowMinutes: 60,
|
||||||
|
importCloseOffsetMinutes: THREE_DAYS,
|
||||||
|
exportCloseOffsetMinutes: THREE_DAYS,
|
||||||
|
};
|
||||||
|
|
||||||
|
const closedByOffset = (over: Record<string, unknown> = {}) => ({
|
||||||
|
id: 'S1',
|
||||||
|
reference: 'S-2026-00001',
|
||||||
|
status: 'SCHEDULED',
|
||||||
|
direction: 'IMPORT',
|
||||||
|
windowPhase: 'DONE',
|
||||||
|
bookingWindowStatus: 'CLOSED',
|
||||||
|
scheduledDepartureDate: DEPARTURE,
|
||||||
|
originStationId: 'Y-ADD',
|
||||||
|
destinationStationId: 'Y-DJ',
|
||||||
|
ruleWindowOpenHour: 8,
|
||||||
|
ruleWindowCloseHour: 17,
|
||||||
|
ruleWindowDurationHours: 3,
|
||||||
|
ruleImportWindowLeadDays: 3,
|
||||||
|
ruleExportBookingLeadHours: 24,
|
||||||
|
ruleImportCloseOffsetMinutes: THREE_DAYS,
|
||||||
|
ruleExportCloseOffsetMinutes: THREE_DAYS,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('closeOffsetReopenCheck', () => {
|
||||||
|
it('is eligible when DONE, not full, departure ahead, and an offset shut it', () => {
|
||||||
|
const check = closeOffsetReopenCheck(closedByOffset(), cfg, NOW);
|
||||||
|
expect(check.eligible).toBe(true);
|
||||||
|
expect(check.offsetMinutes).toBe(THREE_DAYS);
|
||||||
|
expect(check.cutoffAt?.toISOString()).toBe('2026-09-06T07:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['dispatched train', { status: 'DISPATCHED' }, /dispatched/i],
|
||||||
|
['already departed', { scheduledDepartureDate: new Date('2026-09-01T07:00:00.000Z') }, /departed/i],
|
||||||
|
['full train', { bookingWindowStatus: 'FULL' }, /full/i],
|
||||||
|
['window still open', { windowPhase: 'OPEN' }, /not closed yet/i],
|
||||||
|
['legacy row with no window', { windowPhase: null }, /managed booking window/i],
|
||||||
|
])('refuses a %s', (_label, over, reason) => {
|
||||||
|
const check = closeOffsetReopenCheck(closedByOffset(over), cfg, NOW);
|
||||||
|
expect(check.eligible).toBe(false);
|
||||||
|
expect(check.reason).toMatch(reason);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses when the schedule never had an offset (booking ran to departure)', () => {
|
||||||
|
const check = closeOffsetReopenCheck(
|
||||||
|
closedByOffset(),
|
||||||
|
{ ...cfg, importCloseOffsetMinutes: null },
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
expect(check.eligible).toBe(false);
|
||||||
|
expect(check.reason).toMatch(/no close offset/i);
|
||||||
|
expect(check.cutoffAt).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses when the desk could not reopen before departure even with no offset', () => {
|
||||||
|
// Tuesday 18:00 EAT, desk 8–17: next opening is Wednesday 08:00, but the
|
||||||
|
// train departs Wednesday 07:00 EAT — the offset is not the blocker.
|
||||||
|
const lateNow = new Date('2026-09-08T15:00:00.000Z');
|
||||||
|
const earlyDeparture = new Date('2026-09-09T04:00:00.000Z');
|
||||||
|
const check = closeOffsetReopenCheck(
|
||||||
|
closedByOffset({ scheduledDepartureDate: earlyDeparture }),
|
||||||
|
cfg,
|
||||||
|
lateNow,
|
||||||
|
);
|
||||||
|
expect(check.eligible).toBe(false);
|
||||||
|
expect(check.reason).toMatch(/would not reopen before departure/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the export offset for an EXPORT schedule', () => {
|
||||||
|
const check = closeOffsetReopenCheck(
|
||||||
|
closedByOffset({ direction: 'EXPORT' }),
|
||||||
|
{ ...cfg, importCloseOffsetMinutes: null, exportCloseOffsetMinutes: 120 },
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
expect(check.eligible).toBe(true);
|
||||||
|
expect(check.offsetMinutes).toBe(120);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TrainSchedulingService.reduceScheduleCloseOffset', () => {
|
||||||
|
type Fixture = {
|
||||||
|
schedule: Record<string, unknown> | null;
|
||||||
|
siblings?: Record<string, unknown>[];
|
||||||
|
now?: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeService = (fx: Fixture) => {
|
||||||
|
const updates: Array<{ id: string; patch: Record<string, unknown> }> = [];
|
||||||
|
const repo = {
|
||||||
|
update: jest.fn().mockImplementation(async (id: string, patch: Record<string, unknown>) => {
|
||||||
|
updates.push({ id, patch });
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const siblingsQb = {
|
||||||
|
where: jest.fn().mockReturnThis(),
|
||||||
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
|
getMany: jest.fn().mockResolvedValue(fx.siblings ?? []),
|
||||||
|
};
|
||||||
|
const dataSource = {
|
||||||
|
getRepository: jest.fn().mockReturnValue(repo),
|
||||||
|
manager: {
|
||||||
|
getRepository: jest
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue({ createQueryBuilder: () => siblingsQb }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const service = Object.create(
|
||||||
|
TrainSchedulingService.prototype,
|
||||||
|
) as TrainSchedulingService;
|
||||||
|
const emitted: string[] = [];
|
||||||
|
Object.assign(service, {
|
||||||
|
dataSource,
|
||||||
|
trainSchedulesRepository: {
|
||||||
|
findById: jest.fn().mockResolvedValue(fx.schedule),
|
||||||
|
},
|
||||||
|
getWindowConfig: jest.fn().mockResolvedValue(cfg),
|
||||||
|
emitWindowState: jest.fn().mockImplementation(async (id: string) => {
|
||||||
|
emitted.push(id);
|
||||||
|
}),
|
||||||
|
logger: { log: jest.fn(), warn: jest.fn() },
|
||||||
|
});
|
||||||
|
jest.useFakeTimers().setSystemTime(fx.now ?? NOW);
|
||||||
|
return { service, updates, emitted };
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => jest.useRealTimers());
|
||||||
|
|
||||||
|
it('404s on an unknown schedule', async () => {
|
||||||
|
const { service } = makeService({ schedule: null });
|
||||||
|
await expect(
|
||||||
|
service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 60 }),
|
||||||
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a train whose window is not shut by its offset', async () => {
|
||||||
|
const { service, updates } = makeService({
|
||||||
|
schedule: closedByOffset({ bookingWindowStatus: 'FULL' }),
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 60 }),
|
||||||
|
).rejects.toThrow(/full/i);
|
||||||
|
expect(updates).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses an offset that is not shorter than the current one', async () => {
|
||||||
|
const { service, updates } = makeService({ schedule: closedByOffset() });
|
||||||
|
await expect(
|
||||||
|
service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: THREE_DAYS }),
|
||||||
|
).rejects.toThrow(/shorter than the current 3 days/i);
|
||||||
|
expect(updates).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses an offset whose new cutoff is still before the next desk opening', async () => {
|
||||||
|
// 2 days before departure = Monday 10:00 EAT; now is Monday 09:00 so a
|
||||||
|
// cycle fits… but 2 days 1 hour (Mon 09:00) does not.
|
||||||
|
const { service } = makeService({ schedule: closedByOffset() });
|
||||||
|
await expect(
|
||||||
|
service.reduceScheduleCloseOffset('S1', {
|
||||||
|
closeOffsetMinutes: 2 * 1_440 + 60,
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shortens the offset, re-arms the window at now (desk open) and caps it at the new cutoff', async () => {
|
||||||
|
const { service, updates, emitted } = makeService({ schedule: closedByOffset() });
|
||||||
|
|
||||||
|
// 1 day before departure → new cutoff Tuesday 10:00 EAT.
|
||||||
|
await service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 1_440 });
|
||||||
|
|
||||||
|
expect(updates).toHaveLength(1);
|
||||||
|
const [{ id, patch }] = updates;
|
||||||
|
expect(id).toBe('S1');
|
||||||
|
expect(patch).toMatchObject({
|
||||||
|
ruleImportCloseOffsetMinutes: 1_440,
|
||||||
|
windowRuleCustom: true,
|
||||||
|
windowPhase: 'PRE_WINDOW',
|
||||||
|
docReviewCompletedAt: null,
|
||||||
|
docReviewEndsAt: null,
|
||||||
|
paymentPhaseEndsAt: null,
|
||||||
|
});
|
||||||
|
// Desk is open at 09:00 → reopens now; 3h cycle → 12:00 EAT (09:00Z).
|
||||||
|
expect((patch.windowOpensAt as Date).toISOString()).toBe(NOW.toISOString());
|
||||||
|
expect((patch.windowClosesAt as Date).toISOString()).toBe('2026-09-07T09:00:00.000Z');
|
||||||
|
expect(emitted).toEqual(['S1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores 0 as null (booking runs to departure) and caps the cycle at departure', async () => {
|
||||||
|
// Tuesday 16:00 EAT: 3h cycle would run past the 17:00 desk close.
|
||||||
|
const tueAfternoon = new Date('2026-09-08T13:00:00.000Z');
|
||||||
|
const { service, updates } = makeService({
|
||||||
|
schedule: closedByOffset(),
|
||||||
|
now: tueAfternoon,
|
||||||
|
});
|
||||||
|
await service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 0 });
|
||||||
|
const [{ patch }] = updates;
|
||||||
|
expect(patch.ruleImportCloseOffsetMinutes).toBeNull();
|
||||||
|
expect((patch.windowOpensAt as Date).toISOString()).toBe(tueAfternoon.toISOString());
|
||||||
|
// Desk close (17:00 EAT = 14:00Z) ends the cycle before departure.
|
||||||
|
expect((patch.windowClosesAt as Date).toISOString()).toBe('2026-09-08T14:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reopens route+day siblings shut by the same offset and leaves the rest alone', async () => {
|
||||||
|
const { service, updates } = makeService({
|
||||||
|
schedule: closedByOffset(),
|
||||||
|
siblings: [
|
||||||
|
closedByOffset({ id: 'S2' }),
|
||||||
|
// Already full — booking did not close because of the offset.
|
||||||
|
closedByOffset({ id: 'S3', bookingWindowStatus: 'FULL' }),
|
||||||
|
// Still mid-cycle — must keep the state its customers see.
|
||||||
|
closedByOffset({ id: 'S4', windowPhase: 'PAYMENT' }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 1_440 });
|
||||||
|
expect(updates.map((u) => u.id)).toEqual(['S1', 'S2']);
|
||||||
|
expect(updates[1].patch).toMatchObject({
|
||||||
|
ruleImportCloseOffsetMinutes: 1_440,
|
||||||
|
windowPhase: 'PRE_WINDOW',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an EXPORT reopen is a single FCFS window to the new cutoff and touches no sibling', async () => {
|
||||||
|
const { service, updates } = makeService({
|
||||||
|
schedule: closedByOffset({ direction: 'EXPORT' }),
|
||||||
|
siblings: [closedByOffset({ id: 'S2', direction: 'EXPORT' })],
|
||||||
|
});
|
||||||
|
await service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 120 });
|
||||||
|
expect(updates).toHaveLength(1);
|
||||||
|
const [{ patch }] = updates;
|
||||||
|
expect(patch).toMatchObject({
|
||||||
|
ruleExportCloseOffsetMinutes: 120,
|
||||||
|
windowPhase: 'PRE_WINDOW',
|
||||||
|
});
|
||||||
|
expect((patch.windowOpensAt as Date).toISOString()).toBe(NOW.toISOString());
|
||||||
|
// Departure 07:00Z − 2h.
|
||||||
|
expect((patch.windowClosesAt as Date).toISOString()).toBe('2026-09-09T05:00:00.000Z');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ import { Wagon } from '../../wagons/entities/wagon.entity';
|
|||||||
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||||
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
|
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
|
||||||
import { TrainSchedulingGlobalRules } from '../entities/train-scheduling-global-rules.entity';
|
import { TrainSchedulingGlobalRules } from '../entities/train-scheduling-global-rules.entity';
|
||||||
|
import { WeightLimitRule } from '../../rule-engine/entities/weight-limit-rule.entity';
|
||||||
import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity';
|
import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity';
|
||||||
import { TrainSchedulingService } from './train-scheduling.service';
|
import { TrainSchedulingService } from './train-scheduling.service';
|
||||||
|
|
||||||
@@ -2211,4 +2212,46 @@ describe('TrainSchedulingService', () => {
|
|||||||
expect(written.windowPhase).toBeUndefined();
|
expect(written.windowPhase).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('containerCapacityCeilingsByLine — weight limit rule capacity', () => {
|
||||||
|
const ceilings = (bookings: unknown[]) =>
|
||||||
|
(
|
||||||
|
service as never as {
|
||||||
|
containerCapacityCeilingsByLine: (b: unknown[]) => Promise<Record<string, number>>;
|
||||||
|
}
|
||||||
|
).containerCapacityCeilingsByLine(bookings);
|
||||||
|
|
||||||
|
it('maps each container line to its rule capacity, exact direction winning over BOTH', async () => {
|
||||||
|
const find = jest.fn().mockResolvedValue([
|
||||||
|
{ containerTypeId: 'ct-20', tradeDirection: 'BOTH', maxCapacityTons: '28.000' },
|
||||||
|
{ containerTypeId: 'ct-20', tradeDirection: 'EXPORT', maxCapacityTons: '26.000' },
|
||||||
|
{ containerTypeId: 'ct-40', tradeDirection: 'IMPORT', maxCapacityTons: null },
|
||||||
|
]);
|
||||||
|
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||||||
|
if (entity === WeightLimitRule) return { find };
|
||||||
|
throw new Error('unexpected repository');
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await ceilings([
|
||||||
|
{
|
||||||
|
tradeDirection: 'EXPORT',
|
||||||
|
bookingContainers: [
|
||||||
|
{ id: 'line-a', containerTypeId: 'ct-20' },
|
||||||
|
{ id: 'line-b', containerTypeId: 'ct-40' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ tradeDirection: 'IMPORT', bookingContainers: [{ id: 'line-c', containerTypeId: 'ct-20' }] },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toEqual({ 'line-a': 26, 'line-c': 28 });
|
||||||
|
expect(find).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('queries nothing when the bookings carry no container lines', async () => {
|
||||||
|
dataSource.getRepository.mockImplementation(() => {
|
||||||
|
throw new Error('should not be called');
|
||||||
|
});
|
||||||
|
await expect(ceilings([{ tradeDirection: 'EXPORT', bookingContainers: [] }])).resolves.toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-boo
|
|||||||
import { TrainScheduleBookingsRepository } from '../../train-schedules/train-schedule-bookings.repository';
|
import { TrainScheduleBookingsRepository } from '../../train-schedules/train-schedule-bookings.repository';
|
||||||
import { TrainSchedulesRepository } from '../../train-schedules/train-schedules.repository';
|
import { TrainSchedulesRepository } from '../../train-schedules/train-schedules.repository';
|
||||||
import { TrainCompositionRemovalLogRepository } from '../../train-schedules/train-composition-removal-log.repository';
|
import { TrainCompositionRemovalLogRepository } from '../../train-schedules/train-composition-removal-log.repository';
|
||||||
|
import { TrainCrewAssignmentService } from '../../train-crew/train-crew-assignment.service';
|
||||||
import { WagonAllocationBulkLoadsRepository } from '../../train-schedules/wagon-allocation-bulk-loads.repository';
|
import { WagonAllocationBulkLoadsRepository } from '../../train-schedules/wagon-allocation-bulk-loads.repository';
|
||||||
import { WagonAllocationContainerItemsRepository } from '../../train-schedules/wagon-allocation-container-items.repository';
|
import { WagonAllocationContainerItemsRepository } from '../../train-schedules/wagon-allocation-container-items.repository';
|
||||||
import { WagonBookingAllocationsRepository } from '../../train-schedules/wagon-booking-allocations.repository';
|
import { WagonBookingAllocationsRepository } from '../../train-schedules/wagon-booking-allocations.repository';
|
||||||
@@ -74,25 +75,13 @@ import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
|||||||
import { WagonTypesRepository } from '../../wagon-types/wagon-types.repository';
|
import { WagonTypesRepository } from '../../wagon-types/wagon-types.repository';
|
||||||
import { Wagon } from '../../wagons/entities/wagon.entity';
|
import { Wagon } from '../../wagons/entities/wagon.entity';
|
||||||
import { WagonEventInput, WagonHistoryService } from '../../wagon-history/wagon-history.service';
|
import { WagonEventInput, WagonHistoryService } from '../../wagon-history/wagon-history.service';
|
||||||
import { TabularExportService } from '../../exports/tabular-export.service';
|
import {
|
||||||
|
buildWagonListWorkbook,
|
||||||
|
groupWagonListLines,
|
||||||
|
WagonListLine,
|
||||||
|
} from '../utils/wagon-list-workbook.util';
|
||||||
|
|
||||||
/** One line of the schedule wagon-list export (raw SQL projection). */
|
/** One line of the schedule wagon-list export (raw SQL projection). */
|
||||||
interface ScheduleWagonListRow {
|
|
||||||
sequenceNo: number | null;
|
|
||||||
wagonNumber: string | null;
|
|
||||||
wagonType: string | null;
|
|
||||||
containerNumber: string | null;
|
|
||||||
containerSizeFt: number | null;
|
|
||||||
loadType: string | null;
|
|
||||||
status: string | null;
|
|
||||||
bulkCargoDescription: string | null;
|
|
||||||
/** numeric columns arrive as strings from pg. */
|
|
||||||
vgmTons: string | null;
|
|
||||||
originLabel: string | null;
|
|
||||||
destinationLabel: string | null;
|
|
||||||
bookingReference: string | null;
|
|
||||||
customerName: string | null;
|
|
||||||
}
|
|
||||||
import { AdjustScheduleConsistDto } from '../dto/adjust-schedule-consist.dto';
|
import { AdjustScheduleConsistDto } from '../dto/adjust-schedule-consist.dto';
|
||||||
import { AssignBookingsDto } from '../dto/assign-bookings.dto';
|
import { AssignBookingsDto } from '../dto/assign-bookings.dto';
|
||||||
import { CreateContainerTrainScheduleDto } from '../dto/create-container-train-schedule.dto';
|
import { CreateContainerTrainScheduleDto } from '../dto/create-container-train-schedule.dto';
|
||||||
@@ -111,6 +100,7 @@ import { PreviewBulkTrainScheduleDto } from '../dto/preview-bulk-train-schedule.
|
|||||||
import { PreviewContainerTrainScheduleDto } from '../dto/preview-container-train-schedule.dto';
|
import { PreviewContainerTrainScheduleDto } from '../dto/preview-container-train-schedule.dto';
|
||||||
import { PreviewTrainScheduleDto } from '../dto/preview-train-schedule.dto';
|
import { PreviewTrainScheduleDto } from '../dto/preview-train-schedule.dto';
|
||||||
import { TrainSchedulingGlobalRules } from '../entities/train-scheduling-global-rules.entity';
|
import { TrainSchedulingGlobalRules } from '../entities/train-scheduling-global-rules.entity';
|
||||||
|
import { WeightLimitRule } from '../../rule-engine/entities/weight-limit-rule.entity';
|
||||||
import { UpdateTrainSchedulingGlobalRulesDto } from '../dto/update-train-scheduling-global-rules.dto';
|
import { UpdateTrainSchedulingGlobalRulesDto } from '../dto/update-train-scheduling-global-rules.dto';
|
||||||
import {
|
import {
|
||||||
ImportDjiboutiOperation,
|
ImportDjiboutiOperation,
|
||||||
@@ -122,6 +112,7 @@ import {
|
|||||||
UploadImportDjiboutiDocumentDto,
|
UploadImportDjiboutiDocumentDto,
|
||||||
} from '../dto/import-djibouti-operation.dto';
|
} from '../dto/import-djibouti-operation.dto';
|
||||||
import { UpdateScheduleWindowRuleDto } from '../dto/update-schedule-window-rule.dto';
|
import { UpdateScheduleWindowRuleDto } from '../dto/update-schedule-window-rule.dto';
|
||||||
|
import { ReduceScheduleCloseOffsetDto } from '../dto/reduce-schedule-close-offset.dto';
|
||||||
import { UpdateScheduleDateDto } from '../dto/update-schedule-date.dto';
|
import { UpdateScheduleDateDto } from '../dto/update-schedule-date.dto';
|
||||||
import { MergeScheduleTrainDto } from '../dto/merge-schedule-train.dto';
|
import { MergeScheduleTrainDto } from '../dto/merge-schedule-train.dto';
|
||||||
import { UpdateScheduleTrainNumberDto } from '../dto/update-schedule-train-number.dto';
|
import { UpdateScheduleTrainNumberDto } from '../dto/update-schedule-train-number.dto';
|
||||||
@@ -159,6 +150,7 @@ import {
|
|||||||
validateMixedTrainLimitsPerEdge,
|
validateMixedTrainLimitsPerEdge,
|
||||||
MAX_TEU_SLOTS_PER_WAGON,
|
MAX_TEU_SLOTS_PER_WAGON,
|
||||||
type ContainerPlacementInput,
|
type ContainerPlacementInput,
|
||||||
|
type ContainerPlacementRules,
|
||||||
type WagonPlanSlot,
|
type WagonPlanSlot,
|
||||||
} from '../utils/wagon-plan.util';
|
} from '../utils/wagon-plan.util';
|
||||||
import {
|
import {
|
||||||
@@ -189,6 +181,8 @@ import {
|
|||||||
trainSetLocomotiveLimits,
|
trainSetLocomotiveLimits,
|
||||||
wagonTypeDimensionsFromEntity,
|
wagonTypeDimensionsFromEntity,
|
||||||
LocomotiveLimits,
|
LocomotiveLimits,
|
||||||
|
MAX_FALLBACK_LENGTH,
|
||||||
|
MAX_FALLBACK_WEIGHT,
|
||||||
WagonTypeDimensions,
|
WagonTypeDimensions,
|
||||||
} from '../train-capacity.util';
|
} from '../train-capacity.util';
|
||||||
import {
|
import {
|
||||||
@@ -204,12 +198,15 @@ import { orderConsistWagons } from '../consist-order.util';
|
|||||||
import {
|
import {
|
||||||
bookingCloseCutoff,
|
bookingCloseCutoff,
|
||||||
clampCloseToOfficeHours,
|
clampCloseToOfficeHours,
|
||||||
|
closeOffsetReopenCheck,
|
||||||
computeExportWindowTimes,
|
computeExportWindowTimes,
|
||||||
computeImportWindowTimes,
|
computeImportWindowTimes,
|
||||||
earliestSchedulableDeparture,
|
earliestSchedulableDeparture,
|
||||||
eatDay,
|
eatDay,
|
||||||
eatDayToUtc,
|
eatDayToUtc,
|
||||||
|
nextCycleOpensAt,
|
||||||
shiftEatDay,
|
shiftEatDay,
|
||||||
|
type OfficeHours,
|
||||||
} from '../batch-window.util';
|
} from '../batch-window.util';
|
||||||
import { TrainCheckpointEvent } from '../entities/train-checkpoint-event.entity';
|
import { TrainCheckpointEvent } from '../entities/train-checkpoint-event.entity';
|
||||||
import { BookingJourneyService } from '../booking-journey.service';
|
import { BookingJourneyService } from '../booking-journey.service';
|
||||||
@@ -246,6 +243,19 @@ const HANDLING_FIELDS = [
|
|||||||
|
|
||||||
type HandlingField = (typeof HANDLING_FIELDS)[number][0];
|
type HandlingField = (typeof HANDLING_FIELDS)[number][0];
|
||||||
|
|
||||||
|
/** "3 days" / "2 hours" / "45 minutes" for an error message. */
|
||||||
|
function describeMinutes(minutes: number): string {
|
||||||
|
if (minutes % 1_440 === 0) {
|
||||||
|
const d = minutes / 1_440;
|
||||||
|
return `${d} day${d === 1 ? '' : 's'}`;
|
||||||
|
}
|
||||||
|
if (minutes % 60 === 0) {
|
||||||
|
const h = minutes / 60;
|
||||||
|
return `${h} hour${h === 1 ? '' : 's'}`;
|
||||||
|
}
|
||||||
|
return `${minutes} minute${minutes === 1 ? '' : 's'}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Drops the keys a partial override left undefined, so `...` merges keep the base value. */
|
/** Drops the keys a partial override left undefined, so `...` merges keep the base value. */
|
||||||
function pickDefined<T extends object>(source: T): Partial<T> {
|
function pickDefined<T extends object>(source: T): Partial<T> {
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
@@ -273,6 +283,16 @@ function windowRuleSnapshot(cfg: BookingWindowConfig) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Wire shape of a close-offset reopen check (dates as ISO strings). */
|
||||||
|
function toCloseOffsetReopenInfo(check: ReturnType<typeof closeOffsetReopenCheck>) {
|
||||||
|
return {
|
||||||
|
eligible: check.eligible,
|
||||||
|
reason: check.reason,
|
||||||
|
offsetMinutes: check.offsetMinutes,
|
||||||
|
cutoffAt: check.cutoffAt ? check.cutoffAt.toISOString() : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The booking-window config a specific schedule runs under: its frozen rule
|
* The booking-window config a specific schedule runs under: its frozen rule
|
||||||
* snapshot (open/close hour, duration, lead, reopen gap) overlaid on the live
|
* snapshot (open/close hour, duration, lead, reopen gap) overlaid on the live
|
||||||
@@ -378,13 +398,13 @@ export interface UnassignedBookingsResponse {
|
|||||||
bookings: CompositionUnassignedBookingRow[];
|
bookings: CompositionUnassignedBookingRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_TRAIN_LIMITS: Required<TrainLimitConfig> = {
|
/**
|
||||||
maxWeightTons: 3500,
|
* Train weight/length come from locomotive configuration (the assigned set, or
|
||||||
maxLengthMeters: 760,
|
* the strongest in-service locomotive when none is assigned yet); per-box
|
||||||
maxWagonsPerTrain: Math.floor(760 / 14),
|
* container ceilings come from the rule engine's weight limit rules. Only the
|
||||||
max20ftContainerWeightTons: 30,
|
* 20ft pair-imbalance tolerance is a static default.
|
||||||
max20ftPairWeightDiffTons: 10,
|
*/
|
||||||
};
|
const DEFAULT_20FT_PAIR_WEIGHT_DIFF_TONS = 10;
|
||||||
|
|
||||||
/** Raw row shape for the booking-window queries (company- and contract-scoped). */
|
/** Raw row shape for the booking-window queries (company- and contract-scoped). */
|
||||||
interface BookingWindowRow {
|
interface BookingWindowRow {
|
||||||
@@ -411,6 +431,12 @@ interface BookingWindowRow {
|
|||||||
route_stations: string[] | null;
|
route_stations: string[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch crew gate (ITLMS Rolling Stock §1.2). Temporarily disabled so a
|
||||||
|
* train can depart with no crew assigned; set back to true to enforce.
|
||||||
|
*/
|
||||||
|
const ENFORCE_CREW_GATE_ON_DISPATCH = false;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TrainSchedulingService {
|
export class TrainSchedulingService {
|
||||||
private readonly logger = new Logger(TrainSchedulingService.name);
|
private readonly logger = new Logger(TrainSchedulingService.name);
|
||||||
@@ -446,9 +472,10 @@ export class TrainSchedulingService {
|
|||||||
// Per-wagon history ledger (global module). @Optional keeps the positional
|
// Per-wagon history ledger (global module). @Optional keeps the positional
|
||||||
// spec constructors working; production always has it.
|
// spec constructors working; production always has it.
|
||||||
@Optional() private readonly wagonHistory?: WagonHistoryService,
|
@Optional() private readonly wagonHistory?: WagonHistoryService,
|
||||||
|
// Crew composition gate (ITLMS Rolling Stock §1.2 "prior to departure").
|
||||||
// Trailing + @Optional so the positional constructors in the existing specs
|
// Trailing + @Optional so the positional constructors in the existing specs
|
||||||
// keep working; production always resolves it from ExportsModule.
|
// keep working; production always resolves it.
|
||||||
@Optional() private readonly tabularExport?: TabularExportService,
|
@Optional() private readonly trainCrewAssignments?: TrainCrewAssignmentService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Physical wagons behind a set of booking allocations (via their slots), for cargo history rows. */
|
/** Physical wagons behind a set of booking allocations (via their slots), for cargo history rows. */
|
||||||
@@ -809,9 +836,9 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Train length/weight and 20ft weight caps are engine-internal (wagon
|
* Train length/weight and the 20ft weight cap columns are legacy: planning
|
||||||
* planning still reads them off the row); they are no longer exposed or
|
* now takes weight/length from locomotive configuration and per-box ceilings
|
||||||
* editable through the global-rules endpoints.
|
* from weight limit rules. They are neither read nor exposed here.
|
||||||
*/
|
*/
|
||||||
private toPublicGlobalRules(row: TrainSchedulingGlobalRules | null) {
|
private toPublicGlobalRules(row: TrainSchedulingGlobalRules | null) {
|
||||||
if (!row) return row;
|
if (!row) return row;
|
||||||
@@ -1030,6 +1057,152 @@ export class TrainSchedulingService {
|
|||||||
return fresh ?? schedule;
|
return fresh ?? schedule;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shorten the booking-close offset of ONE schedule whose booking shut ONLY
|
||||||
|
* because of that offset, and re-arm its window so the desk reopens. A 3-day
|
||||||
|
* offset that closed booking with the train still days away can be cut to a
|
||||||
|
* day or a couple of hours; the window then opens at the next desk opening
|
||||||
|
* (now, if the desk is open) and runs its normal cycles until the new cutoff.
|
||||||
|
*
|
||||||
|
* Refused for every other kind of closed window (departed, full, no offset,
|
||||||
|
* still mid-cycle) — see `closeOffsetReopenCheck`. The new offset must be
|
||||||
|
* shorter than the current one and must leave room for a cycle before the
|
||||||
|
* new cutoff. The offset is frozen onto the schedule (the global value is
|
||||||
|
* untouched) and the row is marked custom so a later global-rules save does
|
||||||
|
* not re-stamp it.
|
||||||
|
*
|
||||||
|
* IMPORT/DOMESTIC: the same shorter offset is applied to every route+day
|
||||||
|
* sibling that is likewise shut only by its offset, so the group keeps its
|
||||||
|
* single shared timeline (each capped at its own new cutoff). EXPORT windows
|
||||||
|
* are per-train, so an export change touches only this schedule.
|
||||||
|
*/
|
||||||
|
async reduceScheduleCloseOffset(
|
||||||
|
id: string,
|
||||||
|
dto: ReduceScheduleCloseOffsetDto,
|
||||||
|
): Promise<TrainSchedule> {
|
||||||
|
const schedule = await this.trainSchedulesRepository.findById(id);
|
||||||
|
if (!schedule) {
|
||||||
|
throw new NotFoundException(`Train schedule ${id} not found`);
|
||||||
|
}
|
||||||
|
const now = new Date();
|
||||||
|
const liveCfg = await this.getWindowConfig();
|
||||||
|
const cfg = effectiveWindowConfig(schedule, liveCfg);
|
||||||
|
const check = closeOffsetReopenCheck(schedule, cfg, now);
|
||||||
|
if (!check.eligible || check.offsetMinutes == null) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
check.reason ?? 'This schedule cannot reopen by shortening its close offset.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newOffset = dto.closeOffsetMinutes;
|
||||||
|
if (newOffset >= check.offsetMinutes) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`The new close offset must be shorter than the current ${describeMinutes(
|
||||||
|
check.offsetMinutes,
|
||||||
|
)} before departure.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isExport = schedule.direction === 'EXPORT';
|
||||||
|
// 0 is stored as null so "no offset" keeps its single canonical value.
|
||||||
|
const offsetPatch = isExport
|
||||||
|
? { ruleExportCloseOffsetMinutes: newOffset || null }
|
||||||
|
: { ruleImportCloseOffsetMinutes: newOffset || null };
|
||||||
|
const merged: BookingWindowConfig = {
|
||||||
|
...cfg,
|
||||||
|
...(isExport
|
||||||
|
? { exportCloseOffsetMinutes: newOffset || null }
|
||||||
|
: { importCloseOffsetMinutes: newOffset || null }),
|
||||||
|
};
|
||||||
|
const hours: OfficeHours = {
|
||||||
|
windowOpenHour: cfg.windowOpenHour,
|
||||||
|
windowCloseHour: cfg.windowCloseHour,
|
||||||
|
};
|
||||||
|
const cutoff = bookingCloseCutoff(
|
||||||
|
schedule.scheduledDepartureDate,
|
||||||
|
schedule.direction,
|
||||||
|
merged,
|
||||||
|
);
|
||||||
|
// The desk reopens at the next office-hours opening (now, when it is open),
|
||||||
|
// exactly as a reopen cycle would — and only if that lands before the cutoff.
|
||||||
|
const opensAt = nextCycleOpensAt(now, hours, cutoff);
|
||||||
|
if (opensAt == null) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Even with this offset the desk would not reopen before booking closes again ' +
|
||||||
|
`(new cutoff ${cutoff.toISOString()}) — shorten the offset further.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let closesAt: Date;
|
||||||
|
if (isExport) {
|
||||||
|
// Export runs one FCFS window: from the reopen until the cutoff.
|
||||||
|
closesAt = cutoff;
|
||||||
|
} else {
|
||||||
|
closesAt = new Date(opensAt.getTime() + cfg.windowDurationHours * 3_600_000);
|
||||||
|
closesAt = clampCloseToOfficeHours(opensAt, closesAt, hours);
|
||||||
|
if (closesAt.getTime() > cutoff.getTime()) closesAt = cutoff;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cap = (d: Date, bound: Date): Date =>
|
||||||
|
d.getTime() > bound.getTime() ? bound : d;
|
||||||
|
const targets: Array<{ id: string; cutoff: Date }> = [{ id, cutoff }];
|
||||||
|
if (!isExport) {
|
||||||
|
const siblings = await this.findGroupSiblings(
|
||||||
|
this.dataSource.manager,
|
||||||
|
schedule.originStationId,
|
||||||
|
schedule.destinationStationId,
|
||||||
|
schedule.scheduledDepartureDate,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
for (const sib of siblings) {
|
||||||
|
const sibCfg = effectiveWindowConfig(sib, liveCfg);
|
||||||
|
const sibCheck = closeOffsetReopenCheck(sib, sibCfg, now);
|
||||||
|
// Only a sibling that is ALSO shut purely by an offset longer than the
|
||||||
|
// new one joins in; anything else keeps the state its customers saw.
|
||||||
|
if (
|
||||||
|
!sibCheck.eligible ||
|
||||||
|
sibCheck.offsetMinutes == null ||
|
||||||
|
sibCheck.offsetMinutes <= newOffset ||
|
||||||
|
!sib.scheduledDepartureDate
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const sibCutoff = bookingCloseCutoff(sib.scheduledDepartureDate, sib.direction, {
|
||||||
|
...sibCfg,
|
||||||
|
importCloseOffsetMinutes: newOffset || null,
|
||||||
|
});
|
||||||
|
if (opensAt.getTime() >= sibCutoff.getTime()) continue;
|
||||||
|
targets.push({ id: sib.id, cutoff: sibCutoff });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const repo = this.dataSource.getRepository(TrainSchedule);
|
||||||
|
for (const t of targets) {
|
||||||
|
await repo.update(t.id, {
|
||||||
|
...offsetPatch,
|
||||||
|
// Staff-set — exempt from the global re-stamp.
|
||||||
|
windowRuleCustom: true,
|
||||||
|
// Back to PRE_WINDOW: the window tick opens it at windowOpensAt and runs
|
||||||
|
// the normal cycle from there (bookingWindowStatus flips OPEN then).
|
||||||
|
windowPhase: 'PRE_WINDOW',
|
||||||
|
windowOpensAt: cap(opensAt, t.cutoff),
|
||||||
|
windowClosesAt: cap(closesAt, t.cutoff),
|
||||||
|
docReviewCompletedAt: null,
|
||||||
|
docReviewEndsAt: null,
|
||||||
|
paymentPhaseEndsAt: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.logger.log(
|
||||||
|
`Close offset of schedule ${schedule.reference ?? id} shortened ` +
|
||||||
|
`${check.offsetMinutes} → ${newOffset} min before departure` +
|
||||||
|
` (+${targets.length - 1} route+day sibling(s)) — booking reopens ` +
|
||||||
|
`${opensAt.toISOString()}, closes ${closesAt.toISOString()}`,
|
||||||
|
);
|
||||||
|
for (const t of targets) void this.emitWindowState(t.id);
|
||||||
|
|
||||||
|
const fresh = await this.trainSchedulesRepository.findById(id);
|
||||||
|
return fresh ?? schedule;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Correct a departure's operational run identifiers — the train number and
|
* Correct a departure's operational run identifiers — the train number and
|
||||||
* voyage number yards and customs quote.
|
* voyage number yards and customs quote.
|
||||||
@@ -3027,6 +3200,15 @@ export class TrainSchedulingService {
|
|||||||
'End the loading window at the origin station before dispatching',
|
'End the loading window at the origin station before dispatching',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// On-board crew must be complete before the train leaves — ITLMS Rolling
|
||||||
|
// Stock §1.2 enforces composition "prior to departure", so an incomplete
|
||||||
|
// crew saves freely on the assignment page but cannot depart. Optional
|
||||||
|
// dependency: the positional spec constructors omit it.
|
||||||
|
// TODO(crew-gate): temporarily off so trains can dispatch with no crew
|
||||||
|
// assigned. Flip ENFORCE_CREW_GATE_ON_DISPATCH once crew rostering is in use.
|
||||||
|
if (ENFORCE_CREW_GATE_ON_DISPATCH) {
|
||||||
|
await this.trainCrewAssignments?.assertCrewReadyForDispatch(scheduleId);
|
||||||
|
}
|
||||||
// Staff may record the departure after the fact — past is fine, future is not.
|
// Staff may record the departure after the fact — past is fine, future is not.
|
||||||
const now = dto.actualDepartureAt ? new Date(dto.actualDepartureAt) : new Date();
|
const now = dto.actualDepartureAt ? new Date(dto.actualDepartureAt) : new Date();
|
||||||
this.assertNotFuture(now, 'Departure time');
|
this.assertNotFuture(now, 'Departure time');
|
||||||
@@ -3770,15 +3952,15 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The schedule detail page's wagon-list Excel export.
|
* The schedule detail page's wagon-list Excel export, laid out like the
|
||||||
*
|
* wagon sheet the yard circulates by hand: containers grouped by customer,
|
||||||
* One row per container (a wagon carrying two boxes yields two rows, repeating
|
* one line per container (a two-box wagon repeats its wagon number under one
|
||||||
* the wagon number) so each container's own VGM is present and totals footable.
|
* "No."), a blank line between customers, and the wagon count / company /
|
||||||
* Bulk wagons, having no containers, yield a single row carrying the bulk
|
* transitor merged down each group. See buildWagonListWorkbook.
|
||||||
* description and the allocated tonnage as the VGM figure.
|
|
||||||
*
|
*
|
||||||
* Only wagon slots that actually carry an allocation are listed — empty slots
|
* Only wagon slots that actually carry an allocation are listed — empty slots
|
||||||
* on the consist are omitted.
|
* on the consist are omitted. A bulk wagon yields one line carrying the cargo
|
||||||
|
* description in place of a container number.
|
||||||
*/
|
*/
|
||||||
async scheduleWagonListWorkbook(
|
async scheduleWagonListWorkbook(
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
@@ -3787,56 +3969,37 @@ export class TrainSchedulingService {
|
|||||||
if (!schedule) {
|
if (!schedule) {
|
||||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||||
}
|
}
|
||||||
if (!this.tabularExport) {
|
|
||||||
throw new BadRequestException('Tabular export service is unavailable');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Row grain is the container item; the LEFT JOIN keeps bulk (and any
|
// Row grain is the container item; the LEFT JOIN keeps a bulk (or any
|
||||||
// container-less) allocation as one row. `booking_container_units` is joined
|
// container-less) allocation as one row. The transitor is the customs
|
||||||
// on BOTH container number and its booking_container line — container
|
// clearing agent the customer named on the booking.
|
||||||
// numbers repeat across bookings, so number alone would multiply rows.
|
const lines: WagonListLine[] = await this.dataSource.query(
|
||||||
const rows: ScheduleWagonListRow[] = await this.dataSource.query(
|
|
||||||
`SELECT tsw.sequence_no AS "sequenceNo",
|
`SELECT tsw.sequence_no AS "sequenceNo",
|
||||||
w.wagon_number AS "wagonNumber",
|
w.wagon_number AS "wagonNumber",
|
||||||
COALESCE(wt.name, wt.code) AS "wagonType",
|
|
||||||
ci.container_number AS "containerNumber",
|
ci.container_number AS "containerNumber",
|
||||||
cit.size_ft AS "containerSizeFt",
|
cit.size_ft AS "containerSizeFt",
|
||||||
a.load_type AS "loadType",
|
a.load_type AS "loadType",
|
||||||
a.status AS "status",
|
|
||||||
bl.cargo_description AS "bulkCargoDescription",
|
bl.cargo_description AS "bulkCargoDescription",
|
||||||
COALESCE(
|
|
||||||
ci.gross_weight_tons,
|
|
||||||
bcu.vgm_tons,
|
|
||||||
bc.vgm_per_unit_tons,
|
|
||||||
a.allocated_weight_tons
|
|
||||||
) AS "vgmTons",
|
|
||||||
COALESCE(by_.label, so.label) AS "originLabel",
|
COALESCE(by_.label, so.label) AS "originLabel",
|
||||||
COALESCE(ay.label, sd.label) AS "destinationLabel",
|
COALESCE(ay.label, sd.label) AS "destinationLabel",
|
||||||
b.reference AS "bookingReference",
|
|
||||||
COALESCE(
|
COALESCE(
|
||||||
slc.name,
|
slc.name,
|
||||||
CASE WHEN b.is_government THEN NULLIF(TRIM(b.government_institution), '') END,
|
CASE WHEN b.is_government THEN NULLIF(TRIM(b.government_institution), '') END,
|
||||||
c.name
|
c.name
|
||||||
) AS "customerName"
|
) AS "customerName",
|
||||||
|
NULLIF(TRIM(b.customs_clearing_agent), '') AS "transitor"
|
||||||
FROM freight.train_schedules s
|
FROM freight.train_schedules s
|
||||||
JOIN freight.train_set_wagons tsw
|
JOIN freight.train_set_wagons tsw
|
||||||
ON tsw.train_set_id = s.train_set_id AND tsw.deleted_at IS NULL
|
ON tsw.train_set_id = s.train_set_id AND tsw.deleted_at IS NULL
|
||||||
JOIN freight.wagon_booking_allocations a
|
JOIN freight.wagon_booking_allocations a
|
||||||
ON a.train_set_wagon_id = tsw.id AND a.deleted_at IS NULL
|
ON a.train_set_wagon_id = tsw.id AND a.deleted_at IS NULL
|
||||||
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
|
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
|
||||||
LEFT JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id
|
|
||||||
LEFT JOIN freight.bookings b ON b.id = a.booking_id
|
LEFT JOIN freight.bookings b ON b.id = a.booking_id
|
||||||
LEFT JOIN freight.companies c ON c.id = b.company_id
|
LEFT JOIN freight.companies c ON c.id = b.company_id
|
||||||
LEFT JOIN freight.shipping_line_companies slc ON slc.id = b.shipping_line_company_id
|
LEFT JOIN freight.shipping_line_companies slc ON slc.id = b.shipping_line_company_id
|
||||||
LEFT JOIN freight.wagon_allocation_container_items ci
|
LEFT JOIN freight.wagon_allocation_container_items ci
|
||||||
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
|
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
|
||||||
LEFT JOIN freight.container_types cit ON cit.id = ci.container_type_id
|
LEFT JOIN freight.container_types cit ON cit.id = ci.container_type_id
|
||||||
LEFT JOIN freight.booking_container bc
|
|
||||||
ON bc.id = ci.booking_container_id AND bc.deleted_at IS NULL
|
|
||||||
LEFT JOIN freight.booking_container_units bcu
|
|
||||||
ON bcu.container_number = ci.container_number
|
|
||||||
AND bcu.booking_container_id = bc.id
|
|
||||||
AND bcu.deleted_at IS NULL
|
|
||||||
LEFT JOIN freight.wagon_allocation_bulk_loads bl
|
LEFT JOIN freight.wagon_allocation_bulk_loads bl
|
||||||
ON bl.wagon_booking_allocation_id = a.id AND bl.deleted_at IS NULL
|
ON bl.wagon_booking_allocation_id = a.id AND bl.deleted_at IS NULL
|
||||||
LEFT JOIN freight.yards so ON so.id = s.origin_station_id
|
LEFT JOIN freight.yards so ON so.id = s.origin_station_id
|
||||||
@@ -3848,47 +4011,13 @@ export class TrainSchedulingService {
|
|||||||
[scheduleId],
|
[scheduleId],
|
||||||
);
|
);
|
||||||
|
|
||||||
// "number" is the printed line number of the sheet, not the wagon sequence —
|
const { groups, totalWagons } = groupWagonListLines(lines);
|
||||||
// a two-container wagon occupies two lines, and the reader counts lines.
|
const buffer = await buildWagonListWorkbook({
|
||||||
const sheetRows = rows.map((row, index) => ({
|
trainLabel: schedule.trainNumber ?? schedule.reference ?? schedule.id,
|
||||||
number: index + 1,
|
groups,
|
||||||
wagonNumber: row.wagonNumber ?? '—',
|
totalWagons,
|
||||||
containerNumber:
|
|
||||||
row.containerNumber ??
|
|
||||||
(row.loadType === 'BULK' ? (row.bulkCargoDescription ?? 'Bulk') : '—'),
|
|
||||||
vgmTons: row.vgmTons === null ? null : Number(row.vgmTons),
|
|
||||||
originLabel: row.originLabel ?? '—',
|
|
||||||
destinationLabel: row.destinationLabel ?? '—',
|
|
||||||
customerName: row.customerName ?? '—',
|
|
||||||
}));
|
|
||||||
|
|
||||||
const totalVgm = sheetRows.reduce((sum, r) => sum + (r.vgmTons ?? 0), 0);
|
|
||||||
const reference = schedule.reference ?? schedule.trainNumber ?? schedule.id;
|
|
||||||
|
|
||||||
const buffer = await this.tabularExport.toXlsx({
|
|
||||||
title: `Wagons ${reference}`.slice(0, 31),
|
|
||||||
description: `Wagon list for train ${reference}`,
|
|
||||||
label: 'train-schedule:wagon-list',
|
|
||||||
kpis: [
|
|
||||||
{ label: 'Lines', value: sheetRows.length },
|
|
||||||
{
|
|
||||||
label: 'Wagons',
|
|
||||||
value: new Set(rows.map((r) => r.sequenceNo)).size,
|
|
||||||
},
|
|
||||||
{ label: 'Total VGM', value: Number(totalVgm.toFixed(3)), unit: 't' },
|
|
||||||
],
|
|
||||||
columns: [
|
|
||||||
{ key: 'number', label: 'No.', type: 'number' },
|
|
||||||
{ key: 'wagonNumber', label: 'Wagon', type: 'string' },
|
|
||||||
{ key: 'containerNumber', label: 'Container number', type: 'string' },
|
|
||||||
{ key: 'vgmTons', label: 'VGM', type: 'tons' },
|
|
||||||
{ key: 'originLabel', label: 'Origin', type: 'string' },
|
|
||||||
{ key: 'destinationLabel', label: 'Destination', type: 'string' },
|
|
||||||
{ key: 'customerName', label: 'Customer', type: 'string' },
|
|
||||||
],
|
|
||||||
rows: sheetRows,
|
|
||||||
});
|
});
|
||||||
|
const reference = schedule.reference ?? schedule.trainNumber ?? schedule.id;
|
||||||
return {
|
return {
|
||||||
filename: `wagon-list-${this.safeDocumentName(reference)}.xlsx`,
|
filename: `wagon-list-${this.safeDocumentName(reference)}.xlsx`,
|
||||||
buffer,
|
buffer,
|
||||||
@@ -6367,8 +6496,11 @@ export class TrainSchedulingService {
|
|||||||
skip,
|
skip,
|
||||||
take,
|
take,
|
||||||
});
|
});
|
||||||
|
// Live window config: each row's frozen rule overlays it to decide whether
|
||||||
|
// the "shorten close offset" action applies (see closeOffsetReopenCheck).
|
||||||
|
const liveCfg = await this.getWindowConfig();
|
||||||
return {
|
return {
|
||||||
items: schedules.map((s) => this.mapScheduleListItem(s)),
|
items: schedules.map((s) => this.mapScheduleListItem(s, liveCfg)),
|
||||||
meta: buildPaginationMeta(total, page, pageSize),
|
meta: buildPaginationMeta(total, page, pageSize),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -6708,11 +6840,6 @@ export class TrainSchedulingService {
|
|||||||
)),
|
)),
|
||||||
);
|
);
|
||||||
|
|
||||||
const placementRules = {
|
|
||||||
max20ftContainerWeightTons: trainLimits.max20ftContainerWeightTons,
|
|
||||||
max20ftPairWeightDiffTons: trainLimits.max20ftPairWeightDiffTons,
|
|
||||||
};
|
|
||||||
|
|
||||||
// With forceAssign, capacity-shaped rules (train limits, total weight,
|
// With forceAssign, capacity-shaped rules (train limits, total weight,
|
||||||
// locomotive capability) become warnings — staff owns the override. Physical
|
// locomotive capability) become warnings — staff owns the override. Physical
|
||||||
// impossibilities (no wagon of the required type at the yard, wrong route,
|
// impossibilities (no wagon of the required type at the yard, wrong route,
|
||||||
@@ -6745,6 +6872,11 @@ export class TrainSchedulingService {
|
|||||||
);
|
);
|
||||||
if (requireContainerPlacements && resolvedMode !== 'BULK') {
|
if (requireContainerPlacements && resolvedMode !== 'BULK') {
|
||||||
const containerBookings = fittingBookings.filter((b) => b.freightType === 'CONTAINER');
|
const containerBookings = fittingBookings.filter((b) => b.freightType === 'CONTAINER');
|
||||||
|
const placementRules: ContainerPlacementRules = {
|
||||||
|
maxContainerWeightTonsByLineId:
|
||||||
|
await this.containerCapacityCeilingsByLine(containerBookings),
|
||||||
|
max20ftPairWeightDiffTons: trainLimits.max20ftPairWeightDiffTons,
|
||||||
|
};
|
||||||
violations.push(
|
violations.push(
|
||||||
...validateContainerPlacements(
|
...validateContainerPlacements(
|
||||||
containerBookings,
|
containerBookings,
|
||||||
@@ -6892,6 +7024,70 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hard per-box ceiling for every container line of the given bookings, from
|
||||||
|
* the rule engine's weight limit rule (`max_capacity_tons`) matching the
|
||||||
|
* line's container type and the booking's trade direction (a `BOTH` rule
|
||||||
|
* applies to either direction; an exact-direction rule wins over it). Lines
|
||||||
|
* whose rule has no capacity set get no entry — capacity is optional.
|
||||||
|
*/
|
||||||
|
private async containerCapacityCeilingsByLine(
|
||||||
|
bookings: Booking[],
|
||||||
|
): Promise<Record<string, number>> {
|
||||||
|
const lines: Array<{ lineId: string; containerTypeId: string; tradeDirection: string }> = [];
|
||||||
|
for (const booking of bookings) {
|
||||||
|
const direction = String(booking.tradeDirection ?? '').toUpperCase();
|
||||||
|
for (const line of booking.bookingContainers ?? []) {
|
||||||
|
if (!line.containerTypeId) continue;
|
||||||
|
lines.push({ lineId: line.id, containerTypeId: line.containerTypeId, tradeDirection: direction });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!lines.length) return {};
|
||||||
|
|
||||||
|
const typeIds = [...new Set(lines.map((l) => l.containerTypeId))];
|
||||||
|
const rules = await this.dataSource
|
||||||
|
.getRepository(WeightLimitRule)
|
||||||
|
.find({ where: { containerTypeId: In(typeIds) } });
|
||||||
|
|
||||||
|
const ceilings: Record<string, number> = {};
|
||||||
|
for (const { lineId, containerTypeId, tradeDirection } of lines) {
|
||||||
|
const candidates = rules.filter(
|
||||||
|
(r) => r.containerTypeId === containerTypeId && r.maxCapacityTons != null,
|
||||||
|
);
|
||||||
|
const rule =
|
||||||
|
candidates.find((r) => r.tradeDirection === tradeDirection) ??
|
||||||
|
candidates.find((r) => r.tradeDirection === 'BOTH');
|
||||||
|
const cap = Number(rule?.maxCapacityTons);
|
||||||
|
if (Number.isFinite(cap) && cap > 0) ceilings[lineId] = cap;
|
||||||
|
}
|
||||||
|
return ceilings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limits for a train that has no locomotive assigned yet: the strongest
|
||||||
|
* in-service locomotive on each axis, so planning assumes the most capable
|
||||||
|
* power that could be coupled. Null when no locomotive is configured at all.
|
||||||
|
*/
|
||||||
|
private async strongestFleetLocomotiveLimits(): Promise<LocomotiveLimits | null> {
|
||||||
|
const fleet = await this.locomotivesRepository.findAll({
|
||||||
|
where: { status: Not('OUT_OF_SERVICE' as Locomotive['status']) },
|
||||||
|
});
|
||||||
|
const pulls = fleet.map((l) => Number(l.maxPullWeightTons)).filter((v) => v > 0);
|
||||||
|
const lengths = fleet.map((l) => Number(l.maxTrainLengthMeters)).filter((v) => v > 0);
|
||||||
|
if (!pulls.length && !lengths.length) return null;
|
||||||
|
const strongest = (axis: number[], pick: (l: Locomotive) => number) =>
|
||||||
|
fleet.find((l) => pick(l) === Math.max(...axis));
|
||||||
|
return {
|
||||||
|
maxPullWeightTons: pulls.length ? Math.max(...pulls) : Infinity,
|
||||||
|
maxTrainLengthMeters: lengths.length ? Math.max(...lengths) : Infinity,
|
||||||
|
overageToleranceTons:
|
||||||
|
Number(strongest(pulls, (l) => Number(l.maxPullWeightTons))?.overageToleranceTons) || 0,
|
||||||
|
overageToleranceMeters:
|
||||||
|
Number(strongest(lengths, (l) => Number(l.maxTrainLengthMeters))?.overageToleranceMeters) ||
|
||||||
|
0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async resolveTrainLimitConfig(
|
private async resolveTrainLimitConfig(
|
||||||
dto?: {
|
dto?: {
|
||||||
maxTrainWeightTons?: number;
|
maxTrainWeightTons?: number;
|
||||||
@@ -6902,24 +7098,14 @@ export class TrainSchedulingService {
|
|||||||
builtWagonCount?: number,
|
builtWagonCount?: number,
|
||||||
): Promise<Required<TrainLimitConfig>> {
|
): Promise<Required<TrainLimitConfig>> {
|
||||||
const row = await this.loadGlobalRulesRow();
|
const row = await this.loadGlobalRulesRow();
|
||||||
const configured = this.configService?.get<{
|
const configured = this.configService?.get<{ maxWagonsPerTrain?: number }>(
|
||||||
maxTrainWeightTons?: number;
|
'app.trainScheduling',
|
||||||
maxTrainLengthMeters?: number;
|
);
|
||||||
maxWagonsPerTrain?: number;
|
|
||||||
}>('app.trainScheduling');
|
|
||||||
|
|
||||||
const ruleWeightCap =
|
|
||||||
dto?.maxTrainWeightTons ??
|
|
||||||
(row?.maxTrainWeightTons != null
|
|
||||||
? Number(row.maxTrainWeightTons)
|
|
||||||
: configured?.maxTrainWeightTons);
|
|
||||||
const ruleLengthCap =
|
|
||||||
dto?.maxTrainLengthMeters ??
|
|
||||||
(row?.maxTrainLengthMeters != null
|
|
||||||
? Number(row.maxTrainLengthMeters)
|
|
||||||
: configured?.maxTrainLengthMeters);
|
|
||||||
|
|
||||||
const wagonTypes = await this.loadSchedulingWagonTypeDimensions();
|
const wagonTypes = await this.loadSchedulingWagonTypeDimensions();
|
||||||
|
const max20ftPairWeightDiffTons = this.positiveNumber(
|
||||||
|
undefined,
|
||||||
|
Number(row?.max20ftPairWeightDiffTons) || DEFAULT_20FT_PAIR_WEIGHT_DIFF_TONS,
|
||||||
|
);
|
||||||
|
|
||||||
if (locomotive) {
|
if (locomotive) {
|
||||||
// With a locomotive assigned its own limits are the single source of
|
// With a locomotive assigned its own limits are the single source of
|
||||||
@@ -6954,52 +7140,40 @@ export class TrainSchedulingService {
|
|||||||
: builtWagonCount && builtWagonCount > 0
|
: builtWagonCount && builtWagonCount > 0
|
||||||
? builtWagonCount
|
? builtWagonCount
|
||||||
: derived.maxWagonSlots,
|
: derived.maxWagonSlots,
|
||||||
max20ftContainerWeightTons: this.positiveNumber(
|
max20ftPairWeightDiffTons,
|
||||||
undefined,
|
|
||||||
Number(row?.max20ftContainerWeightTons) ||
|
|
||||||
DEFAULT_TRAIN_LIMITS.max20ftContainerWeightTons,
|
|
||||||
),
|
|
||||||
max20ftPairWeightDiffTons: this.positiveNumber(
|
|
||||||
undefined,
|
|
||||||
Number(row?.max20ftPairWeightDiffTons) ||
|
|
||||||
DEFAULT_TRAIN_LIMITS.max20ftPairWeightDiffTons,
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxWeightTons = this.positiveNumber(
|
// No locomotive on the set yet: plan against the strongest in-service
|
||||||
dto?.maxTrainWeightTons,
|
// locomotive's configuration. An explicit dto override still narrows it.
|
||||||
ruleWeightCap ?? DEFAULT_TRAIN_LIMITS.maxWeightTons,
|
const fleet = await this.strongestFleetLocomotiveLimits();
|
||||||
);
|
if (!fleet) {
|
||||||
const maxLengthMeters = this.positiveNumber(
|
this.logger.warn(
|
||||||
dto?.maxTrainLengthMeters,
|
'No in-service locomotive is configured — train weight/length limits fall back to ' +
|
||||||
ruleLengthCap ?? DEFAULT_TRAIN_LIMITS.maxLengthMeters,
|
`${MAX_FALLBACK_WEIGHT}T / ${MAX_FALLBACK_LENGTH}m until a locomotive is added`,
|
||||||
);
|
);
|
||||||
const derivedWithoutLoco = deriveTrainCapacityFromLocomotive(
|
}
|
||||||
{ maxPullWeightTons: maxWeightTons, maxTrainLengthMeters: maxLengthMeters },
|
const derived = deriveTrainCapacityFromLocomotive(
|
||||||
|
fleet ?? { maxPullWeightTons: MAX_FALLBACK_WEIGHT, maxTrainLengthMeters: MAX_FALLBACK_LENGTH },
|
||||||
wagonTypes,
|
wagonTypes,
|
||||||
|
{
|
||||||
|
maxTrainWeightTons: dto?.maxTrainWeightTons,
|
||||||
|
maxTrainLengthMeters: dto?.maxTrainLengthMeters,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
maxWeightTons,
|
maxWeightTons: derived.maxWeightTons,
|
||||||
maxLengthMeters,
|
maxLengthMeters: derived.maxLengthMeters,
|
||||||
maxWagonsPerTrain: Math.floor(
|
maxWagonsPerTrain: Math.floor(
|
||||||
this.positiveNumber(
|
this.positiveNumber(
|
||||||
dto?.maxWagonsPerTrain,
|
dto?.maxWagonsPerTrain,
|
||||||
row?.maxWagonsPerTrain != null
|
row?.maxWagonsPerTrain != null
|
||||||
? Number(row.maxWagonsPerTrain)
|
? Number(row.maxWagonsPerTrain)
|
||||||
: configured?.maxWagonsPerTrain ?? derivedWithoutLoco.maxWagonSlots,
|
: configured?.maxWagonsPerTrain ?? derived.maxWagonSlots,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
max20ftContainerWeightTons: this.positiveNumber(
|
max20ftPairWeightDiffTons,
|
||||||
undefined,
|
|
||||||
Number(row?.max20ftContainerWeightTons) || DEFAULT_TRAIN_LIMITS.max20ftContainerWeightTons,
|
|
||||||
),
|
|
||||||
max20ftPairWeightDiffTons: this.positiveNumber(
|
|
||||||
undefined,
|
|
||||||
Number(row?.max20ftPairWeightDiffTons) ||
|
|
||||||
DEFAULT_TRAIN_LIMITS.max20ftPairWeightDiffTons,
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8869,7 +9043,11 @@ export class TrainSchedulingService {
|
|||||||
throw new ConflictException('Could not allocate a unique schedule reference');
|
throw new ConflictException('Could not allocate a unique schedule reference');
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapScheduleListItem(schedule: import('../../train-schedules/entities/train-schedule.entity').TrainSchedule) {
|
private mapScheduleListItem(
|
||||||
|
schedule: import('../../train-schedules/entities/train-schedule.entity').TrainSchedule,
|
||||||
|
/** Live window config; when given, the row carries its close-offset reopen state. */
|
||||||
|
liveCfg?: BookingWindowConfig,
|
||||||
|
) {
|
||||||
// Wagon figures must match the detail page's wagon plan (WagonPlanGrid) —
|
// Wagon figures must match the detail page's wagon plan (WagonPlanGrid) —
|
||||||
// see computeScheduleWagonUsage for why the stored counter cannot be used.
|
// see computeScheduleWagonUsage for why the stored counter cannot be used.
|
||||||
const { wagonsUsed, wagonsTotal, wagonsReserved, wagonsRemaining } =
|
const { wagonsUsed, wagonsTotal, wagonsReserved, wagonsRemaining } =
|
||||||
@@ -8933,6 +9111,18 @@ export class TrainSchedulingService {
|
|||||||
freightType: this.resolveScheduleFreightType(schedule),
|
freightType: this.resolveScheduleFreightType(schedule),
|
||||||
status: schedule.status,
|
status: schedule.status,
|
||||||
bookingWindowStatus: schedule.bookingWindowStatus ?? 'OPEN',
|
bookingWindowStatus: schedule.bookingWindowStatus ?? 'OPEN',
|
||||||
|
windowPhase: schedule.windowPhase ?? null,
|
||||||
|
// Whether booking shut ONLY because of the close offset — the board offers
|
||||||
|
// "shorten close offset" on exactly these rows.
|
||||||
|
closeOffsetReopen: liveCfg
|
||||||
|
? toCloseOffsetReopenInfo(
|
||||||
|
closeOffsetReopenCheck(
|
||||||
|
schedule,
|
||||||
|
effectiveWindowConfig(schedule, liveCfg),
|
||||||
|
new Date(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
cancellationReason: schedule.cancellationReason ?? null,
|
cancellationReason: schedule.cancellationReason ?? null,
|
||||||
cancelledAt: schedule.cancelledAt ?? null,
|
cancelledAt: schedule.cancelledAt ?? null,
|
||||||
maxWagons: schedule.maxWagons ?? 0,
|
maxWagons: schedule.maxWagons ?? 0,
|
||||||
@@ -10977,6 +11167,14 @@ export class TrainSchedulingService {
|
|||||||
// settings" editor on the ops board (prefill + save one schedule's
|
// settings" editor on the ops board (prefill + save one schedule's
|
||||||
// override). docReview/payment are not snapshotted per schedule (only their
|
// override). docReview/payment are not snapshotted per schedule (only their
|
||||||
// sum, as the frozen reopen gap), so the editor prefills them from live config.
|
// sum, as the frozen reopen gap), so the editor prefills them from live config.
|
||||||
|
// Shut only by its close offset? Drives the "shorten close offset" action.
|
||||||
|
closeOffsetReopen: toCloseOffsetReopenInfo(
|
||||||
|
closeOffsetReopenCheck(
|
||||||
|
schedule,
|
||||||
|
effectiveWindowConfig(schedule, windowCfg),
|
||||||
|
new Date(),
|
||||||
|
),
|
||||||
|
),
|
||||||
windowRule: {
|
windowRule: {
|
||||||
windowOpenHour: schedule.ruleWindowOpenHour ?? null,
|
windowOpenHour: schedule.ruleWindowOpenHour ?? null,
|
||||||
windowCloseHour: schedule.ruleWindowCloseHour ?? null,
|
windowCloseHour: schedule.ruleWindowCloseHour ?? null,
|
||||||
@@ -10986,6 +11184,12 @@ export class TrainSchedulingService {
|
|||||||
: null,
|
: null,
|
||||||
importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null,
|
importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null,
|
||||||
exportBookingLeadHours: schedule.ruleExportBookingLeadHours ?? null,
|
exportBookingLeadHours: schedule.ruleExportBookingLeadHours ?? null,
|
||||||
|
// The offsets this train actually runs under (its frozen snapshot, or
|
||||||
|
// the live global for a legacy row) — null = booking runs to departure.
|
||||||
|
importCloseOffsetMinutes:
|
||||||
|
effectiveWindowConfig(schedule, windowCfg).importCloseOffsetMinutes ?? null,
|
||||||
|
exportCloseOffsetMinutes:
|
||||||
|
effectiveWindowConfig(schedule, windowCfg).exportCloseOffsetMinutes ?? null,
|
||||||
docReviewMinutes: windowCfg.docReviewMinutes,
|
docReviewMinutes: windowCfg.docReviewMinutes,
|
||||||
// Editor prefill: this schedule's own override when staff set one,
|
// Editor prefill: this schedule's own override when staff set one,
|
||||||
// else the live global for the schedule's direction (import/export
|
// else the live global for the schedule's direction (import/export
|
||||||
@@ -13049,3 +13253,4 @@ export class TrainSchedulingService {
|
|||||||
return fresh ?? schedule;
|
return fresh ?? schedule;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
//
|
||||||
@@ -6,7 +6,6 @@ import { BillingModule } from '../billing/billing.module';
|
|||||||
import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module';
|
import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module';
|
||||||
import { BookingsModule } from '../bookings/bookings.module';
|
import { BookingsModule } from '../bookings/bookings.module';
|
||||||
import { Container } from '../container-management/entities/container.entity';
|
import { Container } from '../container-management/entities/container.entity';
|
||||||
import { ExportsModule } from '../exports/exports.module';
|
|
||||||
import { LocomotivesModule } from '../locomotives/locomotives.module';
|
import { LocomotivesModule } from '../locomotives/locomotives.module';
|
||||||
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
||||||
import { FacilityHandlingService } from './facility-handling.service';
|
import { FacilityHandlingService } from './facility-handling.service';
|
||||||
@@ -17,6 +16,7 @@ import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.
|
|||||||
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||||||
import { TrainSet } from '../train-sets/entities/train-set.entity';
|
import { TrainSet } from '../train-sets/entities/train-set.entity';
|
||||||
import { TrainSetsModule } from '../train-sets/train-sets.module';
|
import { TrainSetsModule } from '../train-sets/train-sets.module';
|
||||||
|
import { TrainCrewModule } from '../train-crew/train-crew.module';
|
||||||
import { TrainSchedulesModule } from '../train-schedules/train-schedules.module';
|
import { TrainSchedulesModule } from '../train-schedules/train-schedules.module';
|
||||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||||
import { WagonTypesModule } from '../wagon-types/wagon-types.module';
|
import { WagonTypesModule } from '../wagon-types/wagon-types.module';
|
||||||
@@ -68,7 +68,6 @@ import { ContractsModule } from '../contracts/contracts.module';
|
|||||||
UserTradeAccessModule,
|
UserTradeAccessModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
NotificationInboxModule,
|
NotificationInboxModule,
|
||||||
ExportsModule,
|
|
||||||
LocomotivesModule,
|
LocomotivesModule,
|
||||||
WagonTypesModule,
|
WagonTypesModule,
|
||||||
TrainSetsModule,
|
TrainSetsModule,
|
||||||
@@ -76,6 +75,7 @@ import { ContractsModule } from '../contracts/contracts.module';
|
|||||||
forwardRef(() => WarehousesModule),
|
forwardRef(() => WarehousesModule),
|
||||||
RuleEngineModule,
|
RuleEngineModule,
|
||||||
forwardRef(() => ContractsModule),
|
forwardRef(() => ContractsModule),
|
||||||
|
TrainCrewModule,
|
||||||
],
|
],
|
||||||
controllers: [TrainSchedulingController],
|
controllers: [TrainSchedulingController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import ExcelJS from 'exceljs';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildWagonListWorkbook,
|
||||||
|
groupWagonListLines,
|
||||||
|
WAGON_LIST_HEADERS,
|
||||||
|
WagonListLine,
|
||||||
|
wagonListSheetName,
|
||||||
|
} from './wagon-list-workbook.util';
|
||||||
|
|
||||||
|
const line = (overrides: Partial<WagonListLine>): WagonListLine => ({
|
||||||
|
sequenceNo: 1,
|
||||||
|
wagonNumber: 'ER0001',
|
||||||
|
containerNumber: 'CONT0000001',
|
||||||
|
containerSizeFt: 40,
|
||||||
|
loadType: 'CONTAINER',
|
||||||
|
bulkCargoDescription: null,
|
||||||
|
originLabel: 'DCT',
|
||||||
|
destinationLabel: 'GMP',
|
||||||
|
customerName: 'ABC transit',
|
||||||
|
transitor: null,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mirrors the reference sheet: a 40ft wagon, a wagon carrying two 20ft boxes,
|
||||||
|
// then a second customer's single wagon, and a bulk wagon for a third.
|
||||||
|
const fixture: WagonListLine[] = [
|
||||||
|
line({ sequenceNo: 1, wagonNumber: 'ER0691', containerNumber: 'TLLU4855720' }),
|
||||||
|
line({
|
||||||
|
sequenceNo: 2,
|
||||||
|
wagonNumber: 'ER0693',
|
||||||
|
containerNumber: 'CXDU1833620',
|
||||||
|
containerSizeFt: 20,
|
||||||
|
transitor: 'Semuzu Transit',
|
||||||
|
}),
|
||||||
|
line({
|
||||||
|
sequenceNo: 2,
|
||||||
|
wagonNumber: 'ER0693',
|
||||||
|
containerNumber: 'TTNU1328287',
|
||||||
|
containerSizeFt: 20,
|
||||||
|
transitor: 'Semuzu Transit',
|
||||||
|
}),
|
||||||
|
line({
|
||||||
|
sequenceNo: 3,
|
||||||
|
wagonNumber: 'ER0444',
|
||||||
|
containerNumber: 'ESLU0720200',
|
||||||
|
containerSizeFt: 20,
|
||||||
|
customerName: 'SYNTRANS LOGISTICS PLC',
|
||||||
|
}),
|
||||||
|
line({
|
||||||
|
sequenceNo: 4,
|
||||||
|
wagonNumber: 'ER0716',
|
||||||
|
containerNumber: null,
|
||||||
|
containerSizeFt: null,
|
||||||
|
loadType: 'BULK',
|
||||||
|
bulkCargoDescription: 'Wheat',
|
||||||
|
customerName: 'Baili food processing',
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('groupWagonListLines', () => {
|
||||||
|
it('groups by customer in first-appearance order and counts wagons, not containers', () => {
|
||||||
|
const { groups, totalWagons } = groupWagonListLines(fixture);
|
||||||
|
|
||||||
|
expect(groups.map((g) => g.companyName)).toEqual([
|
||||||
|
'ABC transit',
|
||||||
|
'SYNTRANS LOGISTICS PLC',
|
||||||
|
'Baili food processing',
|
||||||
|
]);
|
||||||
|
expect(groups.map((g) => g.wagonCount)).toEqual([2, 1, 1]);
|
||||||
|
expect(totalWagons).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('numbers wagons across the whole sheet, repeating the ordinal for a second container', () => {
|
||||||
|
const { groups } = groupWagonListLines(fixture);
|
||||||
|
|
||||||
|
expect(groups[0].lines.map((l) => l.wagonOrdinal)).toEqual([1, 2, 2]);
|
||||||
|
expect(groups[1].lines.map((l) => l.wagonOrdinal)).toEqual([3]);
|
||||||
|
expect(groups[2].lines.map((l) => l.wagonOrdinal)).toEqual([4]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders container size as "NNft", bulk loads by cargo description, and the transitor once per group', () => {
|
||||||
|
const { groups } = groupWagonListLines(fixture);
|
||||||
|
|
||||||
|
expect(groups[0].lines.map((l) => l.containerType)).toEqual(['40ft', '20ft', '20ft']);
|
||||||
|
expect(groups[0].transitor).toBe('Semuzu Transit');
|
||||||
|
expect(groups[2].lines[0]).toMatchObject({
|
||||||
|
containerNumber: 'Wheat',
|
||||||
|
containerType: 'Bulk',
|
||||||
|
});
|
||||||
|
expect(groups[2].transitor).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('files lines with no customer under a placeholder group', () => {
|
||||||
|
const { groups } = groupWagonListLines([line({ customerName: null })]);
|
||||||
|
expect(groups[0].companyName).toBe('—');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('wagonListSheetName', () => {
|
||||||
|
it('strips characters Excel forbids and caps at 31 characters', () => {
|
||||||
|
expect(wagonListSheetName('V138U/8502')).toBe('V138U 8502');
|
||||||
|
expect(wagonListSheetName('a'.repeat(40))).toHaveLength(31);
|
||||||
|
expect(wagonListSheetName('///')).toBe('Wagons');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildWagonListWorkbook', () => {
|
||||||
|
let sheet: ExcelJS.Worksheet;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const grouped = groupWagonListLines(fixture);
|
||||||
|
const buffer = await buildWagonListWorkbook({ trainLabel: 'V138U/8502', ...grouped });
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
await workbook.xlsx.load(buffer as unknown as ExcelJS.Buffer);
|
||||||
|
sheet = workbook.worksheets[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
const cell = (address: string) => sheet.getCell(address).value;
|
||||||
|
const merged = (address: string) => sheet.getCell(address).isMerged;
|
||||||
|
|
||||||
|
it('opens with the banner (train + total wagons) merged across every column, then the headers', () => {
|
||||||
|
expect(sheet.name).toBe('V138U 8502');
|
||||||
|
expect(String(cell('A1'))).toMatch(/^V138U\/8502\s+Total wagons= 4$/);
|
||||||
|
expect(merged('I1')).toBe(true);
|
||||||
|
expect(sheet.getRow(2).values).toEqual([undefined, ...WAGON_LIST_HEADERS]);
|
||||||
|
expect(sheet.getCell('A2').font?.bold).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lays each customer out as a contiguous block separated by a blank row', () => {
|
||||||
|
// Rows 3-5: ABC transit; row 6 blank; row 7: SYNTRANS; row 8 blank; row 9: Baili.
|
||||||
|
expect([cell('B3'), cell('B4'), cell('B5')]).toEqual(['ER0691', 'ER0693', 'ER0693']);
|
||||||
|
expect(sheet.getRow(6).values).toEqual([]);
|
||||||
|
expect(cell('B7')).toBe('ER0444');
|
||||||
|
expect(sheet.getRow(8).values).toEqual([]);
|
||||||
|
expect(cell('B9')).toBe('ER0716');
|
||||||
|
expect(cell('C9')).toBe('Wheat');
|
||||||
|
expect(cell('G9')).toBe('Bulk');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prints "No." once per wagon, merged down a two-container wagon', () => {
|
||||||
|
expect([cell('A3'), cell('A4'), cell('A5')]).toEqual([1, 2, 2]);
|
||||||
|
expect(merged('A4')).toBe(true);
|
||||||
|
expect(merged('A5')).toBe(true);
|
||||||
|
expect(merged('A3')).toBe(false);
|
||||||
|
expect(cell('A7')).toBe(3);
|
||||||
|
expect(cell('A9')).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges wagon count, company and transitor down the whole customer block', () => {
|
||||||
|
expect(cell('D3')).toBe(2);
|
||||||
|
expect(cell('H3')).toBe('ABC transit');
|
||||||
|
expect(cell('I3')).toBe('Semuzu Transit');
|
||||||
|
for (const col of ['D', 'H', 'I']) {
|
||||||
|
expect(merged(`${col}3`)).toBe(true);
|
||||||
|
expect(merged(`${col}5`)).toBe(true);
|
||||||
|
}
|
||||||
|
expect(sheet.getCell('H3').font?.bold).toBe(true);
|
||||||
|
// A single-line block has nothing to merge.
|
||||||
|
expect(merged('H7')).toBe(false);
|
||||||
|
expect(cell('D7')).toBe(1);
|
||||||
|
expect(cell('I7')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries the route and container size on every line', () => {
|
||||||
|
expect([cell('E3'), cell('F3'), cell('G3')]).toEqual(['DCT', 'GMP', '40ft']);
|
||||||
|
expect([cell('E5'), cell('F5'), cell('G5')]).toEqual(['DCT', 'GMP', '20ft']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import ExcelJS from 'exceljs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One loaded container (or one bulk load) on a wagon of the schedule — the
|
||||||
|
* input grain of the wagon-list workbook. A wagon carrying two boxes arrives
|
||||||
|
* as two lines sharing `sequenceNo`.
|
||||||
|
*/
|
||||||
|
export interface WagonListLine {
|
||||||
|
sequenceNo: number | null;
|
||||||
|
wagonNumber: string | null;
|
||||||
|
containerNumber: string | null;
|
||||||
|
/** 20 / 40 / 45 …; null for bulk or unknown. */
|
||||||
|
containerSizeFt: number | null;
|
||||||
|
loadType: string | null;
|
||||||
|
bulkCargoDescription: string | null;
|
||||||
|
originLabel: string | null;
|
||||||
|
destinationLabel: string | null;
|
||||||
|
customerName: string | null;
|
||||||
|
/** The customs clearing / transit agent named on the booking. */
|
||||||
|
transitor: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WagonListGroupLine {
|
||||||
|
/** Sheet-wide wagon counter — printed once per wagon, not once per container. */
|
||||||
|
wagonOrdinal: number;
|
||||||
|
sequenceNo: number | null;
|
||||||
|
wagonNumber: string;
|
||||||
|
containerNumber: string;
|
||||||
|
containerType: string;
|
||||||
|
origin: string;
|
||||||
|
destination: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All lines of one customer, contiguous on the sheet. */
|
||||||
|
export interface WagonListGroup {
|
||||||
|
companyName: string;
|
||||||
|
transitor: string;
|
||||||
|
/** Distinct wagons in the group — the "Number of Wagons" cell. */
|
||||||
|
wagonCount: number;
|
||||||
|
lines: WagonListGroupLine[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WagonListWorkbookInput {
|
||||||
|
/** Train number (falls back to the schedule reference) — the banner text. */
|
||||||
|
trainLabel: string;
|
||||||
|
groups: WagonListGroup[];
|
||||||
|
totalWagons: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BLANK = '—';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Groups the container-grain lines by customer, in order of first appearance,
|
||||||
|
* keeping consist order inside each group. Wagon ordinals run across the whole
|
||||||
|
* sheet so the reader can count wagons down the "No." column.
|
||||||
|
*/
|
||||||
|
export function groupWagonListLines(lines: WagonListLine[]): {
|
||||||
|
groups: WagonListGroup[];
|
||||||
|
totalWagons: number;
|
||||||
|
} {
|
||||||
|
const groups = new Map<
|
||||||
|
string,
|
||||||
|
WagonListGroup & { transitors: Set<string>; wagons: Set<string> }
|
||||||
|
>();
|
||||||
|
const ordinalByGroupWagon = new Map<string, number>();
|
||||||
|
let nextOrdinal = 1;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const companyName = line.customerName?.trim() || BLANK;
|
||||||
|
let group = groups.get(companyName);
|
||||||
|
if (!group) {
|
||||||
|
group = {
|
||||||
|
companyName,
|
||||||
|
transitor: '',
|
||||||
|
wagonCount: 0,
|
||||||
|
lines: [],
|
||||||
|
transitors: new Set(),
|
||||||
|
wagons: new Set(),
|
||||||
|
};
|
||||||
|
groups.set(companyName, group);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wagonKey = `${line.sequenceNo ?? ''}|${line.wagonNumber ?? ''}`;
|
||||||
|
const ordinalKey = `${companyName} ${wagonKey}`;
|
||||||
|
let wagonOrdinal = ordinalByGroupWagon.get(ordinalKey);
|
||||||
|
if (wagonOrdinal === undefined) {
|
||||||
|
wagonOrdinal = nextOrdinal++;
|
||||||
|
ordinalByGroupWagon.set(ordinalKey, wagonOrdinal);
|
||||||
|
group.wagons.add(wagonKey);
|
||||||
|
}
|
||||||
|
const transitor = line.transitor?.trim();
|
||||||
|
if (transitor) group.transitors.add(transitor);
|
||||||
|
|
||||||
|
const isBulk = line.loadType === 'BULK' && !line.containerNumber;
|
||||||
|
group.lines.push({
|
||||||
|
wagonOrdinal,
|
||||||
|
sequenceNo: line.sequenceNo,
|
||||||
|
wagonNumber: line.wagonNumber ?? BLANK,
|
||||||
|
containerNumber:
|
||||||
|
line.containerNumber ?? (isBulk ? (line.bulkCargoDescription ?? 'Bulk') : BLANK),
|
||||||
|
containerType: isBulk ? 'Bulk' : line.containerSizeFt ? `${line.containerSizeFt}ft` : BLANK,
|
||||||
|
origin: line.originLabel ?? BLANK,
|
||||||
|
destination: line.destinationLabel ?? BLANK,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = [...groups.values()].map(({ transitors, wagons, ...group }) => ({
|
||||||
|
...group,
|
||||||
|
transitor: [...transitors].join(', '),
|
||||||
|
wagonCount: wagons.size,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
groups: result,
|
||||||
|
totalWagons: result.reduce((sum, g) => sum + g.wagonCount, 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLUMN_WIDTHS = [3.7, 14.9, 14.9, 17.3, 15, 12.8, 16.2, 27.5, 29.9];
|
||||||
|
export const WAGON_LIST_HEADERS = [
|
||||||
|
'No.',
|
||||||
|
'Wagon',
|
||||||
|
'Container No.',
|
||||||
|
'Number of Wagons',
|
||||||
|
'Origin',
|
||||||
|
'Destination',
|
||||||
|
'Type of Container',
|
||||||
|
'Company Name',
|
||||||
|
'Transitor',
|
||||||
|
];
|
||||||
|
const LAST_COLUMN = WAGON_LIST_HEADERS.length;
|
||||||
|
/** Excel's "Blue-Gray, Text 2, Lighter 60%" — the banner fill of the reference sheet. */
|
||||||
|
const BANNER_FILL: ExcelJS.Fill = {
|
||||||
|
type: 'pattern',
|
||||||
|
pattern: 'solid',
|
||||||
|
fgColor: { argb: 'FFACB9CA' },
|
||||||
|
};
|
||||||
|
const CENTERED: Partial<ExcelJS.Alignment> = { horizontal: 'center', vertical: 'middle' };
|
||||||
|
|
||||||
|
/** Excel forbids `[]:*?/\` in sheet names and caps them at 31 characters. */
|
||||||
|
export function wagonListSheetName(trainLabel: string): string {
|
||||||
|
const cleaned = trainLabel.replace(/[[\]:*?/\\]+/g, ' ').trim();
|
||||||
|
return (cleaned || 'Wagons').slice(0, 31);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The operations wagon-list sheet, laid out like the hand-made one the yard
|
||||||
|
* circulates: a banner row (train number + total wagons), one header row, then
|
||||||
|
* the containers grouped by customer with a blank row between customers.
|
||||||
|
* Inside a group the wagon number repeats per container while "No." is merged
|
||||||
|
* down the wagon; "Number of Wagons", "Company Name" and "Transitor" are merged
|
||||||
|
* down the whole group.
|
||||||
|
*/
|
||||||
|
export async function buildWagonListWorkbook(input: WagonListWorkbookInput): Promise<Buffer> {
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const sheet = workbook.addWorksheet(wagonListSheetName(input.trainLabel), {
|
||||||
|
views: [{ zoomScale: 85 }],
|
||||||
|
});
|
||||||
|
COLUMN_WIDTHS.forEach((width, i) => {
|
||||||
|
sheet.getColumn(i + 1).width = width;
|
||||||
|
});
|
||||||
|
|
||||||
|
const banner = sheet.addRow([
|
||||||
|
`${input.trainLabel}${' '.repeat(40)}Total wagons= ${input.totalWagons}`,
|
||||||
|
]);
|
||||||
|
sheet.mergeCells(1, 1, 1, LAST_COLUMN);
|
||||||
|
banner.height = 28;
|
||||||
|
const bannerCell = banner.getCell(1);
|
||||||
|
bannerCell.font = { name: 'Calibri', size: 12, bold: true };
|
||||||
|
bannerCell.alignment = CENTERED;
|
||||||
|
bannerCell.fill = BANNER_FILL;
|
||||||
|
|
||||||
|
const header = sheet.addRow(WAGON_LIST_HEADERS);
|
||||||
|
header.eachCell((cell) => {
|
||||||
|
cell.font = { name: 'Calibri', size: 11, bold: true };
|
||||||
|
cell.alignment = CENTERED;
|
||||||
|
});
|
||||||
|
|
||||||
|
input.groups.forEach((group, groupIndex) => {
|
||||||
|
if (groupIndex > 0) sheet.addRow([]);
|
||||||
|
const firstRow = sheet.rowCount + 1;
|
||||||
|
|
||||||
|
let wagonStartRow = firstRow;
|
||||||
|
group.lines.forEach((line, lineIndex) => {
|
||||||
|
const isFirstLine = lineIndex === 0;
|
||||||
|
const newWagon = isFirstLine || group.lines[lineIndex - 1].wagonOrdinal !== line.wagonOrdinal;
|
||||||
|
const row = sheet.addRow([
|
||||||
|
newWagon ? line.wagonOrdinal : null,
|
||||||
|
line.wagonNumber,
|
||||||
|
line.containerNumber,
|
||||||
|
isFirstLine ? group.wagonCount : null,
|
||||||
|
line.origin,
|
||||||
|
line.destination,
|
||||||
|
line.containerType,
|
||||||
|
isFirstLine ? group.companyName : null,
|
||||||
|
isFirstLine ? group.transitor || null : null,
|
||||||
|
]);
|
||||||
|
for (let col = 1; col <= LAST_COLUMN; col++) {
|
||||||
|
const cell = row.getCell(col);
|
||||||
|
cell.font = { name: 'Calibri', size: 11, bold: col === 8 };
|
||||||
|
if (col === 8) cell.alignment = { ...CENTERED, wrapText: true };
|
||||||
|
else if (col !== 2 && col !== 3) cell.alignment = CENTERED;
|
||||||
|
}
|
||||||
|
row.getCell(1).numFmt = '#,##0';
|
||||||
|
|
||||||
|
if (newWagon && !isFirstLine) {
|
||||||
|
if (row.number - 1 > wagonStartRow) sheet.mergeCells(wagonStartRow, 1, row.number - 1, 1);
|
||||||
|
wagonStartRow = row.number;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const lastRow = sheet.rowCount;
|
||||||
|
if (lastRow > wagonStartRow) sheet.mergeCells(wagonStartRow, 1, lastRow, 1);
|
||||||
|
if (lastRow > firstRow) {
|
||||||
|
for (const col of [4, 8, 9]) sheet.mergeCells(firstRow, col, lastRow, col);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Buffer.from(await workbook.xlsx.writeBuffer());
|
||||||
|
}
|
||||||
@@ -171,7 +171,7 @@ describe('wagon-plan.util', () => {
|
|||||||
expect(validateContainerPlacements([booking], plan, placements)).toEqual([]);
|
expect(validateContainerPlacements([booking], plan, placements)).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects 20ft container over max individual weight', () => {
|
it('rejects a container over its line weight-limit-rule capacity', () => {
|
||||||
const booking = makeContainerBooking('c20', [{ quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 35 }]);
|
const booking = makeContainerBooking('c20', [{ quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 35 }]);
|
||||||
const units = expandBookingContainerUnits([booking]);
|
const units = expandBookingContainerUnits([booking]);
|
||||||
const placements = units.map((unit, index) => ({
|
const placements = units.map((unit, index) => ({
|
||||||
@@ -182,11 +182,29 @@ describe('wagon-plan.util', () => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const violations = validate20ftContainerRules(units, placements, {
|
const violations = validate20ftContainerRules(units, placements, {
|
||||||
max20ftContainerWeightTons: 30,
|
maxContainerWeightTonsByLineId: { [units[0]!.bookingContainerId]: 30 },
|
||||||
max20ftPairWeightDiffTons: 10,
|
max20ftPairWeightDiffTons: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(violations.some((v) => v.includes('exceeds max 30T'))).toBe(true);
|
expect(violations.filter((v) => v.includes('weight limit rule capacity of 30T'))).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies no per-box ceiling to a line without a weight-limit-rule capacity', () => {
|
||||||
|
const booking = makeContainerBooking('c20b', [{ quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 35 }]);
|
||||||
|
const units = expandBookingContainerUnits([booking]);
|
||||||
|
const placements = units.map((unit, index) => ({
|
||||||
|
bookingContainerId: unit.bookingContainerId,
|
||||||
|
unitIndex: unit.unitIndex,
|
||||||
|
sequenceNo: 1,
|
||||||
|
containerNumber: `CNTR-${index + 1}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const violations = validate20ftContainerRules(units, placements, {
|
||||||
|
maxContainerWeightTonsByLineId: {},
|
||||||
|
max20ftPairWeightDiffTons: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(violations).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects 20ft pair when weight difference exceeds limit', () => {
|
it('rejects 20ft pair when weight difference exceeds limit', () => {
|
||||||
@@ -204,7 +222,6 @@ describe('wagon-plan.util', () => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const violations = validate20ftContainerRules(units, placements, {
|
const violations = validate20ftContainerRules(units, placements, {
|
||||||
max20ftContainerWeightTons: 30,
|
|
||||||
max20ftPairWeightDiffTons: 10,
|
max20ftPairWeightDiffTons: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,12 +20,17 @@ export type TrainLimitConfig = {
|
|||||||
maxWeightTons?: number;
|
maxWeightTons?: number;
|
||||||
maxLengthMeters?: number;
|
maxLengthMeters?: number;
|
||||||
maxWagonsPerTrain?: number;
|
maxWagonsPerTrain?: number;
|
||||||
max20ftContainerWeightTons?: number;
|
|
||||||
max20ftPairWeightDiffTons?: number;
|
max20ftPairWeightDiffTons?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ContainerPlacementRules = {
|
export type ContainerPlacementRules = {
|
||||||
max20ftContainerWeightTons?: number;
|
/**
|
||||||
|
* Hard per-box weight ceiling keyed by booking container LINE id, resolved
|
||||||
|
* from the rule engine's weight limit rule (`max_capacity_tons`) for the
|
||||||
|
* line's container type and the booking's trade direction. A line with no
|
||||||
|
* entry has no ceiling — the rule's capacity is optional.
|
||||||
|
*/
|
||||||
|
maxContainerWeightTonsByLineId?: Record<string, number>;
|
||||||
max20ftPairWeightDiffTons?: number;
|
max20ftPairWeightDiffTons?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -820,15 +825,21 @@ export function perEdgeConsistUsage(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-box weight rules for a container plan:
|
||||||
|
* - every unit is checked against its line's weight-limit-rule capacity
|
||||||
|
* ceiling (`maxContainerWeightTonsByLineId`, any size);
|
||||||
|
* - 20ft pairs sharing a wagon are checked for weight imbalance.
|
||||||
|
*/
|
||||||
export function validate20ftContainerRules(
|
export function validate20ftContainerRules(
|
||||||
units: ContainerUnitRow[],
|
units: ContainerUnitRow[],
|
||||||
placements: ContainerPlacementInput[],
|
placements: ContainerPlacementInput[],
|
||||||
rules?: ContainerPlacementRules,
|
rules?: ContainerPlacementRules,
|
||||||
): string[] {
|
): string[] {
|
||||||
const violations: string[] = [];
|
const violations: string[] = [];
|
||||||
const maxEach = rules?.max20ftContainerWeightTons;
|
const capacityByLine = rules?.maxContainerWeightTonsByLineId;
|
||||||
const maxDiff = rules?.max20ftPairWeightDiffTons;
|
const maxDiff = rules?.max20ftPairWeightDiffTons;
|
||||||
if (maxEach == null && maxDiff == null) return violations;
|
if (capacityByLine == null && maxDiff == null) return violations;
|
||||||
|
|
||||||
const placementByUnit = new Map(
|
const placementByUnit = new Map(
|
||||||
placements.map((p) => [`${p.bookingContainerId}:${p.unitIndex}`, p]),
|
placements.map((p) => [`${p.bookingContainerId}:${p.unitIndex}`, p]),
|
||||||
@@ -837,15 +848,16 @@ export function validate20ftContainerRules(
|
|||||||
const weightsBySlot = new Map<number, number[]>();
|
const weightsBySlot = new Map<number, number[]>();
|
||||||
|
|
||||||
for (const unit of units) {
|
for (const unit of units) {
|
||||||
const sizeFt = unit.sizeFt ?? (unit.containerTypeCode.includes('40') ? 40 : 20);
|
const maxEach = capacityByLine?.[unit.bookingContainerId];
|
||||||
if (sizeFt >= 40) continue;
|
|
||||||
|
|
||||||
if (maxEach != null && unit.grossWeightTons > maxEach) {
|
if (maxEach != null && unit.grossWeightTons > maxEach) {
|
||||||
violations.push(
|
violations.push(
|
||||||
`${unit.label} weight ${unit.grossWeightTons}T exceeds max ${maxEach}T for 20ft containers`,
|
`${unit.label} weight ${unit.grossWeightTons}T exceeds the weight limit rule capacity of ${maxEach}T for ${unit.containerTypeCode} containers`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sizeFt = unit.sizeFt ?? (unit.containerTypeCode.includes('40') ? 40 : 20);
|
||||||
|
if (sizeFt >= 40) continue;
|
||||||
|
|
||||||
const placement = placementByUnit.get(`${unit.bookingContainerId}:${unit.unitIndex}`);
|
const placement = placementByUnit.get(`${unit.bookingContainerId}:${unit.unitIndex}`);
|
||||||
if (!placement?.sequenceNo) continue;
|
if (!placement?.sequenceNo) continue;
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,15 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
|||||||
import { Transform } from "class-transformer";
|
import { Transform } from "class-transformer";
|
||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsDateString,
|
|
||||||
IsEmail,
|
IsEmail,
|
||||||
|
IsEnum,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
|
|
||||||
import { IsValidPhone } from "../../../common/validators/is-phone-number.validator";
|
import { IsValidPhone } from "../../../common/validators/is-phone-number.validator";
|
||||||
|
import { TransitAgentCountry } from "../entities/transit-agent.entity";
|
||||||
|
|
||||||
const toBoolean = ({ value }: { value: unknown }) => {
|
const toBoolean = ({ value }: { value: unknown }) => {
|
||||||
if (typeof value === "boolean") return value;
|
if (typeof value === "boolean") return value;
|
||||||
@@ -24,13 +25,18 @@ export class CreateTransitAgentDto {
|
|||||||
@MaxLength(150)
|
@MaxLength(150)
|
||||||
name!: string;
|
name!: string;
|
||||||
|
|
||||||
@ApiProperty({ example: "2026-01-01" })
|
/**
|
||||||
@IsDateString()
|
* Defaults to Djibouti, which is what the whole roster was before Ethiopian
|
||||||
validFrom!: string;
|
* agents were added. Only `ET` agents are offered to a freight forwarder
|
||||||
|
* picking itself during onboarding.
|
||||||
@ApiProperty({ example: "2026-12-31" })
|
*/
|
||||||
@IsDateString()
|
@ApiPropertyOptional({
|
||||||
validTo!: string;
|
enum: TransitAgentCountry,
|
||||||
|
default: TransitAgentCountry.Djibouti,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(TransitAgentCountry)
|
||||||
|
country?: TransitAgentCountry;
|
||||||
|
|
||||||
@ApiPropertyOptional({ default: true })
|
@ApiPropertyOptional({ default: true })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -2,22 +2,38 @@ import { BaseEntity } from "@edr/api-common";
|
|||||||
import { Column, Entity, Index } from "typeorm";
|
import { Column, Entity, Index } from "typeorm";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Djibouti transit officer GL Djibouti may assign against a shipment's
|
* Where the agent is licensed. The roster started Djibouti-only (the officers
|
||||||
* transit-assignee handshake. Admin-managed so the roster and each officer's
|
* GL Djibouti assigns), so that is the column default. An Ethiopian transit
|
||||||
* validity window arrive without a code change; `isActive` is the manual
|
* agent is the same business as a freight forwarder — a forwarder onboarding on
|
||||||
* suspend/reactivate switch, independent of the validity window.
|
* the portal picks itself from the `ET` entries (`Company.transitAgentId`).
|
||||||
|
*/
|
||||||
|
export enum TransitAgentCountry {
|
||||||
|
Ethiopia = "ET",
|
||||||
|
Djibouti = "DJ",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transit officer GL Djibouti may assign against a shipment's transit-assignee
|
||||||
|
* handshake, and — for the Ethiopian entries — the roster a freight forwarder
|
||||||
|
* registers itself against. Admin-managed so the roster arrives without a code
|
||||||
|
* change; `isActive` is the manual suspend/reactivate switch and the only
|
||||||
|
* thing that decides whether an agent may be assigned or picked.
|
||||||
*/
|
*/
|
||||||
@Entity({ schema: "freight", name: "transit_agents" })
|
@Entity({ schema: "freight", name: "transit_agents" })
|
||||||
@Index(["isActive"])
|
@Index(["isActive"])
|
||||||
|
@Index(["country"])
|
||||||
export class TransitAgent extends BaseEntity {
|
export class TransitAgent extends BaseEntity {
|
||||||
@Column({ name: "name", type: "varchar", length: 150 })
|
@Column({ name: "name", type: "varchar", length: 150 })
|
||||||
name!: string;
|
name!: string;
|
||||||
|
|
||||||
@Column({ name: "valid_from", type: "date" })
|
@Column({
|
||||||
validFrom!: string;
|
name: "country",
|
||||||
|
type: "varchar",
|
||||||
@Column({ name: "valid_to", type: "date" })
|
length: 2,
|
||||||
validTo!: string;
|
enum: TransitAgentCountry,
|
||||||
|
default: TransitAgentCountry.Djibouti,
|
||||||
|
})
|
||||||
|
country!: TransitAgentCountry;
|
||||||
|
|
||||||
@Column({ name: "is_active", type: "boolean", default: true })
|
@Column({ name: "is_active", type: "boolean", default: true })
|
||||||
isActive!: boolean;
|
isActive!: boolean;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
|
||||||
|
import { PortalCustomer } from "../../common/booking-guards";
|
||||||
import {
|
import {
|
||||||
RuleEngineCreate,
|
RuleEngineCreate,
|
||||||
RuleEngineDelete,
|
RuleEngineDelete,
|
||||||
@@ -50,16 +51,29 @@ export class TransitAgentsController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Active + currently valid officers — the transit-assignee assignment dropdown. */
|
/** Active officers — the transit-assignee assignment dropdown. */
|
||||||
@Get("assignable")
|
@Get("assignable")
|
||||||
@RuleEngineView("transit-agents")
|
@RuleEngineView("transit-agents")
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: "List active transit agents (assignable)" })
|
||||||
summary: "List transit agents assignable right now (active and in-window)",
|
|
||||||
})
|
|
||||||
findAssignable() {
|
findAssignable() {
|
||||||
return this.transitAgentsService.findAssignable();
|
return this.transitAgentsService.findAssignable();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Ethiopian roster, id + name only, for a customer registering as a
|
||||||
|
* freight forwarder to pick itself from. Declared before `:id` so the
|
||||||
|
* literal path is not swallowed by the UUID route.
|
||||||
|
*/
|
||||||
|
@Get("forwarder-options")
|
||||||
|
@PortalCustomer()
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"List active Ethiopian transit agents (id + name) a freight forwarder can register as",
|
||||||
|
})
|
||||||
|
findForwarderOptions() {
|
||||||
|
return this.transitAgentsService.findForwarderOptions();
|
||||||
|
}
|
||||||
|
|
||||||
@Get(":id")
|
@Get(":id")
|
||||||
@RuleEngineView("transit-agents")
|
@RuleEngineView("transit-agents")
|
||||||
@ApiOperation({ summary: "Get a transit agent by ID" })
|
@ApiOperation({ summary: "Get a transit agent by ID" })
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
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 {
|
import { EntityManager, Repository } from "typeorm";
|
||||||
EntityManager,
|
|
||||||
LessThanOrEqual,
|
|
||||||
MoreThanOrEqual,
|
|
||||||
Repository,
|
|
||||||
} from "typeorm";
|
|
||||||
|
|
||||||
import { TransitAgent } from "./entities/transit-agent.entity";
|
import {
|
||||||
|
TransitAgent,
|
||||||
|
TransitAgentCountry,
|
||||||
|
} from "./entities/transit-agent.entity";
|
||||||
|
|
||||||
|
/** What a forwarder picking itself from the roster needs: the id and a label. */
|
||||||
|
export type ForwarderTransitAgentOption = Pick<TransitAgent, "id" | "name">;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TransitAgentsRepository extends BaseRepository<TransitAgent> {
|
export class TransitAgentsRepository extends BaseRepository<TransitAgent> {
|
||||||
@@ -19,14 +20,23 @@ export class TransitAgentsRepository extends BaseRepository<TransitAgent> {
|
|||||||
super(repository);
|
super(repository);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Active AND currently inside its validity window (today's date, server-side). */
|
/** Every active agent — the GL assignment dropdown. */
|
||||||
findAssignable(today: string): Promise<TransitAgent[]> {
|
findAssignable(): Promise<TransitAgent[]> {
|
||||||
return this.repository.find({
|
return this.repository.find({
|
||||||
where: {
|
where: { isActive: true },
|
||||||
isActive: true,
|
order: { name: "ASC" },
|
||||||
validFrom: LessThanOrEqual(today),
|
});
|
||||||
validTo: MoreThanOrEqual(today),
|
}
|
||||||
},
|
|
||||||
|
/**
|
||||||
|
* The Ethiopian roster a freight forwarder registers itself against, as
|
||||||
|
* `{ id, name }` only — this is served to customers, who have no business
|
||||||
|
* seeing another agent's email or phone. Suspended agents are left out.
|
||||||
|
*/
|
||||||
|
findForwarderOptions(): Promise<ForwarderTransitAgentOption[]> {
|
||||||
|
return this.repository.find({
|
||||||
|
select: { id: true, name: true },
|
||||||
|
where: { isActive: true, country: TransitAgentCountry.Ethiopia },
|
||||||
order: { name: "ASC" },
|
order: { name: "ASC" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ import {
|
|||||||
} from "@tria-plc/api-common/utils/enums/user.enum";
|
} from "@tria-plc/api-common/utils/enums/user.enum";
|
||||||
|
|
||||||
import { ResetChannel } from "../auth/dto/forgot-password.dto";
|
import { ResetChannel } from "../auth/dto/forgot-password.dto";
|
||||||
|
import { TransitAgentCountry } from "./entities/transit-agent.entity";
|
||||||
import { TransitAgentsService } from "./transit-agents.service";
|
import { TransitAgentsService } from "./transit-agents.service";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The account half of a transit agent. The roster half (validity window,
|
* The account half of a transit agent. The roster half (assignability)
|
||||||
* assignability) predates this and is untouched — what these lock is that
|
* predates this and is untouched — what these lock is that
|
||||||
* adding a login did not make an account MANDATORY, since production is full of
|
* adding a login did not make an account MANDATORY, since production is full of
|
||||||
* roster-only agents that must keep working.
|
* roster-only agents that must keep working.
|
||||||
*/
|
*/
|
||||||
@@ -36,8 +37,6 @@ describe("TransitAgentsService accounts", () => {
|
|||||||
|
|
||||||
const base = {
|
const base = {
|
||||||
name: "Ahmed Bourhan",
|
name: "Ahmed Bourhan",
|
||||||
validFrom: "2026-01-01",
|
|
||||||
validTo: "2026-12-31",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -343,4 +342,102 @@ describe("TransitAgentsService accounts", () => {
|
|||||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An Ethiopian transit agent IS a freight forwarder, which signs up on the
|
||||||
|
* portal with its own email and phone. Nothing minted from this side may
|
||||||
|
* claim those first.
|
||||||
|
*/
|
||||||
|
describe("Ethiopian agents carry no contact details or account", () => {
|
||||||
|
const ethiopian = { ...base, country: TransitAgentCountry.Ethiopia };
|
||||||
|
|
||||||
|
it("refuses an email on create", async () => {
|
||||||
|
await expect(
|
||||||
|
service.createWithInvite({ ...ethiopian, email: "ff@example.et" }),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
expect(repo.create).not.toHaveBeenCalled();
|
||||||
|
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a phone number on create", async () => {
|
||||||
|
await expect(
|
||||||
|
service.createWithInvite({ ...ethiopian, phoneNumber: "+251911223344" }),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
expect(repo.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates the roster entry with neither", async () => {
|
||||||
|
const { agent } = await service.createWithInvite(ethiopian);
|
||||||
|
|
||||||
|
expect(agent.country).toBe(TransitAgentCountry.Ethiopia);
|
||||||
|
expect(agent.hasAccount).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to invite one", async () => {
|
||||||
|
repo.findById.mockResolvedValue({
|
||||||
|
id: "ta-1",
|
||||||
|
...ethiopian,
|
||||||
|
isActive: true,
|
||||||
|
userId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.invite("ta-1", { email: "ff@example.et" }),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an email on update", async () => {
|
||||||
|
repo.findById.mockResolvedValue({
|
||||||
|
id: "ta-1",
|
||||||
|
...ethiopian,
|
||||||
|
isActive: true,
|
||||||
|
userId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.update("ta-1", { email: "ff@example.et" }),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
expect(repo.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the contact details when a Djiboutian row is switched", async () => {
|
||||||
|
repo.findById.mockResolvedValue({
|
||||||
|
id: "ta-1",
|
||||||
|
...base,
|
||||||
|
country: TransitAgentCountry.Djibouti,
|
||||||
|
isActive: true,
|
||||||
|
userId: null,
|
||||||
|
email: "a@transit.dj",
|
||||||
|
phoneNumber: "+25377834567",
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.update("ta-1", { country: TransitAgentCountry.Ethiopia });
|
||||||
|
|
||||||
|
expect(repo.update).toHaveBeenCalledWith(
|
||||||
|
"ta-1",
|
||||||
|
expect.objectContaining({
|
||||||
|
country: TransitAgentCountry.Ethiopia,
|
||||||
|
email: null,
|
||||||
|
phoneNumber: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses the switch when the row already has a portal account", async () => {
|
||||||
|
repo.findById.mockResolvedValue({
|
||||||
|
id: "ta-1",
|
||||||
|
...base,
|
||||||
|
country: TransitAgentCountry.Djibouti,
|
||||||
|
isActive: true,
|
||||||
|
userId: "user-1",
|
||||||
|
email: "a@transit.dj",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.update("ta-1", { country: TransitAgentCountry.Ethiopia }),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
expect(repo.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,13 +26,16 @@ import { isDomesticPhone } from "../otp/otp.service";
|
|||||||
import { CreateTransitAgentDto } from "./dto/create-transit-agent.dto";
|
import { CreateTransitAgentDto } from "./dto/create-transit-agent.dto";
|
||||||
import { InviteTransitAgentDto } from "./dto/invite-transit-agent.dto";
|
import { InviteTransitAgentDto } from "./dto/invite-transit-agent.dto";
|
||||||
import { UpdateTransitAgentDto } from "./dto/update-transit-agent.dto";
|
import { UpdateTransitAgentDto } from "./dto/update-transit-agent.dto";
|
||||||
import { TransitAgent } from "./entities/transit-agent.entity";
|
import {
|
||||||
import { TransitAgentsRepository } from "./transit-agents.repository";
|
TransitAgent,
|
||||||
|
TransitAgentCountry,
|
||||||
export type TransitAgentValidityStatus = "VALID" | "NOT_STARTED" | "EXPIRED";
|
} from "./entities/transit-agent.entity";
|
||||||
|
import {
|
||||||
|
ForwarderTransitAgentOption,
|
||||||
|
TransitAgentsRepository,
|
||||||
|
} from "./transit-agents.repository";
|
||||||
|
|
||||||
export type TransitAgentView = TransitAgent & {
|
export type TransitAgentView = TransitAgent & {
|
||||||
validityStatus: TransitAgentValidityStatus;
|
|
||||||
/** True once an IAM account backs this agent — i.e. it can sign in. */
|
/** True once an IAM account backs this agent — i.e. it can sign in. */
|
||||||
hasAccount: boolean;
|
hasAccount: boolean;
|
||||||
};
|
};
|
||||||
@@ -52,24 +55,9 @@ type TransitAgentListFilter = {
|
|||||||
sortOrder?: string;
|
sortOrder?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Today as `yyyy-MM-dd`, matching the `date`-typed validity columns. */
|
function toView(agent: TransitAgent): TransitAgentView {
|
||||||
function todayISODate(): string {
|
|
||||||
return new Date().toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
function validityStatus(
|
|
||||||
agent: Pick<TransitAgent, "validFrom" | "validTo">,
|
|
||||||
): TransitAgentValidityStatus {
|
|
||||||
const today = todayISODate();
|
|
||||||
if (today < agent.validFrom) return "NOT_STARTED";
|
|
||||||
if (today > agent.validTo) return "EXPIRED";
|
|
||||||
return "VALID";
|
|
||||||
}
|
|
||||||
|
|
||||||
function withValidityStatus(agent: TransitAgent): TransitAgentView {
|
|
||||||
return {
|
return {
|
||||||
...agent,
|
...agent,
|
||||||
validityStatus: validityStatus(agent),
|
|
||||||
hasAccount: Boolean(agent.userId),
|
hasAccount: Boolean(agent.userId),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -92,9 +80,7 @@ export class TransitAgentsService {
|
|||||||
}> {
|
}> {
|
||||||
const page = filter.page ?? 1;
|
const page = filter.page ?? 1;
|
||||||
const pageSize = filter.pageSize ?? 500;
|
const pageSize = filter.pageSize ?? 500;
|
||||||
const sortBy = ["name", "validFrom", "validTo", "isActive"].includes(
|
const sortBy = ["name", "country", "isActive"].includes(filter.sortBy ?? "")
|
||||||
filter.sortBy ?? "",
|
|
||||||
)
|
|
||||||
? (filter.sortBy as keyof TransitAgent)
|
? (filter.sortBy as keyof TransitAgent)
|
||||||
: "name";
|
: "name";
|
||||||
const sortOrder =
|
const sortOrder =
|
||||||
@@ -108,7 +94,7 @@ export class TransitAgentsService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: data.map(withValidityStatus),
|
data: data.map(toView),
|
||||||
meta: {
|
meta: {
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
@@ -118,9 +104,14 @@ export class TransitAgentsService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Active and currently inside its validity window — the DJ assignment dropdown. */
|
/** Every active agent — the DJ assignment dropdown. */
|
||||||
async findAssignable(): Promise<TransitAgent[]> {
|
async findAssignable(): Promise<TransitAgent[]> {
|
||||||
return this.transitAgentsRepository.findAssignable(todayISODate());
|
return this.transitAgentsRepository.findAssignable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Ethiopian roster a freight forwarder picks itself from at onboarding. */
|
||||||
|
findForwarderOptions(): Promise<ForwarderTransitAgentOption[]> {
|
||||||
|
return this.transitAgentsRepository.findForwarderOptions();
|
||||||
}
|
}
|
||||||
|
|
||||||
async findById(id: string): Promise<TransitAgentView> {
|
async findById(id: string): Promise<TransitAgentView> {
|
||||||
@@ -128,10 +119,10 @@ export class TransitAgentsService {
|
|||||||
if (!agent) {
|
if (!agent) {
|
||||||
throw new NotFoundException(`Transit agent ${id} not found`);
|
throw new NotFoundException(`Transit agent ${id} not found`);
|
||||||
}
|
}
|
||||||
return withValidityStatus(agent);
|
return toView(agent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Used by the assignment flow — rejects a suspended or out-of-window officer. */
|
/** Used by the assignment flow — rejects a suspended officer. */
|
||||||
async getAssignable(id: string): Promise<TransitAgent> {
|
async getAssignable(id: string): Promise<TransitAgent> {
|
||||||
const agent = await this.transitAgentsRepository.findById(id);
|
const agent = await this.transitAgentsRepository.findById(id);
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
@@ -142,11 +133,6 @@ export class TransitAgentsService {
|
|||||||
`${agent.name} is suspended — pick another transit officer.`,
|
`${agent.name} is suspended — pick another transit officer.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (validityStatus(agent) !== "VALID") {
|
|
||||||
throw new BadRequestException(
|
|
||||||
`${agent.name}'s validity window has expired — pick another transit officer or extend their dates.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return agent;
|
return agent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,6 +207,32 @@ export class TransitAgentsService {
|
|||||||
return { email, username, phoneNumber };
|
return { email, username, phoneNumber };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An Ethiopian transit agent never gets a portal account of its own.
|
||||||
|
*
|
||||||
|
* It IS a freight forwarder, and the forwarder signs up on the portal as a
|
||||||
|
* customer with its own email and phone — the same ones staff would type
|
||||||
|
* here. An IAM account minted from this side would then claim that email
|
||||||
|
* first, and the forwarder's own registration would fail with "already
|
||||||
|
* registered". So for `ET` the contact fields are refused outright, and the
|
||||||
|
* invite path is closed.
|
||||||
|
*/
|
||||||
|
private assertNoAccountForEthiopian(
|
||||||
|
country: TransitAgentCountry,
|
||||||
|
dto: {
|
||||||
|
email?: string | null;
|
||||||
|
phoneNumber?: string | null;
|
||||||
|
username?: string;
|
||||||
|
},
|
||||||
|
): void {
|
||||||
|
if (country !== TransitAgentCountry.Ethiopia) return;
|
||||||
|
if (dto.email || dto.phoneNumber || dto.username) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"An Ethiopian transit agent has no email, phone or portal account here — it registers itself on the portal as a freight forwarder with its own contact details.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a transit agent.
|
* Create a transit agent.
|
||||||
*
|
*
|
||||||
@@ -237,24 +249,18 @@ export class TransitAgentsService {
|
|||||||
async createWithInvite(
|
async createWithInvite(
|
||||||
dto: CreateTransitAgentDto,
|
dto: CreateTransitAgentDto,
|
||||||
): Promise<InvitedTransitAgent> {
|
): Promise<InvitedTransitAgent> {
|
||||||
if (dto.validTo < dto.validFrom) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
"Valid-to date must be on or after valid-from date.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const base = {
|
const base = {
|
||||||
name: dto.name.trim(),
|
name: dto.name.trim(),
|
||||||
validFrom: dto.validFrom,
|
country: dto.country ?? TransitAgentCountry.Djibouti,
|
||||||
validTo: dto.validTo,
|
|
||||||
isActive: dto.isActive ?? true,
|
isActive: dto.isActive ?? true,
|
||||||
};
|
};
|
||||||
|
this.assertNoAccountForEthiopian(base.country, dto);
|
||||||
|
|
||||||
if (!dto.email) {
|
if (!dto.email) {
|
||||||
// Roster-only agent — no account, nothing to send.
|
// Roster-only agent — no account, nothing to send.
|
||||||
const agent = await this.transitAgentsRepository.create(base);
|
const agent = await this.transitAgentsRepository.create(base);
|
||||||
return {
|
return {
|
||||||
agent: withValidityStatus(agent),
|
agent: toView(agent),
|
||||||
activationSentTo: null,
|
activationSentTo: null,
|
||||||
activationChannel: null,
|
activationChannel: null,
|
||||||
};
|
};
|
||||||
@@ -286,7 +292,7 @@ export class TransitAgentsService {
|
|||||||
// valid without it.
|
// valid without it.
|
||||||
const activation = await this.sendActivationLink(agent);
|
const activation = await this.sendActivationLink(agent);
|
||||||
return {
|
return {
|
||||||
agent: withValidityStatus(agent),
|
agent: toView(agent),
|
||||||
activationSentTo: activation?.maskedTarget ?? null,
|
activationSentTo: activation?.maskedTarget ?? null,
|
||||||
activationChannel: activation?.channel ?? null,
|
activationChannel: activation?.channel ?? null,
|
||||||
};
|
};
|
||||||
@@ -312,6 +318,7 @@ export class TransitAgentsService {
|
|||||||
"This transit agent already has a portal account — resend the activation link instead.",
|
"This transit agent already has a portal account — resend the activation link instead.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
this.assertNoAccountForEthiopian(current.country, dto);
|
||||||
|
|
||||||
const { email, username, phoneNumber } = await this.prepareAccountFields(
|
const { email, username, phoneNumber } = await this.prepareAccountFields(
|
||||||
dto,
|
dto,
|
||||||
@@ -338,7 +345,7 @@ export class TransitAgentsService {
|
|||||||
|
|
||||||
const activation = await this.sendActivationLink(agent);
|
const activation = await this.sendActivationLink(agent);
|
||||||
return {
|
return {
|
||||||
agent: withValidityStatus(agent),
|
agent: toView(agent),
|
||||||
activationSentTo: activation?.maskedTarget ?? null,
|
activationSentTo: activation?.maskedTarget ?? null,
|
||||||
activationChannel: activation?.channel ?? null,
|
activationChannel: activation?.channel ?? null,
|
||||||
};
|
};
|
||||||
@@ -436,13 +443,6 @@ export class TransitAgentsService {
|
|||||||
dto: UpdateTransitAgentDto,
|
dto: UpdateTransitAgentDto,
|
||||||
): Promise<TransitAgentView> {
|
): Promise<TransitAgentView> {
|
||||||
const current = await this.findById(id);
|
const current = await this.findById(id);
|
||||||
const nextValidFrom = dto.validFrom ?? current.validFrom;
|
|
||||||
const nextValidTo = dto.validTo ?? current.validTo;
|
|
||||||
if (nextValidTo < nextValidFrom) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
"Valid-to date must be on or after valid-from date.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// `username` only ever names an IAM account, and it is chosen once at
|
// `username` only ever names an IAM account, and it is chosen once at
|
||||||
// account creation. Accepting it here (PartialType inherits it from the
|
// account creation. Accepting it here (PartialType inherits it from the
|
||||||
@@ -450,6 +450,21 @@ export class TransitAgentsService {
|
|||||||
const { username: _ignoredUsername, email, phoneNumber, ...rest } = dto;
|
const { username: _ignoredUsername, email, phoneNumber, ...rest } = dto;
|
||||||
|
|
||||||
const contact: Partial<TransitAgent> = {};
|
const contact: Partial<TransitAgent> = {};
|
||||||
|
const nextCountry = dto.country ?? current.country;
|
||||||
|
if (nextCountry === TransitAgentCountry.Ethiopia) {
|
||||||
|
this.assertNoAccountForEthiopian(nextCountry, { email, phoneNumber });
|
||||||
|
if (current.userId) {
|
||||||
|
// The account already holds the email the forwarder would sign up
|
||||||
|
// with; there is no way to hand it back, so the row stays Djiboutian.
|
||||||
|
throw new BadRequestException(
|
||||||
|
`${current.name} already has a portal account, so it cannot become an Ethiopian transit agent — create a new Ethiopian entry instead.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Whatever contact details a Djiboutian row carried go with the switch,
|
||||||
|
// so the forwarder's own registration cannot collide with them.
|
||||||
|
contact.email = null;
|
||||||
|
contact.phoneNumber = null;
|
||||||
|
}
|
||||||
if (email !== undefined) {
|
if (email !== undefined) {
|
||||||
const normalized = email.trim().toLowerCase();
|
const normalized = email.trim().toLowerCase();
|
||||||
if (await this.transitAgentsRepository.existsByEmail(normalized, id)) {
|
if (await this.transitAgentsRepository.existsByEmail(normalized, id)) {
|
||||||
@@ -483,7 +498,7 @@ export class TransitAgentsService {
|
|||||||
await this.syncIamContact(updated);
|
await this.syncIamContact(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
return withValidityStatus(updated);
|
return toView(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Module } from "@nestjs/common";
|
|||||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
|
|
||||||
import { Booking } from "../bookings/entities/booking.entity";
|
import { Booking } from "../bookings/entities/booking.entity";
|
||||||
|
import { ExternalProfile } from "../companies/entities/external-profile.entity";
|
||||||
import { ClearanceMilestone } from "../contracts/entities/clearance-milestone.entity";
|
import { ClearanceMilestone } from "../contracts/entities/clearance-milestone.entity";
|
||||||
import { FilesModule } from "../files/files.module";
|
import { FilesModule } from "../files/files.module";
|
||||||
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
|
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
|
||||||
@@ -19,11 +20,14 @@ import { TransitAssignmentsService } from "./transit-assignments.service";
|
|||||||
// Milestones and train schedules are read for the agent's dashboard
|
// Milestones and train schedules are read for the agent's dashboard
|
||||||
// timings (declaration stamps, departure/arrival fallbacks) — entities
|
// timings (declaration stamps, departure/arrival fallbacks) — entities
|
||||||
// only, for the same reason as Booking.
|
// only, for the same reason as Booking.
|
||||||
|
// ExternalProfile: `/my` resolves a freight forwarder's portal user to the
|
||||||
|
// transit agent its company registered as — entity only, same reason.
|
||||||
TypeOrmModule.forFeature([
|
TypeOrmModule.forFeature([
|
||||||
TransitAssignment,
|
TransitAssignment,
|
||||||
Booking,
|
Booking,
|
||||||
ClearanceMilestone,
|
ClearanceMilestone,
|
||||||
TrainSchedule,
|
TrainSchedule,
|
||||||
|
ExternalProfile,
|
||||||
]),
|
]),
|
||||||
FilesModule,
|
FilesModule,
|
||||||
TransitAgentsModule,
|
TransitAgentsModule,
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ describe("TransitAssignmentsService", () => {
|
|||||||
files as never,
|
files as never,
|
||||||
milestones as never,
|
milestones as never,
|
||||||
trainSchedules as never,
|
trainSchedules as never,
|
||||||
|
{ findOne: jest.fn().mockResolvedValue(null) } as never, // externalProfiles
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ import {
|
|||||||
} from "@edr/types";
|
} from "@edr/types";
|
||||||
|
|
||||||
import { Booking } from "../bookings/entities/booking.entity";
|
import { Booking } from "../bookings/entities/booking.entity";
|
||||||
|
import { ExternalProfile } from "../companies/entities/external-profile.entity";
|
||||||
|
import {
|
||||||
|
ProfileStatus,
|
||||||
|
ProfileType,
|
||||||
|
} from "../companies/entities/company-profile.entity";
|
||||||
import { ClearanceMilestone } from "../contracts/entities/clearance-milestone.entity";
|
import { ClearanceMilestone } from "../contracts/entities/clearance-milestone.entity";
|
||||||
import { FilesService } from "../files/files.service";
|
import { FilesService } from "../files/files.service";
|
||||||
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
|
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
|
||||||
@@ -170,6 +175,11 @@ export class TransitAssignmentsService {
|
|||||||
private readonly milestonesRepository: Repository<ClearanceMilestone>,
|
private readonly milestonesRepository: Repository<ClearanceMilestone>,
|
||||||
@InjectRepository(TrainSchedule)
|
@InjectRepository(TrainSchedule)
|
||||||
private readonly trainSchedulesRepository: Repository<TrainSchedule>,
|
private readonly trainSchedulesRepository: Repository<TrainSchedule>,
|
||||||
|
// The ExternalProfile ENTITY (not CompaniesModule) for the same reason as
|
||||||
|
// Booking above: `/my` only has to walk portal user → company → the
|
||||||
|
// transit agent that company registered itself as.
|
||||||
|
@InjectRepository(ExternalProfile)
|
||||||
|
private readonly externalProfilesRepository: Repository<ExternalProfile>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private static minutesBetween(
|
private static minutesBetween(
|
||||||
@@ -252,9 +262,52 @@ export class TransitAssignmentsService {
|
|||||||
// client-supplied id: an agent must not be able to read or edit another
|
// client-supplied id: an agent must not be able to read or edit another
|
||||||
// agent's assignments by guessing one.
|
// agent's assignments by guessing one.
|
||||||
|
|
||||||
/** The transit agent this portal user signs in as. */
|
/**
|
||||||
private async requireAgentForUser(userId: string) {
|
* The transit agent this portal user acts as.
|
||||||
const agent = await this.transitAgentsRepository.findByUserId(userId);
|
*
|
||||||
|
* Two kinds of account reach `/my`: a Djibouti transit officer, who signs in
|
||||||
|
* AS the agent (`transit_agents.user_id`), and a customer company that
|
||||||
|
* registered itself as an Ethiopian transit agent (`companies.
|
||||||
|
* transit_agent_id`) — under the transit agent role, the forwarder role, or
|
||||||
|
* both. It may look at its assigned bookings from the moment the role is
|
||||||
|
* requested — that is how it learns work is waiting — but may only act on
|
||||||
|
* them (`forWrite`) once a roster role has been approved.
|
||||||
|
*/
|
||||||
|
private async requireAgentForUser(
|
||||||
|
userId: string,
|
||||||
|
opts: { forWrite?: boolean } = {},
|
||||||
|
) {
|
||||||
|
const own = await this.transitAgentsRepository.findByUserId(userId);
|
||||||
|
if (own) return own;
|
||||||
|
|
||||||
|
const profile = await this.externalProfilesRepository.findOne({
|
||||||
|
where: { userId },
|
||||||
|
relations: { company: { companyProfiles: true } },
|
||||||
|
});
|
||||||
|
const company = profile?.company;
|
||||||
|
if (!company?.transitAgentId) {
|
||||||
|
throw new ForbiddenException("This account is not a transit agent");
|
||||||
|
}
|
||||||
|
// Either roster role will do — a plain transit agent or a forwarder.
|
||||||
|
const agentRoles = (company.companyProfiles ?? []).filter(
|
||||||
|
(p) =>
|
||||||
|
p.type === ProfileType.transitAgent ||
|
||||||
|
p.type === ProfileType.freightForwarder,
|
||||||
|
);
|
||||||
|
if (agentRoles.length === 0) {
|
||||||
|
throw new ForbiddenException("This account is not a transit agent");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
opts.forWrite &&
|
||||||
|
!agentRoles.some((p) => p.status === ProfileStatus.Active)
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"Your transit agent role is not approved yet — you can view assigned bookings but not act on them until it is.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const agent = await this.transitAgentsRepository.findById(
|
||||||
|
company.transitAgentId,
|
||||||
|
);
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
throw new ForbiddenException("This account is not a transit agent");
|
throw new ForbiddenException("This account is not a transit agent");
|
||||||
}
|
}
|
||||||
@@ -640,8 +693,12 @@ export class TransitAssignmentsService {
|
|||||||
return { ...this.toView(assignment), files: await this.listFiles(id) };
|
return { ...this.toView(assignment), files: await this.listFiles(id) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Assert the assignment is this user's before any write reaches it. */
|
/**
|
||||||
|
* Assert the assignment is this user's before any write reaches it — and
|
||||||
|
* that the user may write at all (an unapproved forwarder may only look).
|
||||||
|
*/
|
||||||
private async assertMine(userId: string, id: string): Promise<void> {
|
private async assertMine(userId: string, id: string): Promise<void> {
|
||||||
|
await this.requireAgentForUser(userId, { forWrite: true });
|
||||||
await this.findMineById(userId, id);
|
await this.findMineById(userId, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -677,6 +734,7 @@ export class TransitAssignmentsService {
|
|||||||
id: string,
|
id: string,
|
||||||
input: { finish: boolean; note?: string },
|
input: { finish: boolean; note?: string },
|
||||||
): Promise<TransitAssignmentView> {
|
): Promise<TransitAssignmentView> {
|
||||||
|
await this.requireAgentForUser(userId, { forWrite: true });
|
||||||
const current = await this.findMineById(userId, id);
|
const current = await this.findMineById(userId, id);
|
||||||
if (current.status === TransitAssignmentStatus.Finished) {
|
if (current.status === TransitAssignmentStatus.Finished) {
|
||||||
throw new ForbiddenException("This assignment is already finished.");
|
throw new ForbiddenException("This assignment is already finished.");
|
||||||
|
|||||||
@@ -176,6 +176,49 @@ export class TruckEntranceDto {
|
|||||||
warehouseManagerName?: string;
|
warehouseManagerName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One physical truck at the gate, with the containers it is carrying.
|
||||||
|
*
|
||||||
|
* A customer whose containers arrive together sends several trucks, and each
|
||||||
|
* carries its own load: the plate, driver and boxes belong to that truck, not
|
||||||
|
* to the receive operation as a whole. Each truck is validated and given its
|
||||||
|
* own GRN batch exactly as a single-truck receive always was.
|
||||||
|
*/
|
||||||
|
export class ReceiveTruckDto {
|
||||||
|
/**
|
||||||
|
* The physical containers delivered by this truck: either one 40ft box or up
|
||||||
|
* to two 20ft boxes, the same physical limit a single-truck receive enforces.
|
||||||
|
*/
|
||||||
|
@ApiPropertyOptional({ type: [String] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayNotEmpty()
|
||||||
|
@ArrayMaxSize(2)
|
||||||
|
@ArrayUnique()
|
||||||
|
@Matches(/^[A-Z]{4}\d{7}$/, {
|
||||||
|
each: true,
|
||||||
|
message: 'each container number must match ISO container format, e.g. ABCD1234567',
|
||||||
|
})
|
||||||
|
containerNumbers?: string[];
|
||||||
|
|
||||||
|
@ApiProperty({ type: TruckEntranceDto })
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => TruckEntranceDto)
|
||||||
|
truckEntrance!: TruckEntranceDto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bookings this truck delivers against. Defaults to the operation's
|
||||||
|
* `bookingIds` when omitted; container freight still requires exactly one,
|
||||||
|
* so its containers and documents stay separate per truck.
|
||||||
|
*/
|
||||||
|
@ApiPropertyOptional({ type: [String], format: 'uuid' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayNotEmpty()
|
||||||
|
@IsUUID('all', { each: true })
|
||||||
|
bookingIds?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
/** Bulk-receive eligible PAID bookings into a warehouse location, by trade direction. */
|
/** Bulk-receive eligible PAID bookings into a warehouse location, by trade direction. */
|
||||||
export class BulkReceiveDto {
|
export class BulkReceiveDto {
|
||||||
@ApiProperty({ enum: ['IMPORT', 'EXPORT'] })
|
@ApiProperty({ enum: ['IMPORT', 'EXPORT'] })
|
||||||
@@ -200,9 +243,24 @@ export class BulkReceiveDto {
|
|||||||
@IsUUID('all', { each: true })
|
@IsUUID('all', { each: true })
|
||||||
bookingIds!: string[];
|
bookingIds!: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Several trucks arriving together, each with its own plate, driver and
|
||||||
|
* containers. When present this supersedes the single-truck
|
||||||
|
* `containerNumbers` / `truckEntrance` pair below, which is kept so existing
|
||||||
|
* callers (and single-truck arrivals) keep working unchanged.
|
||||||
|
*/
|
||||||
|
@ApiPropertyOptional({ type: [ReceiveTruckDto] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayNotEmpty()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => ReceiveTruckDto)
|
||||||
|
trucks?: ReceiveTruckDto[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The physical containers delivered by this truck. Container exports are
|
* The physical containers delivered by this truck. Container exports are
|
||||||
* received one truck at a time: either one 40ft box or up to two 20ft boxes.
|
* received one truck at a time: either one 40ft box or up to two 20ft boxes.
|
||||||
|
* Ignored when `trucks` is given.
|
||||||
*/
|
*/
|
||||||
@ApiPropertyOptional({ type: [String] })
|
@ApiPropertyOptional({ type: [String] })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { WarehouseInventoryService } from './warehouse-inventory.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A booking's cargo spans one inventory row per container, and a multi-truck
|
||||||
|
* arrival files a GRN batch per truck. Inspection is a judgement on the cargo,
|
||||||
|
* not on the row it happens to sit in, so ticking one row must pass every
|
||||||
|
* still-inspectable row of the same booking — otherwise a six-container
|
||||||
|
* booking stays half-inspected and never reaches Ready To Load.
|
||||||
|
*
|
||||||
|
* Only the DataSource is touched, so the instance is built off the prototype
|
||||||
|
* rather than stubbing all 20-odd collaborators.
|
||||||
|
*/
|
||||||
|
type Expand = (inventoryIds: string[], eligibleStatuses: string[]) => Promise<string[]>;
|
||||||
|
|
||||||
|
const ELIGIBLE = ['UNLOADED', 'RECEIVED', 'STORED', 'RESERVED'];
|
||||||
|
|
||||||
|
function makeExpand(rows: Array<{ id: string }>) {
|
||||||
|
const query = jest.fn().mockResolvedValue(rows);
|
||||||
|
const service = Object.create(WarehouseInventoryService.prototype) as Record<string, unknown>;
|
||||||
|
service.dataSource = { query };
|
||||||
|
const expand = (
|
||||||
|
service as unknown as { expandInspectionToBooking: Expand }
|
||||||
|
).expandInspectionToBooking.bind(service);
|
||||||
|
return { expand, query };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('bulkMarkInspected — booking cascade', () => {
|
||||||
|
it('pulls in the booking siblings of a selected row', async () => {
|
||||||
|
const { expand } = makeExpand([{ id: 'inv-1' }, { id: 'inv-2' }, { id: 'inv-3' }]);
|
||||||
|
|
||||||
|
await expect(expand(['inv-1'], ELIGIBLE)).resolves.toEqual([
|
||||||
|
'inv-1',
|
||||||
|
'inv-2',
|
||||||
|
'inv-3',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leads with the rows the operator actually ticked', async () => {
|
||||||
|
// The response the operator reads should open with their own selection,
|
||||||
|
// whatever order the database returned the siblings in.
|
||||||
|
const { expand } = makeExpand([{ id: 'inv-3' }, { id: 'inv-2' }, { id: 'inv-1' }]);
|
||||||
|
|
||||||
|
const result = await expand(['inv-1'], ELIGIBLE);
|
||||||
|
|
||||||
|
expect(result[0]).toBe('inv-1');
|
||||||
|
expect(result.slice(1).sort()).toEqual(['inv-2', 'inv-3']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never repeats a row when two siblings are both selected', async () => {
|
||||||
|
const { expand } = makeExpand([{ id: 'inv-1' }, { id: 'inv-2' }]);
|
||||||
|
|
||||||
|
const result = await expand(['inv-1', 'inv-2'], ELIGIBLE);
|
||||||
|
|
||||||
|
expect(result).toEqual(['inv-1', 'inv-2']);
|
||||||
|
expect(new Set(result).size).toBe(result.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes the eligible statuses to the query rather than hard-coding them', async () => {
|
||||||
|
const { expand, query } = makeExpand([{ id: 'inv-1' }]);
|
||||||
|
|
||||||
|
await expand(['inv-1'], ELIGIBLE);
|
||||||
|
|
||||||
|
expect(query).toHaveBeenCalledWith(expect.any(String), [['inv-1'], ELIGIBLE]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not query at all for an empty selection', async () => {
|
||||||
|
const { expand, query } = makeExpand([]);
|
||||||
|
|
||||||
|
await expect(expand([], ELIGIBLE)).resolves.toEqual([]);
|
||||||
|
expect(query).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { WarehouseInspectionService } from './warehouse-inspection.service';
|
||||||
|
import { WarehouseInventoryService } from './warehouse-inventory.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cargo whose inspection failed or is under review must not travel. It becomes
|
||||||
|
* loadable only by being re-inspected and passed, and that reversal has to say
|
||||||
|
* why — the cargo was deliberately held, so its release is deliberate too.
|
||||||
|
*
|
||||||
|
* Only the collaborators each rule touches are stubbed; the instances are built
|
||||||
|
* off the prototype rather than wiring all 20-odd dependencies.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe('load() — inspection gate', () => {
|
||||||
|
const loadWithInspection = (inspectionStatus: string | null) => {
|
||||||
|
const service = Object.create(WarehouseInventoryService.prototype) as Record<string, unknown>;
|
||||||
|
service.findById = jest.fn().mockResolvedValue({
|
||||||
|
id: 'inv-1',
|
||||||
|
status: 'READY_FOR_LOADING',
|
||||||
|
inspectionStatus,
|
||||||
|
warehouseId: 'w-1',
|
||||||
|
yardId: 'y-1',
|
||||||
|
zoneId: 'z-1',
|
||||||
|
});
|
||||||
|
service.assertTransition = jest.fn();
|
||||||
|
// Reached only if the gate lets the item through — failing loudly here
|
||||||
|
// proves the gate did NOT stop it.
|
||||||
|
service.scheduling = {
|
||||||
|
findWagon: jest.fn().mockRejectedValue(new Error('gate did not block')),
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
service as unknown as { load: (id: string, dto: unknown) => Promise<unknown> }
|
||||||
|
).load.bind(service);
|
||||||
|
};
|
||||||
|
|
||||||
|
it.each(['FAILED', 'NEEDS_REVIEW'])('refuses to load %s cargo', async (status) => {
|
||||||
|
await expect(loadWithInspection(status)('inv-1', { wagonId: 'w' })).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to load cargo that was never inspected', async () => {
|
||||||
|
await expect(loadWithInspection(null)('inv-1', { wagonId: 'w' })).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the outcome so the operator knows what to fix', async () => {
|
||||||
|
await expect(loadWithInspection('FAILED')('inv-1', { wagonId: 'w' })).rejects.toThrow(
|
||||||
|
/FAILED/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets passed cargo through the gate', async () => {
|
||||||
|
// It fails later, at the wagon lookup — which is the proof it got past the
|
||||||
|
// inspection gate rather than being stopped by it.
|
||||||
|
await expect(loadWithInspection('PASSED')('inv-1', { wagonId: 'w' })).rejects.toThrow(
|
||||||
|
'gate did not block',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('inspection report — reversing a held inspection', () => {
|
||||||
|
const createReport = (previousStatus: string, itemStatus = 'RECEIVED') => {
|
||||||
|
const update = jest.fn().mockResolvedValue(undefined);
|
||||||
|
const service = Object.create(WarehouseInspectionService.prototype) as Record<string, unknown>;
|
||||||
|
service.dataSource = {
|
||||||
|
getRepository: () => ({
|
||||||
|
findOne: jest.fn().mockResolvedValue({
|
||||||
|
id: 'inv-1',
|
||||||
|
bookingId: 'b-1',
|
||||||
|
inspectionStatus: previousStatus,
|
||||||
|
status: itemStatus,
|
||||||
|
}),
|
||||||
|
update,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
service.inspectionRepository = {
|
||||||
|
findAll: jest.fn().mockResolvedValue([]),
|
||||||
|
create: jest.fn().mockResolvedValue({ id: 'rep-1' }),
|
||||||
|
};
|
||||||
|
service.markImportPickupReadyAndAcceptLastMile = jest.fn().mockResolvedValue(undefined);
|
||||||
|
const create = (
|
||||||
|
service as unknown as {
|
||||||
|
create: (id: string, dto: unknown) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
).create.bind(service);
|
||||||
|
return { create, update };
|
||||||
|
};
|
||||||
|
|
||||||
|
it.each(['FAILED', 'NEEDS_REVIEW'])(
|
||||||
|
'rejects passing %s cargo with no reason given',
|
||||||
|
async (previous) => {
|
||||||
|
const { create } = createReport(previous);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
create('inv-1', { reportType: 'INSPECTION', inspectionStatus: 'PASSED' }),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('rejects whitespace as a reason', async () => {
|
||||||
|
const { create } = createReport('FAILED');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
create('inv-1', { reportType: 'INSPECTION', inspectionStatus: 'PASSED', remarks: ' ' }),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts the reversal once a reason is recorded', async () => {
|
||||||
|
const { create } = createReport('FAILED');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
create('inv-1', {
|
||||||
|
reportType: 'INSPECTION',
|
||||||
|
inspectionStatus: 'PASSED',
|
||||||
|
remarks: 'Reworked packaging, re-weighed and verified.',
|
||||||
|
}),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('needs no reason for a first-time pass', async () => {
|
||||||
|
const { create } = createReport(null as unknown as string);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
create('inv-1', { reportType: 'INSPECTION', inspectionStatus: 'PASSED' }),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pulls failed cargo back out of the ready-to-load queue', async () => {
|
||||||
|
const { create, update } = createReport('PASSED', 'READY_FOR_LOADING');
|
||||||
|
|
||||||
|
await create('inv-1', { reportType: 'INSPECTION', inspectionStatus: 'FAILED' });
|
||||||
|
|
||||||
|
expect(update).toHaveBeenCalledWith('inv-1', { status: 'RECEIVED' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves cargo that never reached the ready queue where it is', async () => {
|
||||||
|
const { create, update } = createReport('PASSED', 'STORED');
|
||||||
|
|
||||||
|
await create('inv-1', { reportType: 'INSPECTION', inspectionStatus: 'FAILED' });
|
||||||
|
|
||||||
|
expect(update).not.toHaveBeenCalledWith('inv-1', { status: 'RECEIVED' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
import { NotificationAudience, NotificationType } from '@edr/types';
|
import { NotificationAudience, NotificationType } from '@edr/types';
|
||||||
@@ -37,6 +37,20 @@ export class WarehouseInspectionService {
|
|||||||
throw new NotFoundException(`Inventory item ${inventoryId} not found`);
|
throw new NotFoundException(`Inventory item ${inventoryId} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Overturning a held inspection is a deliberate act: the cargo was kept off
|
||||||
|
// the train, and the record has to say why it may now travel. A bare PASS
|
||||||
|
// with no remarks leaves the release unexplained.
|
||||||
|
const previousStatus = inventory.inspectionStatus;
|
||||||
|
if (
|
||||||
|
dto.inspectionStatus === 'PASSED' &&
|
||||||
|
(previousStatus === 'FAILED' || previousStatus === 'NEEDS_REVIEW') &&
|
||||||
|
!dto.remarks?.trim()
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Give a reason in Remarks for passing cargo whose inspection is ${previousStatus}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const expected = dto.expectedWeight ?? null;
|
const expected = dto.expectedWeight ?? null;
|
||||||
const actual = dto.actualWeight ?? null;
|
const actual = dto.actualWeight ?? null;
|
||||||
const weightLoss = expected !== null && actual !== null ? Math.max(0, expected - actual) : null;
|
const weightLoss = expected !== null && actual !== null ? Math.max(0, expected - actual) : null;
|
||||||
@@ -83,6 +97,15 @@ export class WarehouseInspectionService {
|
|||||||
|
|
||||||
if (dto.inspectionStatus === 'PASSED') {
|
if (dto.inspectionStatus === 'PASSED') {
|
||||||
await this.markImportPickupReadyAndAcceptLastMile(inventoryId);
|
await this.markImportPickupReadyAndAcceptLastMile(inventoryId);
|
||||||
|
} else if (
|
||||||
|
inventory.status === 'READY_FOR_LOADING' ||
|
||||||
|
inventory.status === 'READY_FOR_PICKUP'
|
||||||
|
) {
|
||||||
|
// A failed or under-review re-inspection pulls the cargo back out of the
|
||||||
|
// ready queue. load() refuses it either way, but leaving it READY_FOR_*
|
||||||
|
// would keep it sitting on the loading and pickup lists as if nothing
|
||||||
|
// had happened.
|
||||||
|
await inventoryRepo.update(inventoryId, { status: 'RECEIVED' });
|
||||||
}
|
}
|
||||||
|
|
||||||
return report;
|
return report;
|
||||||
|
|||||||
@@ -1441,8 +1441,10 @@ export class WarehouseInventoryService {
|
|||||||
-- true regardless of the customer's actual self-haul/EDR-haul choice.
|
-- true regardless of the customer's actual self-haul/EDR-haul choice.
|
||||||
-- The address is the only per-booking record of that choice.
|
-- The address is the only per-booking record of that choice.
|
||||||
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL) AS "lastMileRequested",
|
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL) AS "lastMileRequested",
|
||||||
oy.code AS "origin",
|
-- Operators know a yard by its name: KALITY is universally called
|
||||||
dy.code AS "destination",
|
-- GMP / Gelan Multipurpose Port. Code is only a fallback.
|
||||||
|
COALESCE(oy.label, oy.code) AS "origin",
|
||||||
|
COALESCE(dy.label, dy.code) AS "destination",
|
||||||
oy.country AS "originCountry",
|
oy.country AS "originCountry",
|
||||||
dy.country AS "destinationCountry",
|
dy.country AS "destinationCountry",
|
||||||
b.freight_type AS "freightType",
|
b.freight_type AS "freightType",
|
||||||
@@ -1580,335 +1582,412 @@ export class WarehouseInventoryService {
|
|||||||
bookingId: string;
|
bookingId: string;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
|
// A customer's containers often arrive on several trucks at once. Each
|
||||||
|
// truck carries its own load, so the operation is a list of trucks; the
|
||||||
|
// legacy single-truck fields collapse to a one-element list so existing
|
||||||
|
// callers behave exactly as before.
|
||||||
|
const trucks: Array<{
|
||||||
|
truckEntrance?: BulkReceiveDto['truckEntrance'];
|
||||||
|
containerNumbers?: string[];
|
||||||
|
bookingIds: string[];
|
||||||
|
}> = dto.trucks?.length
|
||||||
|
? dto.trucks.map((truck) => ({
|
||||||
|
truckEntrance: truck.truckEntrance,
|
||||||
|
containerNumbers: truck.containerNumbers,
|
||||||
|
bookingIds: truck.bookingIds?.length ? truck.bookingIds : dto.bookingIds,
|
||||||
|
}))
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
truckEntrance: dto.truckEntrance,
|
||||||
|
containerNumbers: dto.containerNumbers,
|
||||||
|
bookingIds: dto.bookingIds,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Two trucks cannot deliver the same box. The per-booking check below only
|
||||||
|
// catches this once a unit is marked received, which would let a duplicate
|
||||||
|
// through on the truck that happens to be processed first.
|
||||||
|
const seenContainers = new Set<string>();
|
||||||
|
for (const truck of trucks) {
|
||||||
|
for (const raw of truck.containerNumbers ?? []) {
|
||||||
|
const number = raw.trim().toUpperCase();
|
||||||
|
if (seenContainers.has(number)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Container ${number} is listed on more than one truck`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
seenContainers.add(number);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
const { warehouse, yard, zone } = await this.validateLocation(manager, {
|
const { warehouse, yard, zone } = await this.validateLocation(manager, {
|
||||||
warehouseId: dto.warehouseId,
|
warehouseId: dto.warehouseId,
|
||||||
yardId: dto.yardId,
|
yardId: dto.yardId,
|
||||||
zoneId: dto.zoneId,
|
zoneId: dto.zoneId,
|
||||||
});
|
});
|
||||||
// The receive location is whatever the operator selected above — never a
|
// Each arriving truck is its own unit of work: its own plate and driver,
|
||||||
// hand-typed string. Stamp it on the truck entrance for the GRN/notes.
|
// its own containers, its own physical-load check and its own GRN batch.
|
||||||
if (dto.truckEntrance && !dto.truckEntrance.warehouseCodeLocation) {
|
// A single-truck arrival is just the one-element case, so the per-truck
|
||||||
dto.truckEntrance.warehouseCodeLocation = [warehouse.code, yard.code, zone.code]
|
// body below is unchanged from when this only ever handled one truck.
|
||||||
.filter(Boolean)
|
for (const truck of trucks) {
|
||||||
.join(' / ');
|
const truckEntranceInput = truck.truckEntrance;
|
||||||
}
|
const truckContainerNumbers = truck.containerNumbers;
|
||||||
|
const truckBookingIds = truck.bookingIds;
|
||||||
for (const bookingId of dto.bookingIds) {
|
// The receive location is whatever the operator selected above — never a
|
||||||
const skip = (reason: string) => {
|
// hand-typed string. Stamp it on the truck entrance for the GRN/notes.
|
||||||
result.skippedCount += 1;
|
if (truckEntranceInput && !truckEntranceInput.warehouseCodeLocation) {
|
||||||
result.results.push({ bookingId, status: 'SKIPPED', reason });
|
truckEntranceInput.warehouseCodeLocation = [warehouse.code, yard.code, zone.code]
|
||||||
};
|
.filter(Boolean)
|
||||||
|
.join(' / ');
|
||||||
const [booking] = await manager.query(
|
|
||||||
`SELECT b.reference AS "reference",
|
|
||||||
b.payment_status AS "paymentStatus",
|
|
||||||
b.freight_type AS "freightType",
|
|
||||||
b.cargo_total_weight_vgm AS "weight",
|
|
||||||
company.name AS "customer",
|
|
||||||
company.tin AS "customerTin",
|
|
||||||
${companyNotifyPhoneExpr('company')} AS "customerPhone",
|
|
||||||
bc.container_numbers AS "containerNumber",
|
|
||||||
bc.container_quantity AS "containerQuantity",
|
|
||||||
bc.container_packaging_type AS "containerPackagingType",
|
|
||||||
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargoDescription",
|
|
||||||
oy.country AS "originCountry", dy.country AS "destinationCountry",
|
|
||||||
-- No service_types OR here either — see eligibleBookings above.
|
|
||||||
(NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL) AS "hasFirstMile",
|
|
||||||
fm.id AS "firstMileRequestId",
|
|
||||||
fm.status AS "firstMileStatus",
|
|
||||||
v.plate_number AS "firstMileTruckPlateNumber",
|
|
||||||
v.trailer_plate_no AS "firstMileTrailerPlateNumber",
|
|
||||||
COALESCE(
|
|
||||||
NULLIF(TRIM(CONCAT(COALESCE(driver.first_name, ''), ' ', COALESCE(driver.last_name, ''))), ''),
|
|
||||||
v.assigned_driver_name
|
|
||||||
) AS "firstMileDriverName",
|
|
||||||
driver.phone_number AS "firstMileDriverPhone",
|
|
||||||
driver.license_number AS "firstMileDriverLicenseNumber",
|
|
||||||
v.vehicle_type AS "firstMileTruckType",
|
|
||||||
COALESCE((SELECT string_agg(DISTINCT cta.plate_number, ', ')
|
|
||||||
FROM freight.customer_truck_assignments cta
|
|
||||||
WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
|
|
||||||
b.customer_truck_plate_number) AS "customerTruckPlateNumber",
|
|
||||||
COALESCE((SELECT string_agg(DISTINCT cta.driver_name, ', ')
|
|
||||||
FROM freight.customer_truck_assignments cta
|
|
||||||
WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
|
|
||||||
b.customer_truck_driver_name) AS "customerTruckDriverName",
|
|
||||||
b.customer_truck_type AS "customerTruckType",
|
|
||||||
b.customer_truck_container_number AS "customerTruckContainerNumber",
|
|
||||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
|
||||||
b.company_id AS "companyId",
|
|
||||||
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL) AS "hasLastMile"
|
|
||||||
FROM freight.bookings b
|
|
||||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
|
||||||
${primaryContactUserJoin('company')}
|
|
||||||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
|
||||||
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
|
||||||
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
|
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers,
|
|
||||||
SUM(booking_container.quantity)::int AS container_quantity,
|
|
||||||
CASE
|
|
||||||
WHEN COUNT(booking_container.id) = 0 THEN NULL
|
|
||||||
WHEN bool_or(container_type.is_reefer) THEN 'REEFER_CONTAINER'
|
|
||||||
WHEN bool_or(container_type.is_open_top) THEN 'OPEN_TOP_CONTAINER'
|
|
||||||
WHEN MIN(container_type.size_ft) = 20 THEN 'CONTAINER_20FT'
|
|
||||||
WHEN MIN(container_type.size_ft) = 40 THEN 'CONTAINER_40FT'
|
|
||||||
WHEN MIN(container_type.size_ft) = 45 THEN 'CONTAINER_45FT'
|
|
||||||
ELSE 'OTHER_CONTAINER'
|
|
||||||
END AS container_packaging_type
|
|
||||||
FROM freight.booking_container booking_container
|
|
||||||
LEFT JOIN freight.container_types container_type ON container_type.id = booking_container.container_type_id
|
|
||||||
WHERE booking_container.booking_id = b.id AND booking_container.deleted_at IS NULL
|
|
||||||
) bc ON true
|
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT first_mile.id, first_mile.status, first_mile.vehicle_id
|
|
||||||
FROM freight.first_mile first_mile
|
|
||||||
WHERE first_mile.booking_id = b.id AND first_mile.deleted_at IS NULL
|
|
||||||
ORDER BY first_mile.created_at DESC
|
|
||||||
LIMIT 1
|
|
||||||
) fm ON true
|
|
||||||
LEFT JOIN freight.vehicles v ON v.id = fm.vehicle_id
|
|
||||||
LEFT JOIN freight.drivers driver ON driver.id = v.assigned_driver_id
|
|
||||||
WHERE b.id = $1 AND b.deleted_at IS NULL LIMIT 1`,
|
|
||||||
[bookingId],
|
|
||||||
);
|
|
||||||
if (!booking) { skip('Booking not found'); continue; }
|
|
||||||
if (booking.paymentStatus !== 'PAID') { skip('Booking not PAID'); continue; }
|
|
||||||
// Direction is derived from the route (yard countries), not the stored field.
|
|
||||||
const bookingDirection = deriveTradeDirection(
|
|
||||||
{ country: booking.originCountry },
|
|
||||||
{ country: booking.destinationCountry },
|
|
||||||
);
|
|
||||||
if (bookingDirection !== dto.direction) {
|
|
||||||
skip(`Booking route is ${bookingDirection}, not ${dto.direction}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (dto.direction === 'EXPORT' && booking.hasFirstMile) {
|
|
||||||
if (!booking.firstMileRequestId) {
|
|
||||||
skip('First-mile request not created');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (booking.firstMileStatus !== 'RECEIVED_TO_PORT') {
|
|
||||||
skip('First-mile truck has not arrived');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const containerQuantity = Number(booking.containerQuantity ?? 0);
|
for (const bookingId of truckBookingIds) {
|
||||||
if (booking.freightType === 'CONTAINER' && containerQuantity <= 0) {
|
const skip = (reason: string) => {
|
||||||
skip('Container booking has no container quantity');
|
result.skippedCount += 1;
|
||||||
continue;
|
result.results.push({ bookingId, status: 'SKIPPED', reason });
|
||||||
}
|
};
|
||||||
|
|
||||||
const now = new Date();
|
const [booking] = await manager.query(
|
||||||
const truckEntrance = dto.truckEntrance
|
`SELECT b.reference AS "reference",
|
||||||
? this.mergeSystemTruckEntrance(dto.truckEntrance, booking)
|
b.payment_status AS "paymentStatus",
|
||||||
: undefined;
|
b.freight_type AS "freightType",
|
||||||
// Multi-truck self-haul is selected explicitly at the gate. The booking
|
b.cargo_total_weight_vgm AS "weight",
|
||||||
// source contains comma-joined legacy summary fields, which must never
|
company.name AS "customer",
|
||||||
// replace the one physical truck the receiver selected.
|
company.tin AS "customerTin",
|
||||||
if (truckEntrance && !booking.hasFirstMile && dto.truckEntrance) {
|
${companyNotifyPhoneExpr('company')} AS "customerPhone",
|
||||||
truckEntrance.truckPlateNumber = dto.truckEntrance.truckPlateNumber;
|
bc.container_numbers AS "containerNumber",
|
||||||
truckEntrance.driverName = dto.truckEntrance.driverName;
|
bc.container_quantity AS "containerQuantity",
|
||||||
truckEntrance.driverPhone = dto.truckEntrance.driverPhone;
|
bc.container_packaging_type AS "containerPackagingType",
|
||||||
truckEntrance.truckType = dto.truckEntrance.truckType;
|
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargoDescription",
|
||||||
}
|
oy.country AS "originCountry", dy.country AS "destinationCountry",
|
||||||
if (dto.direction === 'EXPORT') {
|
-- No service_types OR here either — see eligibleBookings above.
|
||||||
this.assertTruckEntrance(truckEntrance);
|
(NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL) AS "hasFirstMile",
|
||||||
}
|
fm.id AS "firstMileRequestId",
|
||||||
|
fm.status AS "firstMileStatus",
|
||||||
type ReceiveContainerUnit = {
|
v.plate_number AS "firstMileTruckPlateNumber",
|
||||||
containerNumber: string;
|
v.trailer_plate_no AS "firstMileTrailerPlateNumber",
|
||||||
containerSize: string | null;
|
COALESCE(
|
||||||
weightTons: string | number;
|
NULLIF(TRIM(CONCAT(COALESCE(driver.first_name, ''), ' ', COALESCE(driver.last_name, ''))), ''),
|
||||||
sealNumber: string | null;
|
v.assigned_driver_name
|
||||||
bookingContainerId: string;
|
) AS "firstMileDriverName",
|
||||||
containerTypeId: string | null;
|
driver.phone_number AS "firstMileDriverPhone",
|
||||||
received: boolean;
|
driver.license_number AS "firstMileDriverLicenseNumber",
|
||||||
};
|
v.vehicle_type AS "firstMileTruckType",
|
||||||
let selectedUnits: ReceiveContainerUnit[] = [];
|
COALESCE((SELECT string_agg(DISTINCT cta.plate_number, ', ')
|
||||||
let grnNumber = this.generateGrnNumber(dto.direction, bookingId, now, booking.customer);
|
FROM freight.customer_truck_assignments cta
|
||||||
|
WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
|
||||||
if (booking.freightType === 'CONTAINER') {
|
b.customer_truck_plate_number) AS "customerTruckPlateNumber",
|
||||||
if (dto.bookingIds.length !== 1) {
|
COALESCE((SELECT string_agg(DISTINCT cta.driver_name, ', ')
|
||||||
throw new BadRequestException(
|
FROM freight.customer_truck_assignments cta
|
||||||
'Receive one container booking per arriving truck so its containers and documents stay separate',
|
WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
|
||||||
);
|
b.customer_truck_driver_name) AS "customerTruckDriverName",
|
||||||
}
|
b.customer_truck_type AS "customerTruckType",
|
||||||
const selectedNumbers = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
|
b.customer_truck_container_number AS "customerTruckContainerNumber",
|
||||||
if (!selectedNumbers.length) {
|
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
||||||
throw new BadRequestException('Select the containers arriving on this truck');
|
b.company_id AS "companyId",
|
||||||
}
|
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL) AS "hasLastMile"
|
||||||
const allUnits: ReceiveContainerUnit[] = await manager.query(
|
FROM freight.bookings b
|
||||||
`SELECT UPPER(bcu.container_number) AS "containerNumber",
|
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||||
bc.container_size AS "containerSize",
|
${primaryContactUserJoin('company')}
|
||||||
bcu.vgm_tons AS "weightTons",
|
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||||||
bcu.seal_number AS "sealNumber",
|
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
||||||
bc.id AS "bookingContainerId",
|
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
|
||||||
bc.container_type_id AS "containerTypeId",
|
LEFT JOIN LATERAL (
|
||||||
bcu.received_to_port AS received
|
SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers,
|
||||||
FROM freight.booking_container_units bcu
|
SUM(booking_container.quantity)::int AS container_quantity,
|
||||||
JOIN freight.booking_container bc
|
CASE
|
||||||
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
WHEN COUNT(booking_container.id) = 0 THEN NULL
|
||||||
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL
|
WHEN bool_or(container_type.is_reefer) THEN 'REEFER_CONTAINER'
|
||||||
FOR UPDATE OF bcu`,
|
WHEN bool_or(container_type.is_open_top) THEN 'OPEN_TOP_CONTAINER'
|
||||||
|
WHEN MIN(container_type.size_ft) = 20 THEN 'CONTAINER_20FT'
|
||||||
|
WHEN MIN(container_type.size_ft) = 40 THEN 'CONTAINER_40FT'
|
||||||
|
WHEN MIN(container_type.size_ft) = 45 THEN 'CONTAINER_45FT'
|
||||||
|
ELSE 'OTHER_CONTAINER'
|
||||||
|
END AS container_packaging_type
|
||||||
|
FROM freight.booking_container booking_container
|
||||||
|
LEFT JOIN freight.container_types container_type ON container_type.id = booking_container.container_type_id
|
||||||
|
WHERE booking_container.booking_id = b.id AND booking_container.deleted_at IS NULL
|
||||||
|
) bc ON true
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT first_mile.id, first_mile.status, first_mile.vehicle_id
|
||||||
|
FROM freight.first_mile first_mile
|
||||||
|
WHERE first_mile.booking_id = b.id AND first_mile.deleted_at IS NULL
|
||||||
|
ORDER BY first_mile.created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
) fm ON true
|
||||||
|
LEFT JOIN freight.vehicles v ON v.id = fm.vehicle_id
|
||||||
|
LEFT JOIN freight.drivers driver ON driver.id = v.assigned_driver_id
|
||||||
|
WHERE b.id = $1 AND b.deleted_at IS NULL LIMIT 1`,
|
||||||
[bookingId],
|
[bookingId],
|
||||||
);
|
);
|
||||||
assertTruckLoad({
|
if (!booking) { skip('Booking not found'); continue; }
|
||||||
containers: selectedNumbers,
|
if (booking.paymentStatus !== 'PAID') { skip('Booking not PAID'); continue; }
|
||||||
bookingContainers: allUnits.map((unit) => unit.containerNumber),
|
// Direction is derived from the route (yard countries), not the stored field.
|
||||||
sizes: allUnits
|
const bookingDirection = deriveTradeDirection(
|
||||||
.filter((unit) => selectedNumbers.includes(unit.containerNumber))
|
{ country: booking.originCountry },
|
||||||
.map((unit) => unit.containerSize ?? ''),
|
{ country: booking.destinationCountry },
|
||||||
});
|
);
|
||||||
selectedUnits = allUnits.filter((unit) => selectedNumbers.includes(unit.containerNumber));
|
if (bookingDirection !== dto.direction) {
|
||||||
if (selectedUnits.some((unit) => unit.received)) {
|
skip(`Booking route is ${bookingDirection}, not ${dto.direction}`);
|
||||||
const repeated = selectedUnits.filter((unit) => unit.received).map((unit) => unit.containerNumber);
|
continue;
|
||||||
throw new BadRequestException(`Container(s) already received: ${repeated.join(', ')}`);
|
|
||||||
}
|
}
|
||||||
|
if (dto.direction === 'EXPORT' && booking.hasFirstMile) {
|
||||||
// If this is a customer-assigned truck, it may only deliver the boxes
|
if (!booking.firstMileRequestId) {
|
||||||
// assigned to that plate. Manual/unassigned arrivals retain the same
|
skip('First-mile request not created');
|
||||||
// physical capacity validation but have no assignment list to check.
|
continue;
|
||||||
if (truckEntrance?.truckPlateNumber) {
|
}
|
||||||
const assigned: Array<{ containerNumber: string }> = await manager.query(
|
if (booking.firstMileStatus !== 'RECEIVED_TO_PORT') {
|
||||||
`SELECT UPPER(ctc.container_number) AS "containerNumber"
|
skip('First-mile truck has not arrived');
|
||||||
FROM freight.customer_truck_assignments cta
|
continue;
|
||||||
JOIN freight.customer_truck_containers ctc
|
|
||||||
ON ctc.assignment_id = cta.id AND ctc.deleted_at IS NULL
|
|
||||||
WHERE cta.booking_id = $1
|
|
||||||
AND UPPER(cta.plate_number) = UPPER($2)
|
|
||||||
AND cta.deleted_at IS NULL`,
|
|
||||||
[bookingId, truckEntrance.truckPlateNumber],
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
assigned.length > 0 &&
|
|
||||||
selectedNumbers.some(
|
|
||||||
(number) => !assigned.some((container) => container.containerNumber === number),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
`Selected containers are not assigned to truck ${truckEntrance.truckPlateNumber}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [{ batches }]: Array<{ batches: string }> = await manager.query(
|
const containerQuantity = Number(booking.containerQuantity ?? 0);
|
||||||
`SELECT COUNT(DISTINCT inv.grn_number) AS batches
|
if (booking.freightType === 'CONTAINER' && containerQuantity <= 0) {
|
||||||
FROM freight.warehouse_inventory inv
|
skip('Container booking has no container quantity');
|
||||||
WHERE inv.booking_id = $1
|
|
||||||
AND inv.grn_number IS NOT NULL
|
|
||||||
AND inv.deleted_at IS NULL`,
|
|
||||||
[bookingId],
|
|
||||||
);
|
|
||||||
grnNumber = `${grnNumber}-${String(Number(batches ?? 0) + 1).padStart(2, '0')}`;
|
|
||||||
if (truckEntrance) {
|
|
||||||
truckEntrance.assignedEquipmentNumber = selectedNumbers.join(', ');
|
|
||||||
truckEntrance.unitCount = selectedNumbers.length;
|
|
||||||
truckEntrance.netWeightKg = selectedUnits.reduce(
|
|
||||||
(total, unit) => total + Number(unit.weightTons || 0),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const existing = await manager
|
|
||||||
.getRepository(WarehouseInventory)
|
|
||||||
.findOne({ where: { bookingId } });
|
|
||||||
if (existing) {
|
|
||||||
skip('Already received');
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const receivedBefore =
|
const now = new Date();
|
||||||
booking.freightType === 'CONTAINER'
|
const truckEntrance = truckEntranceInput
|
||||||
? Number(
|
? this.mergeSystemTruckEntrance(truckEntranceInput, booking)
|
||||||
(
|
: undefined;
|
||||||
await manager.query(
|
// Multi-truck self-haul is selected explicitly at the gate. The booking
|
||||||
`SELECT COUNT(*) AS count
|
// source contains comma-joined legacy summary fields, which must never
|
||||||
FROM freight.booking_container_units bcu
|
// replace the one physical truck the receiver selected.
|
||||||
JOIN freight.booking_container bc
|
if (truckEntrance && !booking.hasFirstMile && truckEntranceInput) {
|
||||||
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
truckEntrance.truckPlateNumber = truckEntranceInput.truckPlateNumber;
|
||||||
WHERE bc.booking_id = $1
|
truckEntrance.driverName = truckEntranceInput.driverName;
|
||||||
AND bcu.received_to_port = true
|
truckEntrance.driverPhone = truckEntranceInput.driverPhone;
|
||||||
AND bcu.deleted_at IS NULL`,
|
truckEntrance.truckType = truckEntranceInput.truckType;
|
||||||
[bookingId],
|
}
|
||||||
)
|
if (dto.direction === 'EXPORT') {
|
||||||
)[0]?.count ?? 0,
|
this.assertTruckEntrance(truckEntrance);
|
||||||
)
|
}
|
||||||
: 0;
|
|
||||||
const receivedAfter = receivedBefore + selectedUnits.length;
|
|
||||||
const remainingAfter = Math.max(0, containerQuantity - receivedAfter);
|
|
||||||
const receiveNote = this.buildReceiveNote({
|
|
||||||
grnNumber,
|
|
||||||
direction: dto.direction,
|
|
||||||
notes:
|
|
||||||
booking.freightType === 'CONTAINER'
|
|
||||||
? `${selectedUnits.length} container(s) arrived: ${selectedUnits
|
|
||||||
.map((unit) => unit.containerNumber)
|
|
||||||
.join(', ')}. ${remainingAfter} container(s) left.`
|
|
||||||
: `Bulk received (${dto.direction})`,
|
|
||||||
truckEntrance,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Validate capacity before saving
|
type ReceiveContainerUnit = {
|
||||||
const weight =
|
containerNumber: string;
|
||||||
booking.freightType === 'CONTAINER'
|
containerSize: string | null;
|
||||||
? selectedUnits.reduce((total, unit) => total + Number(unit.weightTons || 0), 0)
|
weightTons: string | number;
|
||||||
: Number(booking.weight) || 0;
|
sealNumber: string | null;
|
||||||
const containerCount = booking.freightType === 'CONTAINER' ? selectedUnits.length : 0;
|
bookingContainerId: string;
|
||||||
this.assertCapacity('Warehouse', warehouse, weight, 0, containerCount);
|
containerTypeId: string | null;
|
||||||
this.assertCapacity('Yard', yard, weight, 0, containerCount);
|
received: boolean;
|
||||||
this.assertCapacity('Zone', zone, weight, 0, containerCount);
|
};
|
||||||
|
let selectedUnits: ReceiveContainerUnit[] = [];
|
||||||
|
let grnNumber = this.generateGrnNumber(dto.direction, bookingId, now, booking.customer);
|
||||||
|
|
||||||
const inventoryIds: string[] = [];
|
if (booking.freightType === 'CONTAINER') {
|
||||||
if (booking.freightType === 'CONTAINER') {
|
if (truckBookingIds.length !== 1) {
|
||||||
const containers = manager.getRepository(Container);
|
throw new BadRequestException(
|
||||||
for (const unit of selectedUnits) {
|
'Receive one container booking per arriving truck so its containers and documents stay separate',
|
||||||
let container = await containers.findOne({
|
);
|
||||||
where: { containerNumber: unit.containerNumber },
|
}
|
||||||
withDeleted: true,
|
const selectedNumbers = (truckContainerNumbers ?? []).map((n) => n.trim().toUpperCase());
|
||||||
|
if (!selectedNumbers.length) {
|
||||||
|
throw new BadRequestException('Select the containers arriving on this truck');
|
||||||
|
}
|
||||||
|
const allUnits: ReceiveContainerUnit[] = await manager.query(
|
||||||
|
`SELECT UPPER(bcu.container_number) AS "containerNumber",
|
||||||
|
bc.container_size AS "containerSize",
|
||||||
|
bcu.vgm_tons AS "weightTons",
|
||||||
|
bcu.seal_number AS "sealNumber",
|
||||||
|
bc.id AS "bookingContainerId",
|
||||||
|
bc.container_type_id AS "containerTypeId",
|
||||||
|
bcu.received_to_port AS received
|
||||||
|
FROM freight.booking_container_units bcu
|
||||||
|
JOIN freight.booking_container bc
|
||||||
|
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||||
|
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL
|
||||||
|
FOR UPDATE OF bcu`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
assertTruckLoad({
|
||||||
|
containers: selectedNumbers,
|
||||||
|
bookingContainers: allUnits.map((unit) => unit.containerNumber),
|
||||||
|
sizes: allUnits
|
||||||
|
.filter((unit) => selectedNumbers.includes(unit.containerNumber))
|
||||||
|
.map((unit) => unit.containerSize ?? ''),
|
||||||
});
|
});
|
||||||
if (!container && !unit.containerTypeId) {
|
selectedUnits = allUnits.filter((unit) => selectedNumbers.includes(unit.containerNumber));
|
||||||
throw new BadRequestException(
|
if (selectedUnits.some((unit) => unit.received)) {
|
||||||
`Container ${unit.containerNumber} has no container type and cannot be received`,
|
const repeated = selectedUnits.filter((unit) => unit.received).map((unit) => unit.containerNumber);
|
||||||
|
throw new BadRequestException(`Container(s) already received: ${repeated.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this is a customer-assigned truck, it may only deliver the boxes
|
||||||
|
// assigned to that plate. Manual/unassigned arrivals retain the same
|
||||||
|
// physical capacity validation but have no assignment list to check.
|
||||||
|
if (truckEntrance?.truckPlateNumber) {
|
||||||
|
const assigned: Array<{ containerNumber: string }> = await manager.query(
|
||||||
|
`SELECT UPPER(ctc.container_number) AS "containerNumber"
|
||||||
|
FROM freight.customer_truck_assignments cta
|
||||||
|
JOIN freight.customer_truck_containers ctc
|
||||||
|
ON ctc.assignment_id = cta.id AND ctc.deleted_at IS NULL
|
||||||
|
WHERE cta.booking_id = $1
|
||||||
|
AND UPPER(cta.plate_number) = UPPER($2)
|
||||||
|
AND cta.deleted_at IS NULL`,
|
||||||
|
[bookingId, truckEntrance.truckPlateNumber],
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
assigned.length > 0 &&
|
||||||
|
selectedNumbers.some(
|
||||||
|
(number) => !assigned.some((container) => container.containerNumber === number),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Selected containers are not assigned to truck ${truckEntrance.truckPlateNumber}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [{ batches }]: Array<{ batches: string }> = await manager.query(
|
||||||
|
`SELECT COUNT(DISTINCT inv.grn_number) AS batches
|
||||||
|
FROM freight.warehouse_inventory inv
|
||||||
|
WHERE inv.booking_id = $1
|
||||||
|
AND inv.grn_number IS NOT NULL
|
||||||
|
AND inv.deleted_at IS NULL`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
grnNumber = `${grnNumber}-${String(Number(batches ?? 0) + 1).padStart(2, '0')}`;
|
||||||
|
if (truckEntrance) {
|
||||||
|
truckEntrance.assignedEquipmentNumber = selectedNumbers.join(', ');
|
||||||
|
truckEntrance.unitCount = selectedNumbers.length;
|
||||||
|
truckEntrance.netWeightKg = selectedUnits.reduce(
|
||||||
|
(total, unit) => total + Number(unit.weightTons || 0),
|
||||||
|
0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!container) {
|
} else {
|
||||||
container = await containers.save(
|
const existing = await manager
|
||||||
containers.create({
|
.getRepository(WarehouseInventory)
|
||||||
containerNumber: unit.containerNumber,
|
.findOne({ where: { bookingId } });
|
||||||
containerTypeId: unit.containerTypeId as string,
|
if (existing) {
|
||||||
bookingContainerId: unit.bookingContainerId,
|
skip('Already received');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const receivedBefore =
|
||||||
|
booking.freightType === 'CONTAINER'
|
||||||
|
? Number(
|
||||||
|
(
|
||||||
|
await manager.query(
|
||||||
|
`SELECT COUNT(*) AS count
|
||||||
|
FROM freight.booking_container_units bcu
|
||||||
|
JOIN freight.booking_container bc
|
||||||
|
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||||
|
WHERE bc.booking_id = $1
|
||||||
|
AND bcu.received_to_port = true
|
||||||
|
AND bcu.deleted_at IS NULL`,
|
||||||
|
[bookingId],
|
||||||
|
)
|
||||||
|
)[0]?.count ?? 0,
|
||||||
|
)
|
||||||
|
: 0;
|
||||||
|
const receivedAfter = receivedBefore + selectedUnits.length;
|
||||||
|
const remainingAfter = Math.max(0, containerQuantity - receivedAfter);
|
||||||
|
const receiveNote = this.buildReceiveNote({
|
||||||
|
grnNumber,
|
||||||
|
direction: dto.direction,
|
||||||
|
notes:
|
||||||
|
booking.freightType === 'CONTAINER'
|
||||||
|
? `${selectedUnits.length} container(s) arrived: ${selectedUnits
|
||||||
|
.map((unit) => unit.containerNumber)
|
||||||
|
.join(', ')}. ${remainingAfter} container(s) left.`
|
||||||
|
: `Bulk received (${dto.direction})`,
|
||||||
|
truckEntrance,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validate capacity before saving
|
||||||
|
const weight =
|
||||||
|
booking.freightType === 'CONTAINER'
|
||||||
|
? selectedUnits.reduce((total, unit) => total + Number(unit.weightTons || 0), 0)
|
||||||
|
: Number(booking.weight) || 0;
|
||||||
|
const containerCount = booking.freightType === 'CONTAINER' ? selectedUnits.length : 0;
|
||||||
|
this.assertCapacity('Warehouse', warehouse, weight, 0, containerCount);
|
||||||
|
this.assertCapacity('Yard', yard, weight, 0, containerCount);
|
||||||
|
this.assertCapacity('Zone', zone, weight, 0, containerCount);
|
||||||
|
|
||||||
|
const inventoryIds: string[] = [];
|
||||||
|
if (booking.freightType === 'CONTAINER') {
|
||||||
|
const containers = manager.getRepository(Container);
|
||||||
|
for (const unit of selectedUnits) {
|
||||||
|
let container = await containers.findOne({
|
||||||
|
where: { containerNumber: unit.containerNumber },
|
||||||
|
withDeleted: true,
|
||||||
|
});
|
||||||
|
if (!container && !unit.containerTypeId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Container ${unit.containerNumber} has no container type and cannot be received`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!container) {
|
||||||
|
container = await containers.save(
|
||||||
|
containers.create({
|
||||||
|
containerNumber: unit.containerNumber,
|
||||||
|
containerTypeId: unit.containerTypeId as string,
|
||||||
|
bookingContainerId: unit.bookingContainerId,
|
||||||
|
bookingId,
|
||||||
|
sealNumber: unit.sealNumber,
|
||||||
|
tareWeight: 0,
|
||||||
|
maxGrossWeight: Number(unit.weightTons || 0),
|
||||||
|
status: 'LOADED',
|
||||||
|
wagonId: null,
|
||||||
|
position: null,
|
||||||
|
wagonBookingAllocationId: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await containers.update(container.id, {
|
||||||
bookingId,
|
bookingId,
|
||||||
|
bookingContainerId: unit.bookingContainerId,
|
||||||
sealNumber: unit.sealNumber,
|
sealNumber: unit.sealNumber,
|
||||||
tareWeight: 0,
|
|
||||||
maxGrossWeight: Number(unit.weightTons || 0),
|
|
||||||
status: 'LOADED',
|
status: 'LOADED',
|
||||||
wagonId: null,
|
deletedAt: null,
|
||||||
position: null,
|
});
|
||||||
wagonBookingAllocationId: null,
|
}
|
||||||
|
const saved = await manager.getRepository(WarehouseInventory).save(
|
||||||
|
manager.getRepository(WarehouseInventory).create({
|
||||||
|
warehouseId: dto.warehouseId,
|
||||||
|
yardId: dto.yardId,
|
||||||
|
zoneId: dto.zoneId,
|
||||||
|
bookingId,
|
||||||
|
containerId: container.id,
|
||||||
|
quantity: 1,
|
||||||
|
weight: Number(unit.weightTons || 0),
|
||||||
|
grnNumber,
|
||||||
|
status: 'RECEIVED',
|
||||||
|
arrivedAt: now,
|
||||||
|
notes: receiveNote,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} else {
|
inventoryIds.push(saved.id);
|
||||||
await containers.update(container.id, {
|
|
||||||
bookingId,
|
|
||||||
bookingContainerId: unit.bookingContainerId,
|
|
||||||
sealNumber: unit.sealNumber,
|
|
||||||
status: 'LOADED',
|
|
||||||
deletedAt: null,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
await manager.query(
|
||||||
|
`UPDATE freight.booking_container_units bcu
|
||||||
|
SET received_to_port = true,
|
||||||
|
received_at = COALESCE(bcu.received_at, NOW()),
|
||||||
|
grn_number = $3,
|
||||||
|
updated_at = NOW()
|
||||||
|
FROM freight.booking_container bc
|
||||||
|
WHERE bc.id = bcu.booking_container_id
|
||||||
|
AND bc.booking_id = $1
|
||||||
|
AND UPPER(bcu.container_number) = ANY($2::varchar[])
|
||||||
|
AND bc.deleted_at IS NULL
|
||||||
|
AND bcu.deleted_at IS NULL`,
|
||||||
|
[bookingId, selectedUnits.map((unit) => unit.containerNumber), grnNumber],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
const saved = await manager.getRepository(WarehouseInventory).save(
|
const saved = await manager.getRepository(WarehouseInventory).save(
|
||||||
manager.getRepository(WarehouseInventory).create({
|
manager.getRepository(WarehouseInventory).create({
|
||||||
warehouseId: dto.warehouseId,
|
warehouseId: dto.warehouseId,
|
||||||
yardId: dto.yardId,
|
yardId: dto.yardId,
|
||||||
zoneId: dto.zoneId,
|
zoneId: dto.zoneId,
|
||||||
bookingId,
|
bookingId,
|
||||||
containerId: container.id,
|
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
weight: Number(unit.weightTons || 0),
|
weight,
|
||||||
grnNumber,
|
grnNumber,
|
||||||
status: 'RECEIVED',
|
status: 'RECEIVED',
|
||||||
arrivedAt: now,
|
arrivedAt: now,
|
||||||
@@ -1917,90 +1996,60 @@ export class WarehouseInventoryService {
|
|||||||
);
|
);
|
||||||
inventoryIds.push(saved.id);
|
inventoryIds.push(saved.id);
|
||||||
}
|
}
|
||||||
await manager.query(
|
|
||||||
`UPDATE freight.booking_container_units bcu
|
// Update warehouse/yard/zone capacity counters
|
||||||
SET received_to_port = true,
|
await this.applyCapacityDelta(manager, dto, weight, 0, containerCount);
|
||||||
received_at = COALESCE(bcu.received_at, NOW()),
|
|
||||||
grn_number = $3,
|
// Export self-haul: this receive IS the truck's arrival — see
|
||||||
updated_at = NOW()
|
// markCustomerTruckArrived / receive()'s single-booking mirror.
|
||||||
FROM freight.booking_container bc
|
if (dto.direction === 'EXPORT') {
|
||||||
WHERE bc.id = bcu.booking_container_id
|
await this.markCustomerTruckArrived(manager, bookingId, truckEntrance?.truckPlateNumber);
|
||||||
AND bc.booking_id = $1
|
}
|
||||||
AND UPPER(bcu.container_number) = ANY($2::varchar[])
|
|
||||||
AND bc.deleted_at IS NULL
|
await this.activityLog.record(
|
||||||
AND bcu.deleted_at IS NULL`,
|
{
|
||||||
[bookingId, selectedUnits.map((unit) => unit.containerNumber), grnNumber],
|
activityType: 'INVENTORY_RECEIVED',
|
||||||
);
|
inventoryId: inventoryIds[0],
|
||||||
} else {
|
|
||||||
const saved = await manager.getRepository(WarehouseInventory).save(
|
|
||||||
manager.getRepository(WarehouseInventory).create({
|
|
||||||
warehouseId: dto.warehouseId,
|
warehouseId: dto.warehouseId,
|
||||||
yardId: dto.yardId,
|
description: truckEntrance?.truckPlateNumber
|
||||||
zoneId: dto.zoneId,
|
? `GRN ${grnNumber}: bulk received ${dto.direction} booking via truck ${truckEntrance.truckPlateNumber}`
|
||||||
bookingId,
|
: `GRN ${grnNumber}: bulk received ${dto.direction} booking`,
|
||||||
quantity: 1,
|
performedBy: dto.performedBy,
|
||||||
weight,
|
},
|
||||||
grnNumber,
|
manager,
|
||||||
status: 'RECEIVED',
|
|
||||||
arrivedAt: now,
|
|
||||||
notes: receiveNote,
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
inventoryIds.push(saved.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update warehouse/yard/zone capacity counters
|
// Queued, not sent here: an SMS/email round-trip inside the transaction
|
||||||
await this.applyCapacityDelta(manager, dto, weight, 0, containerCount);
|
// holds capacity/location locks open for the whole gateway latency.
|
||||||
|
pendingNotifications.push({
|
||||||
// Export self-haul: this receive IS the truck's arrival — see
|
owner: {
|
||||||
// markCustomerTruckArrived / receive()'s single-booking mirror.
|
phone: truckEntrance?.customerPhone ?? booking.customerPhone,
|
||||||
if (dto.direction === 'EXPORT') {
|
ownerName: truckEntrance?.ownerName ?? booking.customer,
|
||||||
await this.markCustomerTruckArrived(manager, bookingId, truckEntrance?.truckPlateNumber);
|
bookingReference: truckEntrance?.edrDigitalBookingId ?? booking.reference,
|
||||||
}
|
grnNumber,
|
||||||
|
direction: dto.direction,
|
||||||
await this.activityLog.record(
|
warehouseId: dto.warehouseId,
|
||||||
{
|
bookingId,
|
||||||
activityType: 'INVENTORY_RECEIVED',
|
},
|
||||||
inventoryId: inventoryIds[0],
|
booking,
|
||||||
warehouseId: dto.warehouseId,
|
|
||||||
description: truckEntrance?.truckPlateNumber
|
|
||||||
? `GRN ${grnNumber}: bulk received ${dto.direction} booking via truck ${truckEntrance.truckPlateNumber}`
|
|
||||||
: `GRN ${grnNumber}: bulk received ${dto.direction} booking`,
|
|
||||||
performedBy: dto.performedBy,
|
|
||||||
},
|
|
||||||
manager,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Queued, not sent here: an SMS/email round-trip inside the transaction
|
|
||||||
// holds capacity/location locks open for the whole gateway latency.
|
|
||||||
pendingNotifications.push({
|
|
||||||
owner: {
|
|
||||||
phone: truckEntrance?.customerPhone ?? booking.customerPhone,
|
|
||||||
ownerName: truckEntrance?.ownerName ?? booking.customer,
|
|
||||||
bookingReference: truckEntrance?.edrDigitalBookingId ?? booking.reference,
|
|
||||||
grnNumber,
|
|
||||||
direction: dto.direction,
|
|
||||||
warehouseId: dto.warehouseId,
|
|
||||||
bookingId,
|
bookingId,
|
||||||
},
|
});
|
||||||
booking,
|
|
||||||
bookingId,
|
|
||||||
});
|
|
||||||
|
|
||||||
result.receivedCount += 1;
|
result.receivedCount += 1;
|
||||||
result.results.push({
|
result.results.push({
|
||||||
bookingId,
|
bookingId,
|
||||||
status: 'RECEIVED',
|
status: 'RECEIVED',
|
||||||
inventoryId: inventoryIds[0],
|
inventoryId: inventoryIds[0],
|
||||||
inventoryIds,
|
inventoryIds,
|
||||||
grnNumber,
|
grnNumber,
|
||||||
...(booking.freightType === 'CONTAINER'
|
...(booking.freightType === 'CONTAINER'
|
||||||
? {
|
? {
|
||||||
receivedContainers: receivedAfter,
|
receivedContainers: receivedAfter,
|
||||||
remainingContainers: remainingAfter,
|
remainingContainers: remainingAfter,
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2032,8 +2081,10 @@ export class WarehouseInventoryService {
|
|||||||
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
|
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
|
||||||
inv.weight AS "weight",
|
inv.weight AS "weight",
|
||||||
COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber",
|
COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber",
|
||||||
oy.code AS "origin",
|
-- Operators know a yard by its name: KALITY is universally called
|
||||||
dy.code AS "destination",
|
-- GMP / Gelan Multipurpose Port. Code is only a fallback.
|
||||||
|
COALESCE(oy.label, oy.code) AS "origin",
|
||||||
|
COALESCE(dy.label, dy.code) AS "destination",
|
||||||
oy.country AS "originCountry",
|
oy.country AS "originCountry",
|
||||||
dy.country AS "destinationCountry",
|
dy.country AS "destinationCountry",
|
||||||
inv.inspection_status AS "inspectionStatus",
|
inv.inspection_status AS "inspectionStatus",
|
||||||
@@ -3075,7 +3126,15 @@ export class WarehouseInventoryService {
|
|||||||
// UNLOADED added for Batch 9 import destination inspection (arrived-train unload landing state).
|
// UNLOADED added for Batch 9 import destination inspection (arrived-train unload landing state).
|
||||||
const eligible = ['UNLOADED', 'RECEIVED', 'STORED', 'RESERVED'];
|
const eligible = ['UNLOADED', 'RECEIVED', 'STORED', 'RESERVED'];
|
||||||
|
|
||||||
for (const inventoryId of dto.inventoryIds) {
|
// Inspection is a judgement on the booking's cargo, not on the row it
|
||||||
|
// happens to sit in. A booking's cargo spans one inventory row per
|
||||||
|
// container, and a multi-truck arrival adds a GRN batch per truck — so
|
||||||
|
// ticking one row passes every eligible row of the same booking and the
|
||||||
|
// whole booking advances together. Without this a six-container booking
|
||||||
|
// stayed half-inspected and never reached Ready To Load.
|
||||||
|
const inventoryIds = await this.expandInspectionToBooking(dto.inventoryIds, eligible);
|
||||||
|
|
||||||
|
for (const inventoryId of inventoryIds) {
|
||||||
const skip = (reason: string) => {
|
const skip = (reason: string) => {
|
||||||
result.skippedCount += 1;
|
result.skippedCount += 1;
|
||||||
result.results.push({ inventoryId, status: 'SKIPPED', reason });
|
result.results.push({ inventoryId, status: 'SKIPPED', reason });
|
||||||
@@ -3084,6 +3143,13 @@ export class WarehouseInventoryService {
|
|||||||
const item = await this.inventoryRepository.findById(inventoryId);
|
const item = await this.inventoryRepository.findById(inventoryId);
|
||||||
if (!item) { skip('Inventory not found'); continue; }
|
if (!item) { skip('Inventory not found'); continue; }
|
||||||
if (item.inspectionStatus === 'PASSED') { skip('Already inspected'); continue; }
|
if (item.inspectionStatus === 'PASSED') { skip('Already inspected'); continue; }
|
||||||
|
// Overturning a failure is a deliberate, reasoned act — never a side
|
||||||
|
// effect of ticking a row in a list. Those items are held back for an
|
||||||
|
// individual re-inspection that records why the cargo may now travel.
|
||||||
|
if (item.inspectionStatus === 'FAILED' || item.inspectionStatus === 'NEEDS_REVIEW') {
|
||||||
|
skip(`Inspection ${item.inspectionStatus} — re-inspect this item individually and give a reason`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (!eligible.includes(item.status)) { skip(`Status ${item.status} not eligible for inspection`); continue; }
|
if (!eligible.includes(item.status)) { skip(`Status ${item.status} not eligible for inspection`); continue; }
|
||||||
|
|
||||||
// Reuse the existing inspection service: creates a minimal PASSED report + sets inspectionStatus/inspectedAt.
|
// Reuse the existing inspection service: creates a minimal PASSED report + sets inspectionStatus/inspectedAt.
|
||||||
@@ -3144,6 +3210,41 @@ export class WarehouseInventoryService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widen a set of selected inventory rows to every still-inspectable row of
|
||||||
|
* the same booking.
|
||||||
|
*
|
||||||
|
* The originally selected ids are always kept, even when ineligible, so the
|
||||||
|
* caller still reports their skip reason rather than dropping them silently.
|
||||||
|
* Rows with no booking (ad-hoc inventory) expand to themselves.
|
||||||
|
*/
|
||||||
|
private async expandInspectionToBooking(
|
||||||
|
inventoryIds: string[],
|
||||||
|
eligibleStatuses: string[],
|
||||||
|
): Promise<string[]> {
|
||||||
|
if (inventoryIds.length === 0) return [];
|
||||||
|
const rows: Array<{ id: string }> = await this.dataSource.query(
|
||||||
|
`SELECT DISTINCT sibling.id AS id
|
||||||
|
FROM freight.warehouse_inventory selected
|
||||||
|
JOIN freight.warehouse_inventory sibling
|
||||||
|
ON sibling.booking_id = selected.booking_id
|
||||||
|
AND sibling.deleted_at IS NULL
|
||||||
|
AND sibling.inspection_status IS DISTINCT FROM 'PASSED'
|
||||||
|
AND sibling.status = ANY($2::text[])
|
||||||
|
WHERE selected.id = ANY($1::uuid[])
|
||||||
|
AND selected.deleted_at IS NULL
|
||||||
|
AND selected.booking_id IS NOT NULL
|
||||||
|
UNION
|
||||||
|
SELECT id FROM freight.warehouse_inventory
|
||||||
|
WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL`,
|
||||||
|
[inventoryIds, eligibleStatuses],
|
||||||
|
);
|
||||||
|
// Selected rows first so their results lead the response the operator sees.
|
||||||
|
const expanded = rows.map((row) => row.id);
|
||||||
|
const selectedFirst = inventoryIds.filter((id) => expanded.includes(id));
|
||||||
|
return [...selectedFirst, ...expanded.filter((id) => !selectedFirst.includes(id))];
|
||||||
|
}
|
||||||
|
|
||||||
// ── Receive ──────────────────────────────────────────────────────────────
|
// ── Receive ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private async acceptLastMileIfRequested(bookingId?: string | null): Promise<void> {
|
private async acceptLastMileIfRequested(bookingId?: string | null): Promise<void> {
|
||||||
@@ -5769,6 +5870,18 @@ export class WarehouseInventoryService {
|
|||||||
// 1. inventory status must be READY_FOR_LOADING (and not already LOADED).
|
// 1. inventory status must be READY_FOR_LOADING (and not already LOADED).
|
||||||
this.assertTransition(item.status, 'LOADED');
|
this.assertTransition(item.status, 'LOADED');
|
||||||
|
|
||||||
|
// 1b. Failed or under-review cargo does not travel. Status alone is not
|
||||||
|
// enough: an item that passed, reached READY_FOR_LOADING and was then
|
||||||
|
// re-inspected as FAILED keeps that status, so the inspection outcome is
|
||||||
|
// checked here — the one choke point every loading path runs through.
|
||||||
|
if (item.inspectionStatus !== 'PASSED') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
item.inspectionStatus
|
||||||
|
? `Inspection is ${item.inspectionStatus} — the cargo must be re-inspected and passed, with a reason, before it can be loaded`
|
||||||
|
: 'Inventory must pass inspection before it can be loaded',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 2. inventory is at a valid warehouse/yard/zone location.
|
// 2. inventory is at a valid warehouse/yard/zone location.
|
||||||
if (!item.warehouseId || !item.yardId || !item.zoneId) {
|
if (!item.warehouseId || !item.yardId || !item.zoneId) {
|
||||||
throw new BadRequestException('Inventory must be at a warehouse/yard/zone before loading');
|
throw new BadRequestException('Inventory must be at a warehouse/yard/zone before loading');
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user