mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 08:58:21 +00:00
Merge pull request #1504 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -17,9 +17,9 @@ export default registerAs("app", () => ({
|
||||
portalBaseUrl: (
|
||||
process.env.FREIGHT_PORTAL_URL ?? "http://localhost:5173"
|
||||
).replace(/\/+$/, ""),
|
||||
// Train weight/length are not env-configured: they come from locomotive
|
||||
// configuration (see TrainSchedulingService.resolveTrainLimitConfig).
|
||||
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),
|
||||
},
|
||||
// Consumed by @edr/api-common ExchangeModule.forRootAsync (see bookings.module.ts).
|
||||
|
||||
@@ -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,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,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);
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
cancelledByCustomer(c: Contract, reason: string): void {
|
||||
this.inAppStaff(
|
||||
|
||||
@@ -1502,6 +1502,105 @@ export class ContractTransitionService {
|
||||
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> {
|
||||
const source = await this.contractsService.findById(contractId);
|
||||
|
||||
|
||||
@@ -78,6 +78,10 @@ import {
|
||||
import { SignContractDto } from './dto/sign-contract.dto';
|
||||
import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto';
|
||||
import { RenewContractDto } from './dto/renew-contract.dto';
|
||||
import {
|
||||
ExtendContractDto,
|
||||
RequestContractExtensionDto,
|
||||
} from './dto/extend-contract.dto';
|
||||
import {
|
||||
CompleteConsolidatedPairDto,
|
||||
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')
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
|
||||
@@ -135,7 +135,9 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
const result = await this.repository
|
||||
.createQueryBuilder()
|
||||
.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')
|
||||
.andWhere('status NOT IN (:...terminal)', { terminal: TERMINAL_CONTRACT_STATUSES })
|
||||
.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
|
||||
.createQueryBuilder()
|
||||
.update(Contract)
|
||||
.set({ status: 'EXPIRED' })
|
||||
.set({ status: 'EXPIRED', statusBeforeExpiry: () => 'status' })
|
||||
.where('id = :id', { id })
|
||||
.andWhere('deleted_at IS NULL')
|
||||
.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
|
||||
// click it and read a 400. The API re-checks on cancel regardless.
|
||||
contract.activeBookingCount =
|
||||
|
||||
@@ -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',
|
||||
/** Customer cancelled their own contract; body is their reason. */
|
||||
'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;
|
||||
export type ContractReviewNoteType =
|
||||
(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 })
|
||||
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' })
|
||||
clearanceStatus!: string;
|
||||
|
||||
@@ -373,6 +390,13 @@ export class Contract extends BaseEntity {
|
||||
*/
|
||||
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
|
||||
* ContractsService.findById. The portal disables customer cancellation while
|
||||
|
||||
@@ -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 { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { TrainCrewAssignment } from './entities/train-crew-assignment.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 { TrainCrewService } from './train-crew.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([TrainCrewMember])],
|
||||
providers: [TrainCrewService],
|
||||
controllers: [TrainCrewController],
|
||||
exports: [TrainCrewService],
|
||||
imports: [TypeOrmModule.forFeature([TrainCrewMember, TrainCrewAssignment])],
|
||||
providers: [TrainCrewService, TrainCrewAssignmentService],
|
||||
controllers: [TrainCrewController, TrainCrewAssignmentController],
|
||||
exports: [TrainCrewService, TrainCrewAssignmentService],
|
||||
})
|
||||
export class TrainCrewModule {}
|
||||
|
||||
@@ -871,7 +871,7 @@ export class TrainSchedulingController {
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({
|
||||
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(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
|
||||
@@ -3,6 +3,13 @@ import { Column, Entity } from 'typeorm';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'train_scheduling_global_rules' })
|
||||
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({
|
||||
name: 'max_train_length_meters',
|
||||
type: 'numeric',
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Wagon } from '../../wagons/entities/wagon.entity';
|
||||
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.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 { TrainSchedulingService } from './train-scheduling.service';
|
||||
|
||||
@@ -2211,4 +2212,46 @@ describe('TrainSchedulingService', () => {
|
||||
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 { TrainSchedulesRepository } from '../../train-schedules/train-schedules.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 { WagonAllocationContainerItemsRepository } from '../../train-schedules/wagon-allocation-container-items.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 { Wagon } from '../../wagons/entities/wagon.entity';
|
||||
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). */
|
||||
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 { AssignBookingsDto } from '../dto/assign-bookings.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 { PreviewTrainScheduleDto } from '../dto/preview-train-schedule.dto';
|
||||
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 {
|
||||
ImportDjiboutiOperation,
|
||||
@@ -159,6 +149,7 @@ import {
|
||||
validateMixedTrainLimitsPerEdge,
|
||||
MAX_TEU_SLOTS_PER_WAGON,
|
||||
type ContainerPlacementInput,
|
||||
type ContainerPlacementRules,
|
||||
type WagonPlanSlot,
|
||||
} from '../utils/wagon-plan.util';
|
||||
import {
|
||||
@@ -189,6 +180,8 @@ import {
|
||||
trainSetLocomotiveLimits,
|
||||
wagonTypeDimensionsFromEntity,
|
||||
LocomotiveLimits,
|
||||
MAX_FALLBACK_LENGTH,
|
||||
MAX_FALLBACK_WEIGHT,
|
||||
WagonTypeDimensions,
|
||||
} from '../train-capacity.util';
|
||||
import {
|
||||
@@ -378,13 +371,13 @@ export interface UnassignedBookingsResponse {
|
||||
bookings: CompositionUnassignedBookingRow[];
|
||||
}
|
||||
|
||||
const DEFAULT_TRAIN_LIMITS: Required<TrainLimitConfig> = {
|
||||
maxWeightTons: 3500,
|
||||
maxLengthMeters: 760,
|
||||
maxWagonsPerTrain: Math.floor(760 / 14),
|
||||
max20ftContainerWeightTons: 30,
|
||||
max20ftPairWeightDiffTons: 10,
|
||||
};
|
||||
/**
|
||||
* Train weight/length come from locomotive configuration (the assigned set, or
|
||||
* the strongest in-service locomotive when none is assigned yet); per-box
|
||||
* container ceilings come from the rule engine's weight limit rules. Only the
|
||||
* 20ft pair-imbalance tolerance is a static default.
|
||||
*/
|
||||
const DEFAULT_20FT_PAIR_WEIGHT_DIFF_TONS = 10;
|
||||
|
||||
/** Raw row shape for the booking-window queries (company- and contract-scoped). */
|
||||
interface BookingWindowRow {
|
||||
@@ -446,9 +439,10 @@ export class TrainSchedulingService {
|
||||
// Per-wagon history ledger (global module). @Optional keeps the positional
|
||||
// spec constructors working; production always has it.
|
||||
@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
|
||||
// keep working; production always resolves it from ExportsModule.
|
||||
@Optional() private readonly tabularExport?: TabularExportService,
|
||||
// keep working; production always resolves it.
|
||||
@Optional() private readonly trainCrewAssignments?: TrainCrewAssignmentService,
|
||||
) {}
|
||||
|
||||
/** Physical wagons behind a set of booking allocations (via their slots), for cargo history rows. */
|
||||
@@ -809,9 +803,9 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Train length/weight and 20ft weight caps are engine-internal (wagon
|
||||
* planning still reads them off the row); they are no longer exposed or
|
||||
* editable through the global-rules endpoints.
|
||||
* Train length/weight and the 20ft weight cap columns are legacy: planning
|
||||
* now takes weight/length from locomotive configuration and per-box ceilings
|
||||
* from weight limit rules. They are neither read nor exposed here.
|
||||
*/
|
||||
private toPublicGlobalRules(row: TrainSchedulingGlobalRules | null) {
|
||||
if (!row) return row;
|
||||
@@ -3027,6 +3021,11 @@ export class TrainSchedulingService {
|
||||
'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.
|
||||
await this.trainCrewAssignments?.assertCrewReadyForDispatch(scheduleId);
|
||||
// Staff may record the departure after the fact — past is fine, future is not.
|
||||
const now = dto.actualDepartureAt ? new Date(dto.actualDepartureAt) : new Date();
|
||||
this.assertNotFuture(now, 'Departure time');
|
||||
@@ -3770,15 +3769,15 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
/**
|
||||
* The schedule detail page's wagon-list Excel export.
|
||||
*
|
||||
* One row per container (a wagon carrying two boxes yields two rows, repeating
|
||||
* the wagon number) so each container's own VGM is present and totals footable.
|
||||
* Bulk wagons, having no containers, yield a single row carrying the bulk
|
||||
* description and the allocated tonnage as the VGM figure.
|
||||
* 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 line per container (a two-box wagon repeats its wagon number under one
|
||||
* "No."), a blank line between customers, and the wagon count / company /
|
||||
* transitor merged down each group. See buildWagonListWorkbook.
|
||||
*
|
||||
* 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(
|
||||
scheduleId: string,
|
||||
@@ -3787,56 +3786,37 @@ export class TrainSchedulingService {
|
||||
if (!schedule) {
|
||||
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
|
||||
// container-less) allocation as one row. `booking_container_units` is joined
|
||||
// on BOTH container number and its booking_container line — container
|
||||
// numbers repeat across bookings, so number alone would multiply rows.
|
||||
const rows: ScheduleWagonListRow[] = await this.dataSource.query(
|
||||
// Row grain is the container item; the LEFT JOIN keeps a bulk (or any
|
||||
// container-less) allocation as one row. The transitor is the customs
|
||||
// clearing agent the customer named on the booking.
|
||||
const lines: WagonListLine[] = await this.dataSource.query(
|
||||
`SELECT tsw.sequence_no AS "sequenceNo",
|
||||
w.wagon_number AS "wagonNumber",
|
||||
COALESCE(wt.name, wt.code) AS "wagonType",
|
||||
ci.container_number AS "containerNumber",
|
||||
cit.size_ft AS "containerSizeFt",
|
||||
a.load_type AS "loadType",
|
||||
a.status AS "status",
|
||||
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(ay.label, sd.label) AS "destinationLabel",
|
||||
b.reference AS "bookingReference",
|
||||
COALESCE(
|
||||
slc.name,
|
||||
CASE WHEN b.is_government THEN NULLIF(TRIM(b.government_institution), '') END,
|
||||
c.name
|
||||
) AS "customerName"
|
||||
) AS "customerName",
|
||||
NULLIF(TRIM(b.customs_clearing_agent), '') AS "transitor"
|
||||
FROM freight.train_schedules s
|
||||
JOIN freight.train_set_wagons tsw
|
||||
ON tsw.train_set_id = s.train_set_id AND tsw.deleted_at IS NULL
|
||||
JOIN freight.wagon_booking_allocations a
|
||||
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.wagon_types wt ON wt.id = tsw.wagon_type_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.shipping_line_companies slc ON slc.id = b.shipping_line_company_id
|
||||
LEFT JOIN freight.wagon_allocation_container_items ci
|
||||
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.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
|
||||
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
|
||||
@@ -3848,47 +3828,13 @@ export class TrainSchedulingService {
|
||||
[scheduleId],
|
||||
);
|
||||
|
||||
// "number" is the printed line number of the sheet, not the wagon sequence —
|
||||
// a two-container wagon occupies two lines, and the reader counts lines.
|
||||
const sheetRows = rows.map((row, index) => ({
|
||||
number: index + 1,
|
||||
wagonNumber: row.wagonNumber ?? '—',
|
||||
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 { groups, totalWagons } = groupWagonListLines(lines);
|
||||
const buffer = await buildWagonListWorkbook({
|
||||
trainLabel: schedule.trainNumber ?? schedule.reference ?? schedule.id,
|
||||
groups,
|
||||
totalWagons,
|
||||
});
|
||||
|
||||
const reference = schedule.reference ?? schedule.trainNumber ?? schedule.id;
|
||||
return {
|
||||
filename: `wagon-list-${this.safeDocumentName(reference)}.xlsx`,
|
||||
buffer,
|
||||
@@ -6708,11 +6654,6 @@ export class TrainSchedulingService {
|
||||
)),
|
||||
);
|
||||
|
||||
const placementRules = {
|
||||
max20ftContainerWeightTons: trainLimits.max20ftContainerWeightTons,
|
||||
max20ftPairWeightDiffTons: trainLimits.max20ftPairWeightDiffTons,
|
||||
};
|
||||
|
||||
// With forceAssign, capacity-shaped rules (train limits, total weight,
|
||||
// locomotive capability) become warnings — staff owns the override. Physical
|
||||
// impossibilities (no wagon of the required type at the yard, wrong route,
|
||||
@@ -6745,6 +6686,11 @@ export class TrainSchedulingService {
|
||||
);
|
||||
if (requireContainerPlacements && resolvedMode !== 'BULK') {
|
||||
const containerBookings = fittingBookings.filter((b) => b.freightType === 'CONTAINER');
|
||||
const placementRules: ContainerPlacementRules = {
|
||||
maxContainerWeightTonsByLineId:
|
||||
await this.containerCapacityCeilingsByLine(containerBookings),
|
||||
max20ftPairWeightDiffTons: trainLimits.max20ftPairWeightDiffTons,
|
||||
};
|
||||
violations.push(
|
||||
...validateContainerPlacements(
|
||||
containerBookings,
|
||||
@@ -6892,6 +6838,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(
|
||||
dto?: {
|
||||
maxTrainWeightTons?: number;
|
||||
@@ -6902,24 +6912,14 @@ export class TrainSchedulingService {
|
||||
builtWagonCount?: number,
|
||||
): Promise<Required<TrainLimitConfig>> {
|
||||
const row = await this.loadGlobalRulesRow();
|
||||
const configured = this.configService?.get<{
|
||||
maxTrainWeightTons?: number;
|
||||
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 configured = this.configService?.get<{ maxWagonsPerTrain?: number }>(
|
||||
'app.trainScheduling',
|
||||
);
|
||||
const wagonTypes = await this.loadSchedulingWagonTypeDimensions();
|
||||
const max20ftPairWeightDiffTons = this.positiveNumber(
|
||||
undefined,
|
||||
Number(row?.max20ftPairWeightDiffTons) || DEFAULT_20FT_PAIR_WEIGHT_DIFF_TONS,
|
||||
);
|
||||
|
||||
if (locomotive) {
|
||||
// With a locomotive assigned its own limits are the single source of
|
||||
@@ -6954,52 +6954,40 @@ export class TrainSchedulingService {
|
||||
: builtWagonCount && builtWagonCount > 0
|
||||
? builtWagonCount
|
||||
: derived.maxWagonSlots,
|
||||
max20ftContainerWeightTons: this.positiveNumber(
|
||||
undefined,
|
||||
Number(row?.max20ftContainerWeightTons) ||
|
||||
DEFAULT_TRAIN_LIMITS.max20ftContainerWeightTons,
|
||||
),
|
||||
max20ftPairWeightDiffTons: this.positiveNumber(
|
||||
undefined,
|
||||
Number(row?.max20ftPairWeightDiffTons) ||
|
||||
DEFAULT_TRAIN_LIMITS.max20ftPairWeightDiffTons,
|
||||
),
|
||||
max20ftPairWeightDiffTons,
|
||||
};
|
||||
}
|
||||
|
||||
const maxWeightTons = this.positiveNumber(
|
||||
dto?.maxTrainWeightTons,
|
||||
ruleWeightCap ?? DEFAULT_TRAIN_LIMITS.maxWeightTons,
|
||||
);
|
||||
const maxLengthMeters = this.positiveNumber(
|
||||
dto?.maxTrainLengthMeters,
|
||||
ruleLengthCap ?? DEFAULT_TRAIN_LIMITS.maxLengthMeters,
|
||||
);
|
||||
const derivedWithoutLoco = deriveTrainCapacityFromLocomotive(
|
||||
{ maxPullWeightTons: maxWeightTons, maxTrainLengthMeters: maxLengthMeters },
|
||||
// No locomotive on the set yet: plan against the strongest in-service
|
||||
// locomotive's configuration. An explicit dto override still narrows it.
|
||||
const fleet = await this.strongestFleetLocomotiveLimits();
|
||||
if (!fleet) {
|
||||
this.logger.warn(
|
||||
'No in-service locomotive is configured — train weight/length limits fall back to ' +
|
||||
`${MAX_FALLBACK_WEIGHT}T / ${MAX_FALLBACK_LENGTH}m until a locomotive is added`,
|
||||
);
|
||||
}
|
||||
const derived = deriveTrainCapacityFromLocomotive(
|
||||
fleet ?? { maxPullWeightTons: MAX_FALLBACK_WEIGHT, maxTrainLengthMeters: MAX_FALLBACK_LENGTH },
|
||||
wagonTypes,
|
||||
{
|
||||
maxTrainWeightTons: dto?.maxTrainWeightTons,
|
||||
maxTrainLengthMeters: dto?.maxTrainLengthMeters,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
maxWeightTons,
|
||||
maxLengthMeters,
|
||||
maxWeightTons: derived.maxWeightTons,
|
||||
maxLengthMeters: derived.maxLengthMeters,
|
||||
maxWagonsPerTrain: Math.floor(
|
||||
this.positiveNumber(
|
||||
dto?.maxWagonsPerTrain,
|
||||
row?.maxWagonsPerTrain != null
|
||||
? Number(row.maxWagonsPerTrain)
|
||||
: configured?.maxWagonsPerTrain ?? derivedWithoutLoco.maxWagonSlots,
|
||||
: configured?.maxWagonsPerTrain ?? derived.maxWagonSlots,
|
||||
),
|
||||
),
|
||||
max20ftContainerWeightTons: this.positiveNumber(
|
||||
undefined,
|
||||
Number(row?.max20ftContainerWeightTons) || DEFAULT_TRAIN_LIMITS.max20ftContainerWeightTons,
|
||||
),
|
||||
max20ftPairWeightDiffTons: this.positiveNumber(
|
||||
undefined,
|
||||
Number(row?.max20ftPairWeightDiffTons) ||
|
||||
DEFAULT_TRAIN_LIMITS.max20ftPairWeightDiffTons,
|
||||
),
|
||||
max20ftPairWeightDiffTons,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { BillingModule } from '../billing/billing.module';
|
||||
import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module';
|
||||
import { BookingsModule } from '../bookings/bookings.module';
|
||||
import { Container } from '../container-management/entities/container.entity';
|
||||
import { ExportsModule } from '../exports/exports.module';
|
||||
import { LocomotivesModule } from '../locomotives/locomotives.module';
|
||||
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
||||
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 { TrainSet } from '../train-sets/entities/train-set.entity';
|
||||
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 { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { WagonTypesModule } from '../wagon-types/wagon-types.module';
|
||||
@@ -68,7 +68,6 @@ import { ContractsModule } from '../contracts/contracts.module';
|
||||
UserTradeAccessModule,
|
||||
NotificationsModule,
|
||||
NotificationInboxModule,
|
||||
ExportsModule,
|
||||
LocomotivesModule,
|
||||
WagonTypesModule,
|
||||
TrainSetsModule,
|
||||
@@ -76,6 +75,7 @@ import { ContractsModule } from '../contracts/contracts.module';
|
||||
forwardRef(() => WarehousesModule),
|
||||
RuleEngineModule,
|
||||
forwardRef(() => ContractsModule),
|
||||
TrainCrewModule,
|
||||
],
|
||||
controllers: [TrainSchedulingController],
|
||||
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([]);
|
||||
});
|
||||
|
||||
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 units = expandBookingContainerUnits([booking]);
|
||||
const placements = units.map((unit, index) => ({
|
||||
@@ -182,11 +182,29 @@ describe('wagon-plan.util', () => {
|
||||
}));
|
||||
|
||||
const violations = validate20ftContainerRules(units, placements, {
|
||||
max20ftContainerWeightTons: 30,
|
||||
maxContainerWeightTonsByLineId: { [units[0]!.bookingContainerId]: 30 },
|
||||
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', () => {
|
||||
@@ -204,7 +222,6 @@ describe('wagon-plan.util', () => {
|
||||
}));
|
||||
|
||||
const violations = validate20ftContainerRules(units, placements, {
|
||||
max20ftContainerWeightTons: 30,
|
||||
max20ftPairWeightDiffTons: 10,
|
||||
});
|
||||
|
||||
|
||||
@@ -20,12 +20,17 @@ export type TrainLimitConfig = {
|
||||
maxWeightTons?: number;
|
||||
maxLengthMeters?: number;
|
||||
maxWagonsPerTrain?: number;
|
||||
max20ftContainerWeightTons?: number;
|
||||
max20ftPairWeightDiffTons?: number;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
units: ContainerUnitRow[],
|
||||
placements: ContainerPlacementInput[],
|
||||
rules?: ContainerPlacementRules,
|
||||
): string[] {
|
||||
const violations: string[] = [];
|
||||
const maxEach = rules?.max20ftContainerWeightTons;
|
||||
const capacityByLine = rules?.maxContainerWeightTonsByLineId;
|
||||
const maxDiff = rules?.max20ftPairWeightDiffTons;
|
||||
if (maxEach == null && maxDiff == null) return violations;
|
||||
if (capacityByLine == null && maxDiff == null) return violations;
|
||||
|
||||
const placementByUnit = new Map(
|
||||
placements.map((p) => [`${p.bookingContainerId}:${p.unitIndex}`, p]),
|
||||
@@ -837,15 +848,16 @@ export function validate20ftContainerRules(
|
||||
const weightsBySlot = new Map<number, number[]>();
|
||||
|
||||
for (const unit of units) {
|
||||
const sizeFt = unit.sizeFt ?? (unit.containerTypeCode.includes('40') ? 40 : 20);
|
||||
if (sizeFt >= 40) continue;
|
||||
|
||||
const maxEach = capacityByLine?.[unit.bookingContainerId];
|
||||
if (maxEach != null && unit.grossWeightTons > maxEach) {
|
||||
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}`);
|
||||
if (!placement?.sequenceNo) continue;
|
||||
|
||||
|
||||
@@ -472,6 +472,13 @@ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
"edr_freight_app:contracts:cancel",
|
||||
"Cancel a contract (terminal)",
|
||||
),
|
||||
// Add validity days to an EXPIRED contract the customer asked to extend and
|
||||
// put it back where it was. Sits on the same desk as suspend/cancel.
|
||||
perm(
|
||||
"a3000001-0001-4000-8000-00000000001d",
|
||||
"edr_freight_app:contracts:extend",
|
||||
"Extend an expired contract",
|
||||
),
|
||||
];
|
||||
|
||||
// Historical ids. EdrOrgSeeder no longer sends them — it upserts on `key` and
|
||||
@@ -1531,6 +1538,11 @@ export const TRAIN_CREW_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
"edr_freight_app:train_crew:delete",
|
||||
"Delete train crew member",
|
||||
),
|
||||
perm(
|
||||
"f5a00001-0001-4000-8000-000000000005",
|
||||
"edr_freight_app:train_crew:assign",
|
||||
"Assign train crew to a schedule",
|
||||
),
|
||||
];
|
||||
|
||||
// E'. Train-scheduling finer actions (augment existing view/manage)
|
||||
@@ -2119,6 +2131,7 @@ export const FREIGHT_PERMS = {
|
||||
clearanceDutyAdvise: "edr_freight_app:contracts:clearance_duty_advise",
|
||||
suspend: "edr_freight_app:contracts:suspend",
|
||||
cancel: "edr_freight_app:contracts:cancel",
|
||||
extend: "edr_freight_app:contracts:extend",
|
||||
editDocument: "edr_freight_app:contracts:edit_document",
|
||||
finalInvoiceRaise: "edr_freight_app:contracts:final_invoice_raise",
|
||||
finalInvoiceConfirm: "edr_freight_app:contracts:final_invoice_confirm",
|
||||
@@ -2364,6 +2377,7 @@ export const FREIGHT_PERMS = {
|
||||
create: "edr_freight_app:train_crew:create",
|
||||
update: "edr_freight_app:train_crew:update",
|
||||
delete: "edr_freight_app:train_crew:delete",
|
||||
assign: "edr_freight_app:train_crew:assign",
|
||||
},
|
||||
tracking: {
|
||||
view: "edr_freight_app:tracking:view",
|
||||
@@ -2952,6 +2966,8 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
// Terminal kill switch, granted alongside suspend on the same desk that
|
||||
// already rejects contracts and cancels bookings.
|
||||
FREIGHT_PERMS.contracts.cancel,
|
||||
// Validity extension of an expired contract, on customer request.
|
||||
FREIGHT_PERMS.contracts.extend,
|
||||
FREIGHT_PERMS.contracts.editDocument,
|
||||
...BOOKING_DESK_NOTIFICATION_KEYS,
|
||||
// Marketing follows up with the customer when a reviewer sends profile
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button, Group, Modal, Stack, Text, Textarea } from "@mantine/core";
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
Ban,
|
||||
CalendarClock,
|
||||
CalendarPlus,
|
||||
Check,
|
||||
Eye,
|
||||
// FilePen, // ponytail: back with the "Edit contract articles" button
|
||||
@@ -84,6 +94,9 @@ export function ContractActionsToolbar({
|
||||
const maySuspend = hasPermission(user, FREIGHT_PERMS.contracts.suspend);
|
||||
// Cancel is its own key — it is terminal, so it is NOT implied by suspend.
|
||||
const mayCancel = hasPermission(user, FREIGHT_PERMS.contracts.cancel);
|
||||
// Revive an EXPIRED contract by adding validity days — only after the
|
||||
// customer asked for it from the portal (the API enforces the same).
|
||||
const mayExtend = hasPermission(user, FREIGHT_PERMS.contracts.extend);
|
||||
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept");
|
||||
@@ -98,6 +111,9 @@ export function ContractActionsToolbar({
|
||||
const [resumeNote, setResumeNote] = useState("");
|
||||
const [cancelOpen, setCancelOpen] = useState(false);
|
||||
const [cancelReason, setCancelReason] = useState("");
|
||||
const [extendOpen, setExtendOpen] = useState(false);
|
||||
const [extendDays, setExtendDays] = useState<number>(30);
|
||||
const [extendNote, setExtendNote] = useState("");
|
||||
|
||||
// Shared by the suspended branch and the normal toolbar — both can cancel.
|
||||
const cancelModal = (
|
||||
@@ -183,7 +199,132 @@ export function ContractActionsToolbar({
|
||||
[validitySetting],
|
||||
);
|
||||
|
||||
if (["REJECTED", "CANCELLED", "EXPIRED", "CONTRACT_CLOSED"].includes(status)) {
|
||||
// Lapsed: nothing to do until the customer asks for more time from the
|
||||
// portal. Once they have, staff add days and the contract returns to the
|
||||
// status it held before it expired.
|
||||
if (status === "EXPIRED") {
|
||||
const requestedAt = contract.extensionRequestedAt
|
||||
? new Date(contract.extensionRequestedAt)
|
||||
: null;
|
||||
const currentEnd = contract.contractValidUntil
|
||||
? new Date(contract.contractValidUntil)
|
||||
: null;
|
||||
// Mirrors ContractTransitionService.extend: days count from today once the
|
||||
// contract has lapsed, from the current end date otherwise.
|
||||
const base =
|
||||
currentEnd && currentEnd.getTime() > Date.now() ? currentEnd : new Date();
|
||||
const newEnd = new Date(base);
|
||||
newEnd.setDate(newEnd.getDate() + Math.max(0, Math.floor(extendDays || 0)));
|
||||
const restoredStatus =
|
||||
contract.statusBeforeExpiry ??
|
||||
(contract.contractKind === "GENERAL" ? "CONTRACT_ACTIVE" : "FULLY_EXECUTED");
|
||||
const daysValid = Number.isInteger(extendDays) && extendDays >= 1;
|
||||
|
||||
return (
|
||||
<SectionCard icon={CalendarClock} title="Contract expired">
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
This contract's validity ended
|
||||
{currentEnd ? ` on ${currentEnd.toLocaleDateString()}` : ""}. New
|
||||
bookings are blocked until it is extended.
|
||||
</Text>
|
||||
{requestedAt ? (
|
||||
<>
|
||||
<Text size="sm">
|
||||
<b>Extension requested</b> by the customer on{" "}
|
||||
{requestedAt.toLocaleDateString()}.
|
||||
</Text>
|
||||
{contract.latestExtensionRequestNote && (
|
||||
<Text size="sm">
|
||||
<b>Reason:</b> {contract.latestExtensionRequestNote}
|
||||
</Text>
|
||||
)}
|
||||
{mayExtend ? (
|
||||
<Button
|
||||
fullWidth
|
||||
color="edr-green"
|
||||
leftSection={<CalendarPlus size={16} />}
|
||||
onClick={() => setExtendOpen(true)}
|
||||
>
|
||||
Extend contract
|
||||
</Button>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
You do not have permission to extend a contract.
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
The customer has not requested an extension. A contract can only
|
||||
be extended once they ask for it from the portal.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Modal
|
||||
opened={extendOpen}
|
||||
onClose={() => setExtendOpen(false)}
|
||||
title="Extend this contract?"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
Contract <b>{contract.reference}</b> gets the days below added to
|
||||
its validity, returns to <b>{restoredStatus}</b>, and the customer
|
||||
is notified. Bookings under it are possible again immediately.
|
||||
</Text>
|
||||
<NumberInput
|
||||
label="Days to add"
|
||||
min={1}
|
||||
max={3650}
|
||||
step={1}
|
||||
allowDecimal={false}
|
||||
value={extendDays}
|
||||
onChange={(v) => setExtendDays(typeof v === "number" ? v : Number(v) || 0)}
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
New validity end:{" "}
|
||||
<b>{daysValid ? newEnd.toLocaleDateString() : "—"}</b>
|
||||
</Text>
|
||||
<Textarea
|
||||
label="Note (optional)"
|
||||
placeholder="Shown to the customer with the extension…"
|
||||
autosize
|
||||
minRows={2}
|
||||
value={extendNote}
|
||||
onChange={(e) => setExtendNote(e.currentTarget.value)}
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={() => setExtendOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
disabled={!daysValid}
|
||||
loading={mutations.extend.isPending}
|
||||
onClick={() =>
|
||||
mutations.extend.mutate(
|
||||
{ days: extendDays, note: extendNote.trim() || undefined },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setExtendOpen(false);
|
||||
setExtendNote("");
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
Extend contract
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (["REJECTED", "CANCELLED", "CONTRACT_CLOSED"].includes(status)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -285,6 +285,7 @@ export const URL_CONSTANTS = {
|
||||
STAFF_CANCEL: (id: string) => `/contracts/${id}/staff/cancel`,
|
||||
SUSPEND: (id: string) => `/contracts/${id}/suspend`,
|
||||
RESUME: (id: string) => `/contracts/${id}/resume`,
|
||||
EXTEND: (id: string) => `/contracts/${id}/extend`,
|
||||
APPROVE_STEP: (id: string, stepId: string) =>
|
||||
`/contracts/${id}/approval-steps/${stepId}/approve`,
|
||||
REJECT_STEP: (id: string, stepId: string) =>
|
||||
|
||||
@@ -172,6 +172,14 @@ export function useContractMutations(contractId: string) {
|
||||
onError: (error) => toast.error(extractErrorMessage(error, "Failed to lift suspension")),
|
||||
});
|
||||
|
||||
const extend = useMutation({
|
||||
mutationFn: (payload: Freight.ExtendContractDto) =>
|
||||
contractsService.extend(contractId, payload),
|
||||
onSuccess: (data) =>
|
||||
onSuccess(data, `Contract extended — it is back to ${data.status}`),
|
||||
onError: (error) => toast.error(extractErrorMessage(error, "Failed to extend contract")),
|
||||
});
|
||||
|
||||
const approveStep = useMutation({
|
||||
// The server derives the required role from the step itself, so the client
|
||||
// does not send one.
|
||||
@@ -280,6 +288,7 @@ export function useContractMutations(contractId: string) {
|
||||
cancelByStaff,
|
||||
suspend,
|
||||
resume,
|
||||
extend,
|
||||
approveStep,
|
||||
rejectStep,
|
||||
generateContract,
|
||||
|
||||
@@ -97,6 +97,7 @@ export const FREIGHT_PERMS = {
|
||||
clearanceDjActions: "edr_freight_app:contracts:clearance_dj_actions",
|
||||
suspend: "edr_freight_app:contracts:suspend",
|
||||
cancel: "edr_freight_app:contracts:cancel",
|
||||
extend: "edr_freight_app:contracts:extend",
|
||||
editDocument: "edr_freight_app:contracts:edit_document",
|
||||
finalInvoiceRaise: "edr_freight_app:contracts:final_invoice_raise",
|
||||
finalInvoiceConfirm: "edr_freight_app:contracts:final_invoice_confirm",
|
||||
@@ -271,6 +272,7 @@ export const FREIGHT_PERMS = {
|
||||
create: "edr_freight_app:train_crew:create",
|
||||
update: "edr_freight_app:train_crew:update",
|
||||
delete: "edr_freight_app:train_crew:delete",
|
||||
assign: "edr_freight_app:train_crew:assign",
|
||||
},
|
||||
tracking: {
|
||||
view: "edr_freight_app:tracking:view",
|
||||
|
||||
@@ -1,23 +1,612 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Loader,
|
||||
Select,
|
||||
Stack,
|
||||
Stepper,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Plus,
|
||||
ShieldCheck,
|
||||
Train,
|
||||
Trash2,
|
||||
Users,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import {
|
||||
trainCrewService,
|
||||
trainCrewRoleLabel,
|
||||
type TrainCrewMember,
|
||||
type TrainCrewRole,
|
||||
} from "@/services/trainCrew.service";
|
||||
import {
|
||||
DUTY_ROLE_OPTIONS,
|
||||
trainCrewAssignmentService,
|
||||
type CorridorYard,
|
||||
type CrewDutyRole,
|
||||
} from "@/services/trainCrewAssignment.service";
|
||||
|
||||
/**
|
||||
* Train crew assignment for one schedule.
|
||||
* One driver row being built. The leg (two yards) and the duty role are
|
||||
* properties of THIS run, not of the person.
|
||||
*/
|
||||
interface DriverRow {
|
||||
key: string;
|
||||
crewMemberId: string | null;
|
||||
fromYardId: string | null;
|
||||
toYardId: string | null;
|
||||
dutyRole: CrewDutyRole | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A new row pre-filled with the schedule's own endpoints — the common case is
|
||||
* one driver over the whole route, and staff narrow it from there.
|
||||
*/
|
||||
const newDriverRow = (yards: CorridorYard[]): DriverRow => ({
|
||||
key: `driver-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
crewMemberId: null,
|
||||
fromYardId: yards[0]?.id ?? null,
|
||||
toYardId: yards[yards.length - 1]?.id ?? null,
|
||||
dutyRole: null,
|
||||
});
|
||||
|
||||
/**
|
||||
* Assign a train crew to one schedule — ITLMS Rolling Stock §1.1 and §1.2.
|
||||
*
|
||||
* Intentionally blank: the assignment rules — crew counts per role, the driver
|
||||
* pairing cases, and which corridor segment each driver covers — are still to
|
||||
* be specified, so only the route and header exist so far.
|
||||
* Crew sizes are free-form: operations add as many drivers, police, technicians
|
||||
* or specialists as a given run needs, rather than filling the fixed pairing
|
||||
* cases of §2. What is still enforced is what makes a run coherent — every
|
||||
* driver carries a leg and duty role, one Primary per leg, Djibouti drivers
|
||||
* confined to Dire Dawa and eastward (§1.1), and the specialized crew the cargo
|
||||
* actually demands (§1.2).
|
||||
*
|
||||
* A partial crew always saves: §1.2 puts the hard gate at departure, so this
|
||||
* page and the dispatch guard call the same server-side validator.
|
||||
*/
|
||||
export default function ScheduleCrewPage() {
|
||||
const { scheduleId = "" } = useParams();
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
const canAssign = hasPermission(user, FREIGHT_PERMS.trainCrew.assign);
|
||||
|
||||
const [step, setStep] = useState(0);
|
||||
const [drivers, setDrivers] = useState<DriverRow[]>([]);
|
||||
/** Support and specialist picks, keyed by role. */
|
||||
const [supportIds, setSupportIds] = useState<Record<string, Array<string | null>>>({});
|
||||
|
||||
const { data: crew, isLoading } = useQuery({
|
||||
queryKey: ["schedule-crew", scheduleId],
|
||||
queryFn: async () => (await trainCrewAssignmentService.get(scheduleId)).data,
|
||||
enabled: Boolean(scheduleId),
|
||||
});
|
||||
|
||||
const { data: roster = [] } = useQuery({
|
||||
queryKey: ["train-crew", "roster-all"],
|
||||
queryFn: async () => {
|
||||
const res = await trainCrewService.getAll({ limit: 200, status: "ACTIVE" });
|
||||
return res.data.data;
|
||||
},
|
||||
});
|
||||
|
||||
// Seed from what is already saved, so reopening resumes rather than restarts.
|
||||
useEffect(() => {
|
||||
if (!crew) return;
|
||||
const driverRows: DriverRow[] = [];
|
||||
const support: Record<string, Array<string | null>> = {};
|
||||
for (const a of crew.assignments) {
|
||||
if (a.role === "TRAIN_DRIVER") {
|
||||
driverRows.push({
|
||||
key: a.id,
|
||||
crewMemberId: a.crewMemberId,
|
||||
fromYardId: a.fromYardId ?? null,
|
||||
toYardId: a.toYardId ?? null,
|
||||
dutyRole: a.dutyRole ?? null,
|
||||
});
|
||||
} else {
|
||||
support[a.role] = [...(support[a.role] ?? []), a.crewMemberId];
|
||||
}
|
||||
}
|
||||
setDrivers(driverRows);
|
||||
setSupportIds(support);
|
||||
}, [crew]);
|
||||
|
||||
const corridorYards = crew?.corridorYards ?? [];
|
||||
|
||||
const yardOptions = useMemo(
|
||||
() => corridorYards.map((y) => ({ value: y.id, label: y.label })),
|
||||
[corridorYards],
|
||||
);
|
||||
|
||||
/**
|
||||
* §1.1 — a leg is open to a Djibouti driver only when both ends sit at or
|
||||
* beyond Dire Dawa. Position along the corridor answers this without naming
|
||||
* station pairs, so a handover anywhere east of Dire Dawa works.
|
||||
*/
|
||||
const legOpenToDjibouti = (leg: {
|
||||
fromYardId: string | null;
|
||||
toYardId: string | null;
|
||||
}) => {
|
||||
const boundary = corridorYards.find((y) => /dire dawa/i.test(y.label));
|
||||
const from = corridorYards.find((y) => y.id === leg.fromYardId);
|
||||
const to = corridorYards.find((y) => y.id === leg.toYardId);
|
||||
// An unknown boundary or half-built leg is not a breach — the server-side
|
||||
// validator reports the incomplete leg on its own.
|
||||
if (!boundary || !from || !to) return true;
|
||||
return Math.min(from.displayOrder, to.displayOrder) >= boundary.displayOrder;
|
||||
};
|
||||
|
||||
const byRole = useMemo(() => {
|
||||
const map = new Map<TrainCrewRole, TrainCrewMember[]>();
|
||||
for (const m of roster) {
|
||||
map.set(m.role, [...(map.get(m.role) ?? []), m]);
|
||||
}
|
||||
return map;
|
||||
}, [roster]);
|
||||
|
||||
/** Everyone already picked — nobody may hold two seats on one run. */
|
||||
const takenIds = useMemo(() => {
|
||||
const ids = [
|
||||
...drivers.map((d) => d.crewMemberId),
|
||||
...Object.values(supportIds).flat(),
|
||||
].filter(Boolean) as string[];
|
||||
return new Set(ids);
|
||||
}, [drivers, supportIds]);
|
||||
|
||||
const memberOptions = (
|
||||
role: TrainCrewRole,
|
||||
currentValue: string | null,
|
||||
leg?: { fromYardId: string | null; toYardId: string | null },
|
||||
) =>
|
||||
(byRole.get(role) ?? [])
|
||||
.filter((m) => {
|
||||
// §1.1 territorial boundary: a Djibouti driver never appears on a leg
|
||||
// they may not work. Enforced by making the invalid choice unavailable
|
||||
// rather than by rejecting it afterwards.
|
||||
if (leg && m.nationality === "DJIBOUTIAN" && !legOpenToDjibouti(leg)) {
|
||||
return false;
|
||||
}
|
||||
return m.id === currentValue || !takenIds.has(m.id);
|
||||
})
|
||||
.map((m) => ({
|
||||
value: m.id,
|
||||
label: `${m.firstName} ${m.lastName} · ${m.nationality === "ETHIOPIAN" ? "ET" : "DJ"}`,
|
||||
}));
|
||||
|
||||
const setDriver = (key: string, patch: Partial<DriverRow>) =>
|
||||
setDrivers((prev) =>
|
||||
prev.map((row) => {
|
||||
if (row.key !== key) return row;
|
||||
const next = { ...row, ...patch };
|
||||
// Moving the leg can invalidate the person already chosen — clear
|
||||
// rather than silently persist a territorial breach.
|
||||
const legMoved = patch.fromYardId !== undefined || patch.toYardId !== undefined;
|
||||
if (legMoved && next.crewMemberId) {
|
||||
const member = roster.find((m) => m.id === next.crewMemberId);
|
||||
if (member?.nationality === "DJIBOUTIAN" && !legOpenToDjibouti(next)) {
|
||||
next.crewMemberId = null;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}),
|
||||
);
|
||||
|
||||
const setSupportCount = (role: TrainCrewRole, count: number) =>
|
||||
setSupportIds((prev) => ({
|
||||
...prev,
|
||||
[role]: Array.from({ length: count }, (_, i) => prev[role]?.[i] ?? null),
|
||||
}));
|
||||
|
||||
const buildPayload = () => {
|
||||
const assignments: Array<{
|
||||
crewMemberId: string;
|
||||
role: TrainCrewRole;
|
||||
dutyRole?: CrewDutyRole;
|
||||
fromYardId?: string;
|
||||
toYardId?: string;
|
||||
}> = [];
|
||||
for (const row of drivers) {
|
||||
if (row.crewMemberId) {
|
||||
assignments.push({
|
||||
crewMemberId: row.crewMemberId,
|
||||
role: "TRAIN_DRIVER",
|
||||
...(row.dutyRole ? { dutyRole: row.dutyRole } : {}),
|
||||
...(row.fromYardId ? { fromYardId: row.fromYardId } : {}),
|
||||
...(row.toYardId ? { toYardId: row.toYardId } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const [role, ids] of Object.entries(supportIds)) {
|
||||
for (const id of ids) {
|
||||
if (id) assignments.push({ crewMemberId: id, role: role as TrainCrewRole });
|
||||
}
|
||||
}
|
||||
return { assignments };
|
||||
};
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => trainCrewAssignmentService.save(scheduleId, buildPayload()),
|
||||
onSuccess: (res) => {
|
||||
const validation = res.data;
|
||||
toast({
|
||||
title: validation.complete
|
||||
? "Crew saved — composition complete"
|
||||
: "Crew saved (still incomplete)",
|
||||
description: validation.complete
|
||||
? undefined
|
||||
: "The train cannot be dispatched until every rule passes.",
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: ["schedule-crew", scheduleId] });
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const message = (error as { response?: { data?: { message?: unknown } } })
|
||||
?.response?.data?.message;
|
||||
toast({
|
||||
title: "Could not save crew",
|
||||
description: Array.isArray(message)
|
||||
? message.join(", ")
|
||||
: typeof message === "string"
|
||||
? message
|
||||
: "The request failed. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Group justify="center" py="xl">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const demand = crew?.demand;
|
||||
const specialized = crew?.requirements.specialized ?? [];
|
||||
const technicianRule = crew?.requirements.technician;
|
||||
const validation = crew?.validation;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Assign Train Crew"
|
||||
subtitle="Add as many drivers and crew as this run needs"
|
||||
backTo={`/dashboard/operations/train-scheduling-v2/${scheduleId}`}
|
||||
meta={
|
||||
validation ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color={validation.complete ? "green" : "orange"}
|
||||
leftSection={
|
||||
validation.complete ? <CheckCircle2 size={12} /> : <AlertTriangle size={12} />
|
||||
}
|
||||
>
|
||||
{validation.complete ? "Ready to dispatch" : "Incomplete"}
|
||||
</Badge>
|
||||
) : null
|
||||
}
|
||||
action={
|
||||
canAssign ? (
|
||||
<Button
|
||||
onClick={() => saveMutation.mutate()}
|
||||
loading={saveMutation.isPending}
|
||||
color="edr-green"
|
||||
>
|
||||
Save Crew
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<Stepper active={step} onStepClick={setStep} mt="md" size="sm">
|
||||
<Stepper.Step label="Drivers" description="Any number">
|
||||
<Stack gap="md" mt="lg">
|
||||
<Text size="sm" c="dimmed">
|
||||
Add a row per driver and set the leg they work — any two yards on this
|
||||
schedule's route, so a handover at Feto or Meiso is as easy as one at
|
||||
Dire Dawa. Djibouti drivers are offered only on legs from Dire Dawa
|
||||
eastward.
|
||||
</Text>
|
||||
|
||||
{drivers.length === 0 ? (
|
||||
<Alert color="gray">No drivers added yet.</Alert>
|
||||
) : (
|
||||
drivers.map((row, index) => (
|
||||
<Card key={row.key} withBorder padding="md">
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={28} radius="md" variant="light" color="edr-green">
|
||||
<Train size={14} />
|
||||
</ThemeIcon>
|
||||
<Text fw={600} size="sm">
|
||||
Driver {index + 1}
|
||||
</Text>
|
||||
</Group>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
aria-label="Remove driver"
|
||||
onClick={() =>
|
||||
setDrivers((prev) => prev.filter((d) => d.key !== row.key))
|
||||
}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<Group grow align="flex-start" wrap="wrap">
|
||||
<Select
|
||||
label="From yard"
|
||||
placeholder="Start of this leg"
|
||||
searchable
|
||||
data={yardOptions}
|
||||
value={row.fromYardId}
|
||||
onChange={(val) => setDriver(row.key, { fromYardId: val })}
|
||||
/>
|
||||
<Select
|
||||
label="To yard"
|
||||
placeholder="End of this leg"
|
||||
searchable
|
||||
data={yardOptions}
|
||||
value={row.toYardId}
|
||||
onChange={(val) => setDriver(row.key, { toYardId: val })}
|
||||
/>
|
||||
<Select
|
||||
label="Duty role"
|
||||
placeholder="Select a duty role"
|
||||
data={DUTY_ROLE_OPTIONS}
|
||||
value={row.dutyRole}
|
||||
onChange={(val) =>
|
||||
setDriver(row.key, { dutyRole: (val as CrewDutyRole) ?? null })
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
label="Driver"
|
||||
placeholder="Select a driver"
|
||||
searchable
|
||||
clearable
|
||||
data={memberOptions("TRAIN_DRIVER", row.crewMemberId, row)}
|
||||
value={row.crewMemberId}
|
||||
onChange={(val) => setDriver(row.key, { crewMemberId: val })}
|
||||
/>
|
||||
</Group>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<Plus size={16} />}
|
||||
onClick={() => setDrivers((prev) => [...prev, newDriverRow(corridorYards)])}
|
||||
>
|
||||
Add Driver
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step label="Support crew" description="Police, technical, cargo">
|
||||
<Stack gap="lg" mt="lg">
|
||||
<SupportSection
|
||||
role="FEDERAL_POLICE"
|
||||
icon={<ShieldCheck size={16} />}
|
||||
color="blue"
|
||||
title="Security detail"
|
||||
hint="Add as many federal police as this run needs"
|
||||
values={supportIds.FEDERAL_POLICE ?? []}
|
||||
onCount={(n) => setSupportCount("FEDERAL_POLICE", n)}
|
||||
onPick={(i, val) =>
|
||||
setSupportIds((prev) => ({
|
||||
...prev,
|
||||
FEDERAL_POLICE: (prev.FEDERAL_POLICE ?? []).map((v, idx) =>
|
||||
idx === i ? val : v,
|
||||
),
|
||||
}))
|
||||
}
|
||||
options={(value) => memberOptions("FEDERAL_POLICE", value)}
|
||||
/>
|
||||
|
||||
<SupportSection
|
||||
role="TECHNICIAN"
|
||||
icon={<Wrench size={16} />}
|
||||
color="orange"
|
||||
title="Technical maintenance crew"
|
||||
hint={technicianRule?.reason ?? "Optional technical maintenance crew"}
|
||||
alert={
|
||||
demand?.hasBadOrderWagon
|
||||
? "A defective wagon is attached, so at least one technician is mandatory."
|
||||
: undefined
|
||||
}
|
||||
values={supportIds.TECHNICIAN ?? []}
|
||||
onCount={(n) => setSupportCount("TECHNICIAN", n)}
|
||||
onPick={(i, val) =>
|
||||
setSupportIds((prev) => ({
|
||||
...prev,
|
||||
TECHNICIAN: (prev.TECHNICIAN ?? []).map((v, idx) => (idx === i ? val : v)),
|
||||
}))
|
||||
}
|
||||
options={(value) => memberOptions("TECHNICIAN", value)}
|
||||
/>
|
||||
|
||||
{specialized.length ? (
|
||||
specialized.map((rule) => (
|
||||
<SupportSection
|
||||
key={rule.role}
|
||||
role={rule.role}
|
||||
icon={<Users size={16} />}
|
||||
color="grape"
|
||||
title={trainCrewRoleLabel(rule.role)}
|
||||
hint={rule.reason}
|
||||
values={supportIds[rule.role] ?? []}
|
||||
onCount={(n) => setSupportCount(rule.role, n)}
|
||||
onPick={(i, val) =>
|
||||
setSupportIds((prev) => ({
|
||||
...prev,
|
||||
[rule.role]: (prev[rule.role] ?? []).map((v, idx) =>
|
||||
idx === i ? val : v,
|
||||
),
|
||||
}))
|
||||
}
|
||||
options={(value) => memberOptions(rule.role, value)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Alert color="gray">
|
||||
No specialized cargo detected on this train — no reefer, HAZMAT, break-bulk
|
||||
or livestock crew is required.
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Completed>
|
||||
<Stack gap="md" mt="lg">
|
||||
<Card withBorder padding="lg">
|
||||
<Text fw={600} mb="sm">
|
||||
Composition checklist
|
||||
</Text>
|
||||
{validation?.complete ? (
|
||||
<Group gap="xs">
|
||||
<ThemeIcon size={22} radius="xl" color="green" variant="light">
|
||||
<CheckCircle2 size={14} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm">Every rule passes — this train may be dispatched.</Text>
|
||||
</Group>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
{validation?.issues.map((issue) => (
|
||||
<Group key={`${issue.code}-${issue.message}`} gap="xs" wrap="nowrap">
|
||||
<ThemeIcon size={22} radius="xl" color="orange" variant="light">
|
||||
<AlertTriangle size={14} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm">{issue.message}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Card>
|
||||
{validation?.runType ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
Derived run type:{" "}
|
||||
<Text span fw={600}>
|
||||
{validation.runType === "LONG_RUN" ? "Long run" : "Short run"}
|
||||
</Text>
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Stepper.Completed>
|
||||
</Stepper>
|
||||
|
||||
<Group justify="space-between" mt="xl">
|
||||
<Button variant="light" disabled={step === 0} onClick={() => setStep((s) => s - 1)}>
|
||||
Back
|
||||
</Button>
|
||||
<Button variant="light" disabled={step > 1} onClick={() => setStep((s) => s + 1)}>
|
||||
Next
|
||||
</Button>
|
||||
</Group>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/** A crew block: add/remove rows freely, each naming one person. */
|
||||
function SupportSection({
|
||||
role,
|
||||
icon,
|
||||
color,
|
||||
title,
|
||||
hint,
|
||||
alert,
|
||||
values,
|
||||
onCount,
|
||||
onPick,
|
||||
options,
|
||||
}: {
|
||||
role: TrainCrewRole;
|
||||
icon: React.ReactNode;
|
||||
color: string;
|
||||
title: string;
|
||||
hint: string;
|
||||
alert?: string;
|
||||
values: Array<string | null>;
|
||||
onCount: (count: number) => void;
|
||||
onPick: (index: number, value: string | null) => void;
|
||||
options: (currentValue: string | null) => Array<{ value: string; label: string }>;
|
||||
}) {
|
||||
return (
|
||||
<Card withBorder padding="lg">
|
||||
<Group gap="sm" mb="md">
|
||||
<ThemeIcon size={32} radius="md" variant="light" color={color}>
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={600}>{title}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{hint}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{alert ? (
|
||||
<Alert color="orange" icon={<AlertTriangle size={16} />} mb="md">
|
||||
{alert}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Stack gap="sm">
|
||||
{values.map((value, index) => (
|
||||
<Group key={index} align="flex-end" wrap="nowrap">
|
||||
<Select
|
||||
label={`${trainCrewRoleLabel(role)} ${index + 1}`}
|
||||
placeholder="Select a crew member"
|
||||
searchable
|
||||
clearable
|
||||
data={options(value)}
|
||||
value={value}
|
||||
onChange={(val) => onPick(index, val)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
aria-label="Remove"
|
||||
onClick={() => {
|
||||
const next = values.filter((_, i) => i !== index);
|
||||
onCount(next.length);
|
||||
next.forEach((v, i) => onPick(i, v));
|
||||
}}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<Plus size={14} />}
|
||||
onClick={() => onCount(values.length + 1)}
|
||||
style={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
Add {trainCrewRoleLabel(role)}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -303,6 +303,14 @@ export const contractsService = {
|
||||
resume: (id: string, note?: string) =>
|
||||
postContract<Freight.IContract>(C.RESUME(id), { note }),
|
||||
|
||||
/**
|
||||
* Add validity days to an EXPIRED contract the customer asked to extend; it
|
||||
* returns to the status it held before it lapsed. The API refuses it while
|
||||
* no customer request is pending.
|
||||
*/
|
||||
extend: (id: string, payload: Freight.ExtendContractDto) =>
|
||||
postContract<Freight.IContract>(C.EXTEND(id), payload),
|
||||
|
||||
/**
|
||||
* Approve the next pending step. The server resolves the step's required role
|
||||
* and authorizes against it — the client never declares its own role.
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { api as apiClient } from '../auth/http';
|
||||
import type { TrainCrewMember, TrainCrewRole } from './trainCrew.service';
|
||||
|
||||
export type CrewDutyRole = 'PRIMARY' | 'ASSISTANT' | 'BENCH_RELIEF';
|
||||
|
||||
/** A yard on the schedule's route, ordered along the corridor. */
|
||||
export interface CorridorYard {
|
||||
id: string;
|
||||
label: string;
|
||||
country: string;
|
||||
displayOrder: number;
|
||||
}
|
||||
|
||||
export interface CrewAssignment {
|
||||
id: string;
|
||||
trainScheduleId: string;
|
||||
crewMemberId: string;
|
||||
role: TrainCrewRole;
|
||||
dutyRole?: CrewDutyRole | null;
|
||||
fromYardId?: string | null;
|
||||
toYardId?: string | null;
|
||||
status: string;
|
||||
crewMember?: TrainCrewMember;
|
||||
}
|
||||
|
||||
/** What the consist and its cargo demand (§1.2), detected server-side. */
|
||||
export interface CrewDemand {
|
||||
hasBadOrderWagon: boolean;
|
||||
badOrderWagonLabels: string[];
|
||||
hasReeferCargo: boolean;
|
||||
reeferSources: string[];
|
||||
hasHazmatCargo: boolean;
|
||||
hazmatSources: string[];
|
||||
hasBreakBulkCargo: boolean;
|
||||
breakBulkSources: string[];
|
||||
hasLivestockCargo: boolean;
|
||||
livestockSources: string[];
|
||||
}
|
||||
|
||||
export interface CrewRequirement {
|
||||
role: TrainCrewRole;
|
||||
/** Hard floor — 0 unless the cargo or consist forces someone aboard. */
|
||||
min: number;
|
||||
/** The count §1.2 suggests. A hint only; nothing enforces it. */
|
||||
typical: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface CrewValidation {
|
||||
complete: boolean;
|
||||
issues: Array<{ code: string; message: string }>;
|
||||
runType: 'SHORT_RUN' | 'LONG_RUN' | null;
|
||||
}
|
||||
|
||||
export interface ScheduleCrewResponse {
|
||||
scheduleId: string;
|
||||
assignments: CrewAssignment[];
|
||||
/** Yards a driver leg may use — bounded by the schedule's own endpoints. */
|
||||
corridorYards: CorridorYard[];
|
||||
demand: CrewDemand;
|
||||
requirements: {
|
||||
technician: CrewRequirement;
|
||||
specialized: CrewRequirement[];
|
||||
};
|
||||
validation: CrewValidation;
|
||||
}
|
||||
|
||||
export interface SaveCrewPayload {
|
||||
assignments: Array<{
|
||||
crewMemberId: string;
|
||||
role: TrainCrewRole;
|
||||
dutyRole?: CrewDutyRole;
|
||||
fromYardId?: string;
|
||||
toYardId?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const base = (scheduleId: string) => `/train-schedules/${scheduleId}/crew`;
|
||||
|
||||
export const trainCrewAssignmentService = {
|
||||
get: (scheduleId: string) =>
|
||||
apiClient.get<ScheduleCrewResponse>(base(scheduleId)),
|
||||
eligibleDrivers: (scheduleId: string, fromYardId: string, toYardId: string) =>
|
||||
apiClient.get<TrainCrewMember[]>(
|
||||
`${base(scheduleId)}/eligible-drivers?fromYardId=${fromYardId}&toYardId=${toYardId}`,
|
||||
),
|
||||
corridorYards: (scheduleId: string) =>
|
||||
apiClient.get<CorridorYard[]>(`${base(scheduleId)}/corridor-yards`),
|
||||
save: (scheduleId: string, payload: SaveCrewPayload) =>
|
||||
apiClient.put<CrewValidation>(base(scheduleId), payload),
|
||||
};
|
||||
|
||||
export const DUTY_ROLE_OPTIONS: Array<{ value: CrewDutyRole; label: string }> = [
|
||||
{ value: 'PRIMARY', label: 'Primary Driver' },
|
||||
{ value: 'ASSISTANT', label: 'Assistant Driver' },
|
||||
{ value: 'BENCH_RELIEF', label: 'Bench/Relief Driver' },
|
||||
];
|
||||
|
||||
export const dutyRoleLabel = (dutyRole: CrewDutyRole): string =>
|
||||
({
|
||||
PRIMARY: 'Primary Driver',
|
||||
ASSISTANT: 'Assistant Driver',
|
||||
BENCH_RELIEF: 'Bench/Relief Driver',
|
||||
})[dutyRole];
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Group,
|
||||
Modal,
|
||||
Text,
|
||||
Textarea,
|
||||
ThemeIcon,
|
||||
type ButtonProps,
|
||||
} from "@mantine/core";
|
||||
@@ -15,6 +16,7 @@ import { useNavigate } from "react-router-dom";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { bookingDocNoun } from "@/pages/bookings/clearance/bookingNextAction";
|
||||
import { deriveContractCustomerAction } from "./deriveContractCustomerAction";
|
||||
|
||||
@@ -61,6 +63,18 @@ export function ContractCustomerAction({
|
||||
);
|
||||
}
|
||||
|
||||
if (action.type === "requestExtension") {
|
||||
return (
|
||||
<RequestExtensionButton
|
||||
contract={action.contract}
|
||||
label={action.label}
|
||||
icon={action.icon}
|
||||
size={size}
|
||||
listStyle={listStyle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = action.icon;
|
||||
const variant = action.primary ? "filled" : "light";
|
||||
|
||||
@@ -226,6 +240,156 @@ export function InitiateBookingButton({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask EDR to extend an EXPIRED contract. Nothing changes on the contract until
|
||||
* staff add validity days on their side — this only records the request (and
|
||||
* an optional reason) and notifies the contract desk.
|
||||
*/
|
||||
export function RequestExtensionButton({
|
||||
contract,
|
||||
label = "Request extension",
|
||||
icon: Icon,
|
||||
size = "xs",
|
||||
listStyle = false,
|
||||
fullWidth = false,
|
||||
}: {
|
||||
contract: Freight.IContract;
|
||||
label?: string;
|
||||
icon: LucideIcon;
|
||||
size?: ButtonProps["size"];
|
||||
listStyle?: boolean;
|
||||
fullWidth?: boolean;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [note, setNote] = useState("");
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
contractsService.requestExtension(contract.id, note.trim() || undefined),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.contracts.get.queryKey({ id: contract.id }),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: api.contracts.list.queryKey() });
|
||||
queryClient.invalidateQueries({ queryKey: api.contracts.listMy.queryKey() });
|
||||
toast.success(
|
||||
"Extension requested — EDR will review it and extend the contract.",
|
||||
);
|
||||
setConfirmOpen(false);
|
||||
setNote("");
|
||||
},
|
||||
onError: (e: Error) => {
|
||||
const data = (
|
||||
e as { response?: { data?: { message?: string | string[] } } }
|
||||
).response?.data;
|
||||
const message = Array.isArray(data?.message)
|
||||
? data.message.join(", ")
|
||||
: data?.message;
|
||||
toast.error(message || e.message || "Could not request the extension");
|
||||
},
|
||||
});
|
||||
|
||||
const validUntil = contract.contractValidUntil
|
||||
? new Date(contract.contractValidUntil).toLocaleDateString()
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
opened={confirmOpen}
|
||||
onClose={() => {
|
||||
if (!mutation.isPending) setConfirmOpen(false);
|
||||
}}
|
||||
centered
|
||||
radius="lg"
|
||||
size="md"
|
||||
closeOnClickOutside={!mutation.isPending}
|
||||
closeOnEscape={!mutation.isPending}
|
||||
withCloseButton={!mutation.isPending}
|
||||
title={
|
||||
<Group gap={10} wrap="nowrap">
|
||||
<ThemeIcon variant="light" color="edr-green" radius="md" size={34}>
|
||||
<Icon size={18} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700}>Request a contract extension?</Text>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Text size="sm" c="dimmed">
|
||||
Contract{" "}
|
||||
<Text span fw={700} c="#10202F">
|
||||
{contract.reference}
|
||||
</Text>{" "}
|
||||
{validUntil ? `expired on ${validUntil}` : "has expired"}. EDR will
|
||||
review your request and add validity days; the contract becomes active
|
||||
again as soon as they do, and you will be notified.
|
||||
</Text>
|
||||
<Textarea
|
||||
mt="md"
|
||||
label="Reason (optional)"
|
||||
placeholder="Why do you need this contract extended?"
|
||||
autosize
|
||||
minRows={2}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.currentTarget.value)}
|
||||
disabled={mutation.isPending}
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm" mt="lg">
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
onClick={() => setConfirmOpen(false)}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Icon size={16} />}
|
||||
loading={mutation.isPending}
|
||||
onClick={() => mutation.mutate()}
|
||||
>
|
||||
Send request
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
<Button
|
||||
size={size}
|
||||
radius="md"
|
||||
h={listStyle ? 34 : undefined}
|
||||
variant="filled"
|
||||
color="edr-green"
|
||||
fullWidth={fullWidth}
|
||||
leftSection={<Icon size={15} />}
|
||||
loading={mutation.isPending}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmOpen(true);
|
||||
}}
|
||||
styles={
|
||||
listStyle
|
||||
? {
|
||||
root: {
|
||||
fontWeight: 600,
|
||||
fontSize: 13,
|
||||
paddingInline: 14,
|
||||
whiteSpace: "nowrap" as const,
|
||||
boxShadow: "0 1px 2px rgba(14,163,113,0.25)",
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
fw={listStyle ? undefined : 700}
|
||||
fz={listStyle ? undefined : 13}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Action column cell: doc button + primary customer action. */
|
||||
export function ContractCustomerActionCell({
|
||||
contract,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
CalendarPlus,
|
||||
Eye,
|
||||
FileSignature,
|
||||
PackagePlus,
|
||||
@@ -32,6 +33,14 @@ export type ContractCustomerAction =
|
||||
label: string;
|
||||
primary: boolean;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
| {
|
||||
/** Ask EDR to extend an EXPIRED contract — mutation with a reason, not navigation. */
|
||||
type: "requestExtension";
|
||||
contract: Freight.IContract;
|
||||
label: string;
|
||||
primary: boolean;
|
||||
icon: LucideIcon;
|
||||
};
|
||||
|
||||
/** Single best customer action for a contract row (list / home). */
|
||||
@@ -73,6 +82,27 @@ export function deriveContractCustomerAction(
|
||||
};
|
||||
}
|
||||
|
||||
// Lapsed: the customer's one move is to ask EDR for more time. Once asked,
|
||||
// the row just views until staff extend it (the API refuses a second ask).
|
||||
if (contract.status === "EXPIRED") {
|
||||
if (!contract.extensionRequestedAt) {
|
||||
return {
|
||||
type: "requestExtension",
|
||||
contract,
|
||||
label: "Request extension",
|
||||
primary: true,
|
||||
icon: CalendarPlus,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "navigate",
|
||||
label: "View",
|
||||
to: `/contracts/${id}`,
|
||||
primary: false,
|
||||
icon: Eye,
|
||||
};
|
||||
}
|
||||
|
||||
// Paying happens from the booking row/detail — contract rows never show
|
||||
// "Pay now" (payable bookings fall through to the next action here).
|
||||
if (
|
||||
|
||||
@@ -157,6 +157,7 @@ export const URL_CONSTANTS = {
|
||||
CONTRACT_SEND_SIGNING_OTP: (id: string) =>
|
||||
`/api/contracts/${id}/contract/send-signing-otp`,
|
||||
RENEW: (id: string) => `/api/contracts/${id}/renew`,
|
||||
EXTENSION_REQUEST: (id: string) => `/api/contracts/${id}/extension-request`,
|
||||
CANCEL: (id: string) => `/api/contracts/${id}/cancel`,
|
||||
CLEARANCE: (id: string) => `/api/contracts/${id}/clearance`,
|
||||
CLEARANCE_DOCUMENTS: (id: string) =>
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import {
|
||||
ArrowLeft,
|
||||
CalendarClock,
|
||||
CalendarPlus,
|
||||
CheckCircle2,
|
||||
ChevronRight,
|
||||
Download,
|
||||
@@ -62,7 +63,10 @@ import {
|
||||
PaymentBadge,
|
||||
SchedulingCell,
|
||||
} from "@/pages/bookings/booking-display";
|
||||
import { InitiateBookingButton } from "@/components/customer-actions/ContractCustomerAction";
|
||||
import {
|
||||
InitiateBookingButton,
|
||||
RequestExtensionButton,
|
||||
} from "@/components/customer-actions/ContractCustomerAction";
|
||||
import { formatRateUnit } from "./new-contract-form/unit-rates";
|
||||
import { formatAmount } from "./new-shipment-form/total";
|
||||
import { bookingDocNoun } from "@/pages/bookings/clearance/bookingNextAction";
|
||||
@@ -391,6 +395,11 @@ export default function ContractDetailPage() {
|
||||
// Self-clearance import/export (ONE_TIME or GENERAL): one-click bare booking
|
||||
// instance — the per-booking clearance runs first, so no window gate here.
|
||||
const canInitiateBooking = bookingAction.kind === "initiate" && isContainer;
|
||||
// Lapsed contract: the customer asks for more time once; staff then add
|
||||
// validity days and the contract comes back. The API refuses a second ask.
|
||||
const extensionPending = Boolean(contract.extensionRequestedAt);
|
||||
const canRequestExtension =
|
||||
contract.status === "EXPIRED" && !extensionPending;
|
||||
|
||||
return (
|
||||
<Box style={{ padding: "28px 32px 40px" }}>
|
||||
@@ -479,6 +488,13 @@ export default function ContractDetailPage() {
|
||||
size="md"
|
||||
/>
|
||||
)}
|
||||
{canRequestExtension && (
|
||||
<RequestExtensionButton
|
||||
contract={contract}
|
||||
icon={CalendarPlus}
|
||||
size="md"
|
||||
/>
|
||||
)}
|
||||
{canBookShipment &&
|
||||
(bookingWindowOpen ? (
|
||||
<Button
|
||||
@@ -534,6 +550,28 @@ export default function ContractDetailPage() {
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{contract.status === "EXPIRED" && (
|
||||
<Alert
|
||||
color={extensionPending ? "blue" : "red"}
|
||||
radius="md"
|
||||
title={
|
||||
extensionPending
|
||||
? "Extension requested — awaiting EDR"
|
||||
: "Contract expired"
|
||||
}
|
||||
>
|
||||
{extensionPending
|
||||
? `You asked EDR to extend this contract on ${new Date(
|
||||
contract.extensionRequestedAt!,
|
||||
).toLocaleDateString()}. Once EDR adds validity days it becomes active again and you will be notified.`
|
||||
: `This contract's validity ended${
|
||||
contract.contractValidUntil
|
||||
? ` on ${new Date(contract.contractValidUntil).toLocaleDateString()}`
|
||||
: ""
|
||||
}. New shipments cannot be booked under it. Request an extension and EDR will add validity days to make it active again.`}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{contract.status === "SUSPENDED" && (
|
||||
<Alert color="orange" radius="md" title="Contract suspended by EDR">
|
||||
{contract.latestSuspensionNote
|
||||
|
||||
@@ -290,6 +290,19 @@ export const contractsService = {
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Ask EDR to extend the validity of an EXPIRED contract. Staff then add days
|
||||
* on their side and the contract becomes active again. One pending request
|
||||
* at a time — the API rejects a second one.
|
||||
*/
|
||||
requestExtension: async (
|
||||
id: string,
|
||||
note?: string,
|
||||
): Promise<Freight.IContract> => {
|
||||
const { data } = await client.post(C.EXTENSION_REQUEST(id), { note });
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Cancel own contract so a fresh one can be requested on the same lane. The
|
||||
* API rejects it while any shipment on the contract is still live.
|
||||
|
||||
@@ -844,6 +844,22 @@ export interface IContract extends BaseEntity {
|
||||
* SUSPENDED). Shown to both staff and customer.
|
||||
*/
|
||||
latestSuspensionNote?: string | null;
|
||||
/**
|
||||
* Status the contract held when it lapsed to EXPIRED; restored when staff
|
||||
* extend it. Null on rows that expired before this was tracked.
|
||||
*/
|
||||
statusBeforeExpiry?: ContractStatus | null;
|
||||
/**
|
||||
* When the customer asked for this EXPIRED contract's validity to be
|
||||
* extended. Null when never asked, and cleared again once staff extend it.
|
||||
* Staff can only extend while it is set.
|
||||
*/
|
||||
extensionRequestedAt?: string | null;
|
||||
/**
|
||||
* Body of the latest EXTENSION_REQUESTED review note (detail response only,
|
||||
* while the request is pending). Tells staff why the customer wants more time.
|
||||
*/
|
||||
latestExtensionRequestNote?: string | null;
|
||||
/**
|
||||
* Bookings on this contract that are not in a terminal state (detail response
|
||||
* only). The portal's cancel action is blocked while this is > 0.
|
||||
@@ -1157,3 +1173,17 @@ export interface RenewContractDto {
|
||||
/** Reference of the prior contract being renewed. */
|
||||
previousContractReference?: string;
|
||||
}
|
||||
|
||||
/** Customer asks EDR to extend the validity of their EXPIRED contract. */
|
||||
export interface RequestContractExtensionDto {
|
||||
/** Why the customer needs more time (optional, shown to staff). */
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/** Staff extend an EXPIRED contract the customer asked to extend. */
|
||||
export interface ExtendContractDto {
|
||||
/** Days to add — from today once lapsed, else from the current end date. */
|
||||
days: number;
|
||||
/** Optional note recorded with the extension and shown to the customer. */
|
||||
note?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user