diff --git a/apps/edr-freight-api/src/config/app.config.ts b/apps/edr-freight-api/src/config/app.config.ts index 8e03ac3f0..f88661950 100644 --- a/apps/edr-freight-api/src/config/app.config.ts +++ b/apps/edr-freight-api/src/config/app.config.ts @@ -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). diff --git a/apps/edr-freight-api/src/migrations/3860000000000-TrainCrewAssignments.ts b/apps/edr-freight-api/src/migrations/3860000000000-TrainCrewAssignments.ts new file mode 100644 index 000000000..04dba29ba --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3860000000000-TrainCrewAssignments.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 { + 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 { + await queryRunner.query(`DROP TABLE IF EXISTS freight.train_crew_assignments`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3870000000000-DropCrewingCase.ts b/apps/edr-freight-api/src/migrations/3870000000000-DropCrewingCase.ts new file mode 100644 index 000000000..2feaef0b4 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3870000000000-DropCrewingCase.ts @@ -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 { + 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 { + 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') + ) + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3880000000000-CrewLegYards.ts b/apps/edr-freight-api/src/migrations/3880000000000-CrewLegYards.ts new file mode 100644 index 000000000..37607802d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3880000000000-CrewLegYards.ts @@ -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 { + 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 { + 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 + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3920000000000-ContractExtensionRequest.ts b/apps/edr-freight-api/src/migrations/3920000000000-ContractExtensionRequest.ts new file mode 100644 index 000000000..d51ae6ddd --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3920000000000-ContractExtensionRequest.ts @@ -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 { + 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 { + 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`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/contracts/contract-extension.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-extension.spec.ts new file mode 100644 index 000000000..da3fdf04f --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-extension.spec.ts @@ -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 => + ({ + 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(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts index 10b6afd37..8395d28c5 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts @@ -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( diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index 5d8c24060..bdacdb69d 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -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 { + 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 { + 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 { const source = await this.contractsService.findById(contractId); diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 7cf7a8398..1df3eea0e 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -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({ diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index 5b5e73021..9f025f350 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -135,7 +135,9 @@ export class ContractsRepository extends BaseRepository { 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 { 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 }) diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index dc9f8ac7b..83e8f8952 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -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 = diff --git a/apps/edr-freight-api/src/modules/contracts/dto/extend-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/extend-contract.dto.ts new file mode 100644 index 000000000..7c82947c8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/dto/extend-contract.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract-review-note.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract-review-note.entity.ts index 5b64fe5f7..693c0edbe 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract-review-note.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract-review-note.entity.ts @@ -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]; diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts index b776333b9..fd3b54b58 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts @@ -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 diff --git a/apps/edr-freight-api/src/modules/train-crew/crew-composition.rules.spec.ts b/apps/edr-freight-api/src/modules/train-crew/crew-composition.rules.spec.ts new file mode 100644 index 000000000..8b589ef65 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-crew/crew-composition.rules.spec.ts @@ -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 = { + 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); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-crew/crew-composition.rules.ts b/apps/edr-freight-api/src/modules/train-crew/crew-composition.rules.ts new file mode 100644 index 000000000..9c2a04838 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-crew/crew-composition.rules.ts @@ -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; + 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(); + 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(); + 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(); + 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(); + 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, +): '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, + }; +}; diff --git a/apps/edr-freight-api/src/modules/train-crew/dto/save-crew-assignments.dto.ts b/apps/edr-freight-api/src/modules/train-crew/dto/save-crew-assignments.dto.ts new file mode 100644 index 000000000..4938f6260 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-crew/dto/save-crew-assignments.dto.ts @@ -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[]; +} diff --git a/apps/edr-freight-api/src/modules/train-crew/entities/train-crew-assignment.entity.ts b/apps/edr-freight-api/src/modules/train-crew/entities/train-crew-assignment.entity.ts new file mode 100644 index 000000000..3591a45a8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-crew/entities/train-crew-assignment.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/train-crew/train-crew-assignment.controller.ts b/apps/edr-freight-api/src/modules/train-crew/train-crew-assignment.controller.ts new file mode 100644 index 000000000..97e012a5a --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-crew/train-crew-assignment.controller.ts @@ -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); + } +} diff --git a/apps/edr-freight-api/src/modules/train-crew/train-crew-assignment.service.ts b/apps/edr-freight-api/src/modules/train-crew/train-crew-assignment.service.ts new file mode 100644 index 000000000..df1c1321f --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-crew/train-crew-assignment.service.ts @@ -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, + @InjectRepository(TrainCrewMember) + private readonly memberRepo: Repository, + private readonly dataSource: DataSource, + ) {} + + /** Every assignment on a schedule, with the roster member joined. */ + async listForSchedule(scheduleId: string): Promise { + 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 { + 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 { + 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( + 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 { + 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 { + 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 { + 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 { + 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', +}); diff --git a/apps/edr-freight-api/src/modules/train-crew/train-crew.module.ts b/apps/edr-freight-api/src/modules/train-crew/train-crew.module.ts index e242204c3..8e9d01da9 100644 --- a/apps/edr-freight-api/src/modules/train-crew/train-crew.module.ts +++ b/apps/edr-freight-api/src/modules/train-crew/train-crew.module.ts @@ -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 {} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts index 1c7cd11ca..1416d1d16 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts index 50debc6c0..1037364f2 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts @@ -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', diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts index f61593442..03235a983 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts @@ -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>; + } + ).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({}); + }); + }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index 03d75da18..bfbeafed6 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -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 = { - 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> { + 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 = {}; + 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 { + 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> { 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, }; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts index fdbc504b5..0ce67e959 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -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: [ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-list-workbook.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-list-workbook.util.spec.ts new file mode 100644 index 000000000..58bf11a3b --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-list-workbook.util.spec.ts @@ -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 => ({ + 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']); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-list-workbook.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-list-workbook.util.ts new file mode 100644 index 000000000..adfd04075 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-list-workbook.util.ts @@ -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; wagons: Set } + >(); + const ordinalByGroupWagon = new Map(); + 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 = { 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 { + 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()); +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.spec.ts index a9741b476..f8b831bb0 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.spec.ts @@ -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, }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts index b4e87d398..b31b5c7e2 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts @@ -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; 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(); 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; diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 1c27b0db8..2eb64913d 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -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 diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx index be70ffe4d..d1508d475 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx @@ -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(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 ( + + + + This contract's validity ended + {currentEnd ? ` on ${currentEnd.toLocaleDateString()}` : ""}. New + bookings are blocked until it is extended. + + {requestedAt ? ( + <> + + Extension requested by the customer on{" "} + {requestedAt.toLocaleDateString()}. + + {contract.latestExtensionRequestNote && ( + + Reason: {contract.latestExtensionRequestNote} + + )} + {mayExtend ? ( + + ) : ( + + You do not have permission to extend a contract. + + )} + + ) : ( + + The customer has not requested an extension. A contract can only + be extended once they ask for it from the portal. + + )} + + + setExtendOpen(false)} + title="Extend this contract?" + centered + > + + + Contract {contract.reference} gets the days below added to + its validity, returns to {restoredStatus}, and the customer + is notified. Bookings under it are possible again immediately. + + setExtendDays(typeof v === "number" ? v : Number(v) || 0)} + /> + + New validity end:{" "} + {daysValid ? newEnd.toLocaleDateString() : "—"} + +