diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 58fb60b18..d6a39bfed 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -93,6 +93,9 @@ FAYDA_PRIVATE_KEY_BASE64= FAYDA_REDIRECT_URI=http://localhost:3001/api/fayda/verification/complete # OAuth redirect_uri for WEB clients. Defaults to FAYDA_REDIRECT_URI when unset. FAYDA_WEB_REDIRECT_URI=http://localhost:3000/callback +# OAuth redirect_uri for the customer portal (its own origin — must also be +# registered with eSignet). Defaults to FAYDA_WEB_REDIRECT_URI when unset. +FAYDA_PORTAL_REDIRECT_URI=http://localhost:5173/callback CLIENT_ASSERTION_TYPE=urn:ietf:params:oauth:client-assertion-type:jwt-bearer FAYDA_SCOPE=openid profile email phone address FAYDA_ACR_VALUES=mosip:idp:acr:generated-code diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 0531d056b..20bfa1bad 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -58,7 +58,7 @@ "@nestjs/typeorm": "^11.0.1", "@nestjs/websockets": "^11.1.27", "@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz", - "@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.12.tgz", + "@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.15.tgz", "amqp-connection-manager": "^5.0.0", "amqplib": "^2.0.1", "axios": "^1.16.1", diff --git a/apps/edr-freight-api/src/config/fayda.config.ts b/apps/edr-freight-api/src/config/fayda.config.ts index a25289159..30525dd01 100644 --- a/apps/edr-freight-api/src/config/fayda.config.ts +++ b/apps/edr-freight-api/src/config/fayda.config.ts @@ -15,7 +15,7 @@ export interface FaydaJwk { qi?: string; } -export type FaydaPlatform = 'WEB' | 'MOBILE'; +export type FaydaPlatform = 'WEB' | 'MOBILE' | 'PORTAL'; export interface FaydaConfig { enabled: boolean; @@ -25,8 +25,10 @@ export interface FaydaConfig { userInfoEndpoint: string; /** OAuth redirect_uri sent to eSignet for MOBILE clients. */ redirectUri: string; - /** OAuth redirect_uri sent to eSignet for WEB clients. Falls back to `redirectUri`. */ + /** OAuth redirect_uri sent to eSignet for WEB (backoffice) clients. Falls back to `redirectUri`. */ webRedirectUri: string; + /** OAuth redirect_uri sent to eSignet for the customer portal. Falls back to `webRedirectUri`. */ + portalRedirectUri: string; privateJwk: FaydaJwk; scope: string; acrValues: string; @@ -77,6 +79,7 @@ export default registerAs('fayda', (): FaydaConfig => { const sessionTtl = Number.parseInt(process.env.FAYDA_SESSION_TTL_MINUTES ?? '10', 10); const redirectUri = process.env.FAYDA_REDIRECT_URI ?? ''; const webRedirectUri = process.env.FAYDA_WEB_REDIRECT_URI || redirectUri; + const portalRedirectUri = process.env.FAYDA_PORTAL_REDIRECT_URI || webRedirectUri; if (!enabled) { return { enabled: false, @@ -86,6 +89,7 @@ export default registerAs('fayda', (): FaydaConfig => { userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT ?? '', redirectUri, webRedirectUri, + portalRedirectUri, privateJwk: { kty: 'RSA', n: '', e: '', d: '' }, scope, acrValues, @@ -117,6 +121,7 @@ export default registerAs('fayda', (): FaydaConfig => { userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT!, redirectUri, webRedirectUri, + portalRedirectUri, privateJwk: decodePrivateJwk(process.env.FAYDA_PRIVATE_KEY_BASE64!), scope, acrValues, diff --git a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts index 8b8b92f09..07f4d25d3 100644 --- a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts @@ -157,10 +157,14 @@ export class ContractViewModelBuilder { pricing, rateSchedule, signatures, - canSignCustomer: - booking.status === 'CONTRACT_READY' && !hasCustomer, - canSignStaff: - booking.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff, + // Government contracts are generated at creation and signable at any + // time, in any order — no status gate, no customer-first sequencing. + canSignCustomer: booking.isGovernment + ? !hasCustomer + : booking.status === 'CONTRACT_READY' && !hasCustomer, + canSignStaff: booking.isGovernment + ? !hasStaff + : booking.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff, hasContractDocument: hasContractFile, hasCustomerSignature: hasCustomer, hasStaffSignature: hasStaff, diff --git a/apps/edr-freight-api/src/migrations/3050000000000-MergeDuplicateSebetaYards.ts b/apps/edr-freight-api/src/migrations/3050000000000-MergeDuplicateSebetaYards.ts new file mode 100644 index 000000000..c8a2c5722 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3050000000000-MergeDuplicateSebetaYards.ts @@ -0,0 +1,67 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Two active "Sebeta" yards existed (code LEGACY_DEST label "Sebeta", and code + * SEBETA label "sebeta") — rates and routes pointed at one or the other, so a + * rate configured against one never matched a contract routed via the other. + * Merge them: keep the row all rates/distances/facilities reference + * (LEGACY_DEST), repoint every yard reference from the duplicate to it, retire + * the duplicate, and give the survivor the clean SEBETA code. Then make + * duplicate active yard labels/codes impossible at the DB level. + */ +export class MergeDuplicateSebetaYards3050000000000 implements MigrationInterface { + name = "MergeDuplicateSebetaYards3050000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DO $$ + DECLARE + survivor uuid; + dupe uuid; + col record; + BEGIN + SELECT id INTO survivor FROM freight.yards + WHERE code = 'LEGACY_DEST' AND lower(trim(label)) = 'sebeta' AND deleted_at IS NULL; + SELECT id INTO dupe FROM freight.yards + WHERE code = 'SEBETA' AND deleted_at IS NULL; + IF survivor IS NULL OR dupe IS NULL OR survivor = dupe THEN + RETURN; + END IF; + + -- Every yard-referencing column in the schema, so rows created between + -- authoring and running this migration are repointed too. + FOR col IN + SELECT table_name, column_name FROM information_schema.columns + WHERE table_schema = 'freight' + AND table_name <> 'yards' + AND (column_name LIKE '%yard_id%' OR column_name LIKE '%station_id%') + LOOP + EXECUTE format( + 'UPDATE freight.%I SET %I = $1 WHERE %I = $2', + col.table_name, col.column_name, col.column_name + ) USING survivor, dupe; + END LOOP; + + UPDATE freight.yards + SET code = 'SEBETA@merged', label = 'sebeta@merged', deleted_at = now() + WHERE id = dupe; + UPDATE freight.yards SET code = 'SEBETA', label = 'Sebeta' WHERE id = survivor; + END $$; + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_yards_label_active" + ON freight.yards (lower(trim(label))) WHERE deleted_at IS NULL + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_yards_code_active" + ON freight.yards (lower(trim(code))) WHERE deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Data repair — not reversible. The uniqueness indexes are the new invariant. + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_yards_label_active"`); + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_yards_code_active"`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3060000000000-AddExportPaymentWindow.ts b/apps/edr-freight-api/src/migrations/3060000000000-AddExportPaymentWindow.ts new file mode 100644 index 000000000..c55745126 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3060000000000-AddExportPaymentWindow.ts @@ -0,0 +1,47 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Export bookings get their own pay window, separately tunable from import: + * - global_rules.export_payment_window_minutes — global default for EXPORT + * (payment_window_minutes keeps governing IMPORT/DOMESTIC). + * - train_schedules.rule_payment_window_minutes — per-schedule override; until + * now the DTO accepted paymentWindowMinutes but only folded it into the + * reopen-delay sum, so the override never reached the actual pay window. + * - bookings.requested_train_schedule_id — the export train the customer picked + * at day-commit; pickExportSchedule honors it instead of earliest-first. + * - bookings.payment_reminder_sent_at — marks the one pre-deadline pay + * reminder so the 10s window tick doesn't re-send it. + */ +export class AddExportPaymentWindow3060000000000 implements MigrationInterface { + name = 'AddExportPaymentWindow3060000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.train_scheduling_global_rules ADD COLUMN IF NOT EXISTS export_payment_window_minutes int NOT NULL DEFAULT 60;`, + ); + await queryRunner.query( + `ALTER TABLE freight.train_schedules ADD COLUMN IF NOT EXISTS rule_payment_window_minutes int;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS requested_train_schedule_id uuid;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS payment_reminder_sent_at timestamptz;`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS payment_reminder_sent_at;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS requested_train_schedule_id;`, + ); + await queryRunner.query( + `ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS rule_payment_window_minutes;`, + ); + await queryRunner.query( + `ALTER TABLE freight.train_scheduling_global_rules DROP COLUMN IF EXISTS export_payment_window_minutes;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/3070000000000-AddBulkTotalWeightTons.ts b/apps/edr-freight-api/src/migrations/3070000000000-AddBulkTotalWeightTons.ts new file mode 100644 index 000000000..f2c50ec8d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3070000000000-AddBulkTotalWeightTons.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Break-bulk (PER_ITEM) bookings store their item count in + * cargo_total_weight_vgm, so the actual tonnage was never captured — wagon + * allocation divided an item COUNT by a tons capacity and under-allocated + * (400 machines ÷ 69T wagon read as 6 wagons instead of 12). New column holds + * the real total weight in tons for PER_ITEM cargo; null for PER_TON bulk and + * container bookings. + */ +export class AddBulkTotalWeightTons3070000000000 implements MigrationInterface { + name = 'AddBulkTotalWeightTons3070000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS bulk_total_weight_tons numeric(12,3);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS bulk_total_weight_tons;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/3080000000000-BackfillDireDawaMilestone.ts b/apps/edr-freight-api/src/migrations/3080000000000-BackfillDireDawaMilestone.ts new file mode 100644 index 000000000..78c44c0ce --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3080000000000-BackfillDireDawaMilestone.ts @@ -0,0 +1,65 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Several DCT (DORALEH) → GMP (KALITY) routes are missing the Dire Dawa stop + * in their milestone list. The corridor budget builds its per-leg edges from + * route_milestones, so on those routes a DCT→Dire Dawa or Dire Dawa→GMP + * booking cannot resolve its own leg and conservatively occupies the WHOLE + * route — per-leg wagon reuse (a wagon freed at Dire Dawa reloading for GMP) + * silently degrades to train-wide accounting. + * + * Insert the Dire Dawa milestone at sequence 2 on every active DORALEH→KALITY + * route with a stop list that lacks it, shifting later stops down. Matched by + * yard CODE so the repair is portable across environments. Idempotent: routes + * already carrying Dire Dawa are untouched. + */ +export class BackfillDireDawaMilestone3080000000000 implements MigrationInterface { + name = "BackfillDireDawaMilestone3080000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DO $$ + DECLARE + dire uuid; + r record; + BEGIN + SELECT id INTO dire FROM freight.yards + WHERE code = 'DIRE_DAWA' AND deleted_at IS NULL; + IF dire IS NULL THEN + RETURN; + END IF; + + FOR r IN + SELECT rt.id + FROM freight.routes rt + JOIN freight.yards o ON o.id = rt.origin_yard_id AND o.code = 'DORALEH' + JOIN freight.yards d ON d.id = rt.destination_yard_id AND d.code = 'KALITY' + WHERE rt.deleted_at IS NULL + AND EXISTS (SELECT 1 FROM freight.route_milestones m + WHERE m.route_id = rt.id AND m.deleted_at IS NULL) + AND NOT EXISTS (SELECT 1 FROM freight.route_milestones m + WHERE m.route_id = rt.id AND m.yard_id = dire + AND m.deleted_at IS NULL) + LOOP + -- Two-phase shift: uq_route_milestones_route_sequence isn't deferrable, + -- so a direct +1 UPDATE can collide mid-scan (seq 2 -> 3 while seq 3 still live). + -- Route through negative sequence_no first to avoid any interim collision. + -- Soft-deleted rows shift too: the constraint counts them, so a dead row + -- left at a target sequence would still collide. + UPDATE freight.route_milestones + SET sequence_no = -sequence_no + WHERE route_id = r.id AND sequence_no >= 2; + UPDATE freight.route_milestones + SET sequence_no = -sequence_no + 1 + WHERE route_id = r.id AND sequence_no < 0; + INSERT INTO freight.route_milestones (route_id, yard_id, sequence_no) + VALUES (r.id, dire, 2); + END LOOP; + END $$; + `); + } + + public async down(): Promise { + // Data repair — not reversible. + } +} diff --git a/apps/edr-freight-api/src/migrations/3090000000000-AddSavedSignatureStamp.ts b/apps/edr-freight-api/src/migrations/3090000000000-AddSavedSignatureStamp.ts new file mode 100644 index 000000000..3beafb6bf --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3090000000000-AddSavedSignatureStamp.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddSavedSignatureStamp3090000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.saved_signatures + ADD COLUMN IF NOT EXISTS stamp_file_id UUID NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.saved_signatures + DROP COLUMN IF EXISTS stamp_file_id; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3100000000000-PromoteDraftSchedulesToScheduled.ts b/apps/edr-freight-api/src/migrations/3100000000000-PromoteDraftSchedulesToScheduled.ts new file mode 100644 index 000000000..f5a916bd3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3100000000000-PromoteDraftSchedulesToScheduled.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * The draft/finalize phase is abolished: train schedules are created SCHEDULED + * and the Finalize button is gone from the backoffice. Promote every surviving + * DRAFT schedule so it stays reachable (dispatch requires SCHEDULED and there + * is no manual promotion path anymore). Idempotent; one-way — the original + * DRAFT set is not recorded, so down() cannot restore it. + */ +export class PromoteDraftSchedulesToScheduled3100000000000 implements MigrationInterface { + name = "PromoteDraftSchedulesToScheduled3100000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `UPDATE freight.train_schedules + SET status = 'SCHEDULED' + WHERE status = 'DRAFT' + AND deleted_at IS NULL`, + ); + } + + public async down(): Promise { + // One-way data promotion — nothing to restore. + } +} diff --git a/apps/edr-freight-api/src/migrations/3110000000000-AddYardToScheduleWagonAdjustmentLogs.ts b/apps/edr-freight-api/src/migrations/3110000000000-AddYardToScheduleWagonAdjustmentLogs.ts new file mode 100644 index 000000000..0e1d13175 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3110000000000-AddYardToScheduleWagonAdjustmentLogs.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Consist adjustments can now happen mid-route (train standing at a stop), so + * each history row records WHERE it happened. Nullable — rows written before + * this column simply have no yard. + */ +export class AddYardToScheduleWagonAdjustmentLogs3110000000000 implements MigrationInterface { + name = "AddYardToScheduleWagonAdjustmentLogs3110000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.schedule_wagon_adjustment_logs + ADD COLUMN IF NOT EXISTS yard_id uuid`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.schedule_wagon_adjustment_logs + DROP COLUMN IF EXISTS yard_id`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts index e7682879b..dbf2bd4fd 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -470,3 +470,78 @@ describe("BillingService.issuePayable", () => { expect(manager.update).not.toHaveBeenCalled(); }); }); + +describe("BillingService — CAC Bank (OTP debit)", () => { + const openInvoice = { + id: "inv-1", + status: Freight.InvoiceStatus.Pending, + source: Freight.InvoiceSource.Booking, + sourceId: "booking-1", + type: "PREPAID", + invoiceNumber: "INV-20260101-00001", + currency: "USD", + balanceAmount: 500, + totalAmount: 500, + paymentId: "intent-1", + dueAt: null, + }; + + const build = (payment: Record) => { + const repo = { + findOne: jest.fn().mockResolvedValue(openInvoice), + update: jest.fn().mockResolvedValue(undefined), + }; + const service = new BillingService( + { getRepository: () => repo } as never, + {} as never, + {} as never, + makeEvents() as never, + payment as never, + {} as never, + {} as never, + ); + return { service, repo }; + }; + + it("rejects a CAC Bank charge with no payer mobile before calling the gateway", async () => { + const initiate = jest.fn(); + const { service } = build({ initiate }); + + await expect( + service.payInvoice("inv-1", { method: "CAC_BANK" }), + ).rejects.toThrow(/payerAccount/); + expect(initiate).not.toHaveBeenCalled(); + }); + + it("does not settle an OTP intent at initiate — the payer still has to confirm", async () => { + const handlePaymentEvent = jest.fn(); + const { service } = build({ + initiate: jest.fn().mockResolvedValue({ + intentId: "intent-1", + immediateSuccess: false, + response: { + intentId: "intent-1", + status: "REQUIRES_ACTION", + clientAction: { type: "COLLECT_OTP", providerOrderId: "cac-1" }, + }, + }), + handlePaymentEvent, + }); + + await service.payInvoice("inv-1", { + method: "CAC_BANK", + payerAccount: "77123456", + }); + + expect(handlePaymentEvent).not.toHaveBeenCalled(); + }); + + it("confirms the OTP against the intent stamped on the invoice", async () => { + const confirmOtp = jest.fn().mockResolvedValue({ status: "SUCCEEDED" }); + const { service } = build({ confirmOtp }); + + await service.confirmInvoiceOtp("inv-1", "123456"); + + expect(confirmOtp).toHaveBeenCalledWith("intent-1", "123456"); + }); +}); diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 2a08be24b..850791f02 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -12,7 +12,7 @@ import { DataSource, EntityManager, In } from "typeorm"; import { CompaniesService } from "../companies/companies.service"; import { PaymentService } from "../payment/payment.service"; -import { InitiateResponseDto } from "../payment/payments.dto"; +import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto"; import { InvoiceDocumentModel, InvoiceDocumentService, @@ -373,6 +373,34 @@ export class BillingService { return this.payInvoice(id, opts); } + /** + * Submit the CAC Bank OTP for one of the customer's own invoices + * (ownership-checked). Settlement of the invoice happens inside the payment + * service when the OTP succeeds. + */ + async confirmInvoiceOtpForUser( + id: string, + userId: string, + otp: string, + ): Promise { + await this.findByIdForUser(id, userId); + return this.confirmInvoiceOtp(id, otp); + } + + /** OTP confirmation by invoice id — the intent is the one stamped at initiate. */ + async confirmInvoiceOtp( + invoiceId: string, + otp: string, + ): Promise { + const invoice = await this.dataSource + .getRepository(Invoice) + .findOne({ where: { id: invoiceId } }); + if (!invoice?.paymentId) { + throw new NotFoundException("No payment to confirm for this invoice"); + } + return this.payment.confirmOtp(invoice.paymentId, otp); + } + /** Sealed invoice PDF for one of the customer's own invoices (ownership-checked). */ async documentForUser( id: string, @@ -1004,6 +1032,17 @@ export class BillingService { * never fire before the link exists. Throws when the invoice is not found or * not in an open/payable status. */ + /** + * Settlement check before expiring a payable order (reconcile-before-expire): + * live-queries the gateway for any settled intent on the source order. Kept + * on billing so the domain never talks to the payment service directly. + */ + reconcilePayable( + sourceId: string, + ): Promise<{ paid: boolean; unverifiable: boolean }> { + return this.payment.reconcileShipment(sourceId); + } + async payInvoice( invoiceId: string, opts: { @@ -1024,11 +1063,39 @@ export class BillingService { ); } + // A booking's PREPAID invoice is only payable inside its pay window — + // `dueAt` mirrors booking.paymentDeadline (issuePayable at reserve time). + // Blocking INITIATION here is what makes the deadline real: a payment + // STARTED before this gate but settling late is still honored by the + // expire-time gateway reconcile. Other invoice types keep dueAt display-only. + if ( + invoice.source === Freight.InvoiceSource.Booking && + invoice.type === "PREPAID" && + invoice.dueAt && + invoice.dueAt.getTime() <= Date.now() + ) { + throw new BadRequestException( + "The payment window for this booking has closed — the reserved wagons " + + "were released. Please book again.", + ); + } + const amountDue = Number(invoice.balanceAmount ?? invoice.totalAmount); if (!(amountDue > 0)) { throw new BadRequestException("Invoice has no outstanding balance."); } + // CAC Bank is an OTP debit — the bank SMSes the code to this number, so it is + // required up front (the payment service rejects it otherwise, as a 502 here). + if ( + (opts.method ?? "").toUpperCase() === "CAC_BANK" && + !opts.payerAccount?.trim() + ) { + throw new BadRequestException( + "payerAccount (mobile number) is required for CAC Bank", + ); + } + const result = await this.payment.initiate({ referenceId: invoice.sourceId, source: invoice.source, @@ -1059,9 +1126,14 @@ export class BillingService { .update({ id: invoice.id }, { paymentId: result.intentId }); // Settlement is driven by the payment API (webhook/outbox → payment.succeeded); - // billing must not simulate it. Kept for local demos only. NEVER for CBE_BILL — - // its bill must stay open until CBE actually settles it via /cbe/payment. - if (!result.immediateSuccess && opts.method !== "CBE_BILL") { + // billing must not simulate it. Kept commented for local demos only. + // An OTP intent (CAC Bank) is NOT paid yet — the payer still has to enter the + // code — so the demo shortcut must never fire for it. + if ( + !result.immediateSuccess && + result.response.clientAction?.type !== "COLLECT_OTP" && + opts.method !== "CBE_BILL" + ) { await this.payment.handlePaymentEvent({ eventType: "payment.succeeded", eventId: `demo-${result.intentId}`, diff --git a/apps/edr-freight-api/src/modules/billing/dto/pay-invoice.dto.ts b/apps/edr-freight-api/src/modules/billing/dto/pay-invoice.dto.ts index c29160ab7..354dd004e 100644 --- a/apps/edr-freight-api/src/modules/billing/dto/pay-invoice.dto.ts +++ b/apps/edr-freight-api/src/modules/billing/dto/pay-invoice.dto.ts @@ -1,5 +1,13 @@ -import { ApiPropertyOptional } from "@nestjs/swagger"; -import { IsIn, IsOptional, IsString } from "class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsIn, IsNotEmpty, IsOptional, IsString } from "class-validator"; + +/** OTP submitted for a COLLECT_OTP provider (CAC Bank). */ +export class ConfirmOtpDto { + @ApiProperty({ description: "One-time password SMSed by the bank." }) + @IsString() + @IsNotEmpty() + otp!: string; +} /** Gateway options for paying an invoice from the customer portal. */ export class PayInvoiceDto { diff --git a/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts b/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts index 94e917754..981233df0 100644 --- a/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts @@ -18,7 +18,7 @@ import { } from "../../common/resolve-auth-user-id"; import { sendPdf } from "./billing.controller"; import { BillingService } from "./billing.service"; -import { PayInvoiceDto } from "./dto/pay-invoice.dto"; +import { ConfirmOtpDto, PayInvoiceDto } from "./dto/pay-invoice.dto"; /** * Customer-facing billing endpoints. Unlike {@link BillingController} (admin, @@ -96,4 +96,20 @@ export class PortalBillingController { failureUrl: dto.failureUrl, }); } + + @Post("my-invoices/:id/confirm") + @ApiOperation({ + summary: "Confirm an OTP-debit payment (CAC Bank) for one of the customer's invoices", + }) + confirmOtp( + @Param("id", ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUserPayload, + @Body() dto: ConfirmOtpDto, + ) { + return this.billingService.confirmInvoiceOtpForUser( + id, + resolveAuthUserId(user), + dto.otp, + ); + } } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts index 5297f3d57..095928bb3 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts @@ -112,15 +112,9 @@ export class BookingContractService { const templateKey = this.templateResolver.resolve(booking); const summary = this.buildContractSummary(booking); - // PDF rendering (Puppeteer/Chromium) is best-effort and must NOT block the contract - // from becoming ready — the document is (re)rendered lazily on view/download. - try { - await this.upsertContractPdf(bookingId, booking.reference, templateKey); - } catch (err) { - this.logger.warn( - `Contract PDF deferred for ${booking.reference}: ${err}. It will render on view/download once Chromium is available.`, - ); - } + // No eager PDF render here: streamContract re-renders the document on every + // view/download, so rendering now only adds a Chromium launch (seconds, or a + // 60s asset-load hang) inside the staff-accept request. const now = new Date(); const updated = await this.bookingsRepository.update(bookingId, { @@ -132,6 +126,31 @@ export class BookingContractService { return updated!; } + /** + * Government bookings skip the whole customer contract flow (approve → + * CONTRACT_READY → sign chain): their contract is stamped server-side at + * creation/expedite WITHOUT touching booking status — the booking is already + * PAID/allocatable and the contract can be signed at any time. Idempotent. + */ + async generateContractForGovernment(bookingId: string): Promise { + const booking = await this.requireBooking(bookingId); + if (!booking.isGovernment || booking.contractGeneratedAt) return; + const templateKey = this.templateResolver.resolve(booking); + await this.bookingsRepository.update(bookingId, { + contractSummary: this.buildContractSummary(booking), + contractTemplateKey: templateKey, + contractGeneratedAt: new Date(), + } as never); + // Render the PDF eagerly but NEVER block creation on it — Chromium can take + // seconds (or hang on assets); the document re-renders on view/download. + void this.upsertContractPdf(bookingId, booking.reference, templateKey).catch( + (err) => + this.logger.warn( + `Government contract PDF deferred for ${booking.reference}: ${err}. It will render on view/download.`, + ), + ); + } + async streamContract(bookingId: string) { const booking = await this.requireBooking(bookingId); const templateKey = @@ -152,8 +171,13 @@ export class BookingContractService { const booking = await this.requireBooking(bookingId); const role = dto.role as ContractSignerRole; + // Government contracts are order-free and status-free: either party may + // sign at any time (each once) — the booking is already expedited past the + // customer contract flow, so no status gate applies. if (role === 'CUSTOMER') { - assertBookingStatus(booking, ['CONTRACT_READY']); + if (!booking.isGovernment) { + assertBookingStatus(booking, ['CONTRACT_READY']); + } const existing = await this.bookingsRepository.findContractSignature( bookingId, 'CUSTOMER', @@ -162,7 +186,9 @@ export class BookingContractService { throw new BadRequestException('Customer has already signed this contract'); } } else { - assertBookingStatus(booking, ['SIGNED_CUSTOMER']); + if (!booking.isGovernment) { + assertBookingStatus(booking, ['SIGNED_CUSTOMER']); + } const existing = await this.bookingsRepository.findContractSignature( bookingId, 'STAFF', @@ -235,20 +261,30 @@ export class BookingContractService { ); if (role === 'CUSTOMER') { - updates.status = 'SIGNED_CUSTOMER'; updates.customerSignedAt = now; + // Government bookings keep their operational status (PAID) — a signature + // must never pull them back into the customer workflow. + if (!booking.isGovernment) updates.status = 'SIGNED_CUSTOMER'; } else { updates.fullyExecutedAt = now; updates.marketingApprovedAt = now; updates.marketingApprovedById = options.signerUserId ?? null; - updates.lockedAt = now; - updates.status = clearanceCode ? 'AWAITING_DOCUMENTS' : 'FULLY_EXECUTED'; + if (!booking.isGovernment) { + updates.lockedAt = now; + updates.status = clearanceCode ? 'AWAITING_DOCUMENTS' : 'FULLY_EXECUTED'; + } } const updated = await this.bookingsRepository.update(bookingId, updates as never); // Only the non-clearance (legacy/domestic) path enters the batch pipeline now; - // clearance bookings enter operations after the GL document gate. - if (role === 'STAFF' && !clearanceCode && updated?.trainScheduleId) { + // clearance bookings enter operations after the GL document gate. Government + // bookings are already in the pool from expedite — signing changes nothing. + if ( + role === 'STAFF' && + !booking.isGovernment && + !clearanceCode && + updated?.trainScheduleId + ) { this.bookingBatchService.enqueueScheduleProcessing(updated.trainScheduleId); } try { diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index ec2fc5f2e..0eb65bf5d 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -16,6 +16,7 @@ import { containersPerWagonForSize, wagonsPerUnitForSize, } from '../rule-engine/container-type.util'; +import { bulkItemWagonsRequired } from '../train-scheduling/train-capacity.util'; import { BookingsRepository } from './bookings.repository'; import { wagonRemainder } from './consolidation.service'; import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto'; @@ -1186,6 +1187,10 @@ export class BookingPricingService { ...(cargo.wagonTypes ?? []).map((w) => Number(w.capacityTons) || 0), ); if (!(capacity > 0)) return null; + // Break-bulk (PER_ITEM): `tons` above is the item count; size by + // indivisible items instead of pretending the count is tonnage. + const byItems = bulkItemWagonsRequired(booking, capacity); + if (byItems > 0) return byItems; return Math.max(1, Math.ceil(tons / capacity)); } catch { return null; diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts index 0cc7e59e4..b67366431 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts @@ -14,10 +14,10 @@ describe('BookingTransitionService — finalizeClearance gate', () => { serviceType: { includesCustoms: false }, // no output set → only the input gate }; - // Input set has two required docs. Non-customs bookings resolve to the - // ONE_TIME self-clearance document set. + // Input set has two required docs. Non-customs bookings resolve to their + // own without-customs document set. const inputSetting = { - code: 'contract_clearance_selfclear_import_container', + code: 'clearance_import_container_without_customs', fields: [ { fileKey: 'commercial_invoice', isRequired: true }, { fileKey: 'packing_list', isRequired: true }, @@ -201,7 +201,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', ( */ describe('BookingTransitionService — submitClearanceDocuments required-fields gate', () => { const inputSetting = { - code: 'contract_clearance_selfclear_import_container', + code: 'clearance_import_container_without_customs', fields: [ { fileKey: 'commercial_invoice', fileLabel: 'Commercial invoice', isRequired: true }, { fileKey: 'packing_list', fileLabel: 'Packing list', isRequired: true }, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts index 5f82a8e3b..0726445d5 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts @@ -26,6 +26,7 @@ describe('BookingTransitionService — operation review', () => { }; const bookingsService = { findById: jest.fn().mockResolvedValue(booking), + assertNoUnpaidHold: jest.fn().mockResolvedValue(undefined), }; const bookingBatchService = { enqueueRouteDayProcessing: jest.fn(), @@ -144,6 +145,7 @@ describe('BookingTransitionService — requestOperation export space gate', () = }; const bookingsService = { findById: jest.fn().mockResolvedValue(booking), + assertNoUnpaidHold: jest.fn().mockResolvedValue(undefined), checkDayCompatibilityForBooking: jest .fn() .mockResolvedValue({ hasDeparture: true, hasCompatible: true }), diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index d7b24d514..149875e6e 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -9,7 +9,10 @@ import { } from "@nestjs/common"; import { EventEmitter2, OnEvent } from "@nestjs/event-emitter"; -import { BookingBatchService } from '../train-scheduling/booking-batch.service'; +import { + BookingBatchService, + type ExportTrainOption, +} from '../train-scheduling/booking-batch.service'; import { eatDay } from '../train-scheduling/batch-window.util'; import { isRoadService } from './road.util'; import { RuleEngineService } from '../rule-engine/rule-engine.service'; @@ -401,6 +404,31 @@ export class BookingTransitionService { return fresh; } + /** + * Customer cancels their own unpaid hold (SELECTED_FOR_BATCH): the wagons + * release immediately instead of tying up the train until the pay window + * lapses. Ends CANCELLED; the freed capacity tops up from the waiting list. + */ + async cancelHold(bookingId: string, reason?: string): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ["SELECTED_FOR_BATCH"]); + if (booking.consolidationPartnerId) { + throw new BadRequestException( + "This booking shares a consolidated wagon with another booking — " + + "contact support to cancel it.", + ); + } + await this.bookingsRepository.createReviewNote( + bookingId, + reason ?? "Customer cancelled before payment", + "REJECTION", + ); + await this.bookingBatchService.cancelReservation(bookingId); + const fresh = await this.bookingsService.findById(bookingId); + this.notifier.cancelled(fresh, reason ?? "Cancelled before payment"); + return fresh; + } + async cancel(bookingId: string, reason: string): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, [ @@ -891,6 +919,7 @@ export class BookingTransitionService { async requestOperation( bookingId: string, scheduledDate: string, + requestedTrainScheduleId?: string | null, ): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, [ @@ -898,6 +927,13 @@ export class BookingTransitionService { "OPERATION_CHANGES_REQUESTED", ]); + // A company sitting on another unpaid hold commits nothing new — this is + // the moment export capacity locks, so the lock applies here too. + // Government bookings allocate without paying and are exempt. + if (!booking.isGovernment) { + await this.bookingsService.assertNoUnpaidHold(booking.companyId); + } + // A bare initiated instance (clearance-first flow) carries no cargo or // price — it must go through the contract completion endpoint, which // persists cargo, prices, invoices and only then lands here itself. @@ -942,10 +978,18 @@ export class BookingTransitionService { // largest bookable leftover ("reduce to N wagons or pick another day"). // Import/domestic bookings are batched + splittable, so they are NOT gated // here — they get an advisory count below and the batch engine sizes them. - const scheduledBooking = { ...booking, scheduledDate: date } as Booking; const isExportTrain = booking.tradeDirection === "EXPORT" && !isRoadService(booking.serviceType); + // The customer's train pick only exists for export rail; it rides the + // booking through the space checks below AND is persisted so the accept / + // reserve path locks onto that train (pickExportSchedule honors it). + const requestedId = isExportTrain ? (requestedTrainScheduleId ?? null) : null; + const scheduledBooking = { + ...booking, + scheduledDate: date, + requestedTrainScheduleId: requestedId, + } as Booking; if (isExportTrain) { // With export split ON the booking no longer has to ride ONE train whole: // the largest fitting part is offered and the leftover rebooks on the next @@ -958,9 +1002,14 @@ export class BookingTransitionService { eatDay(date), "EXPORT", ); - if (!fitting.length) { + const fitsRequest = requestedId + ? fitting.some((f) => f.scheduleId === requestedId) + : fitting.length > 0; + if (!fitsRequest) { throw new ConflictException( - "No export train on this day has space left — pick another shipment day.", + requestedId + ? "The selected train has no space left — pick another train or day." + : "No export train on this day has space left — pick another shipment day.", ); } } else { @@ -971,6 +1020,7 @@ export class BookingTransitionService { await this.bookingsRepository.update(bookingId, { status: "OPERATION_REQUEST_PENDING", scheduledDate: date, + requestedTrainScheduleId: requestedId, } as never); const fresh = await this.bookingsService.findById(bookingId); this.notifier.operationRequestedToStaff(fresh); @@ -988,6 +1038,43 @@ export class BookingTransitionService { * total covers the booking. `trainsForDay` is false when no departure carries * the leg — the day is unbookable regardless of space. */ + /** + * Export train picker data for a shipment day the customer is choosing: + * each export train on the booking's corridor with per-wagon-type free + * space. Export rail bookings only — nothing else picks a train. + */ + async exportTrainsForBooking( + bookingId: string, + scheduledDate: string, + overrides?: { + containerTypeIds?: string[]; + containerSizes?: string[]; + cargoTypeId?: string; + cargoTypeCode?: string; + wagons?: number; + }, + ): Promise { + const booking = await this.bookingsService.findById(bookingId); + const date = new Date(scheduledDate); + if (Number.isNaN(date.getTime())) { + throw new BadRequestException("A valid schedule date is required"); + } + if ( + booking.tradeDirection !== "EXPORT" || + isRoadService(booking.serviceType) + ) { + throw new BadRequestException( + "Train selection is only available for export rail bookings", + ); + } + const scheduledBooking = { ...booking, scheduledDate: date } as Booking; + return this.bookingBatchService.exportTrainOptionsForDay( + scheduledBooking, + eatDay(date), + overrides, + ); + } + async dayAvailabilityForBooking( bookingId: string, scheduledDate: string, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 54ce1660e..7b1f34f36 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -464,6 +464,26 @@ export class BookingsController { res.send(buffer); } + @Get(':id/carriage-acceptance-sheet') + @ApiOperation({ + summary: + 'Download the carriage acceptance sheet (one per booking, lists every allocated wagon)', + }) + async carriageAcceptanceSheet( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + @Res() res: Response, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + const { filename, buffer } = await this.bookingsService.carriageAcceptanceSheet(id); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.send(buffer); + } + @Get(':id/customer-trucks') @ApiOperation({ summary: 'List customer self-haul trucks (multi-truck) for a booking' }) async listCustomerTrucks( @@ -751,10 +771,42 @@ export class BookingsController { const booking = await this.transitionService.requestOperation( id, dto.scheduledDate, + dto.trainScheduleId ?? null, ); return this.transitionService.enrichBookingResponse(booking); } + @Get(":id/export-trains") + @ApiOperation({ + summary: + "Export train picker: the day's export trains on the booking's corridor " + + "with per-wagon-type free space (export rail bookings only)", + }) + async exportTrainsForBooking( + @Param("id", ParseUUIDPipe) id: string, + @Query("date") date: string, + // Bare contract instances carry no cargo yet — the completion form sends + // what the customer is entering so per-type space reflects THEIR cargo. + @Query("containerTypeIds") containerTypeIds?: string, + @Query("containerSizes") containerSizes?: string, + @Query("cargoTypeId") cargoTypeId?: string, + @Query("cargoTypeCode") cargoTypeCode?: string, + @Query("wagons") wagons?: string, + ) { + const parsedWagons = Number(wagons); + return this.transitionService.exportTrainsForBooking(id, date, { + containerTypeIds: containerTypeIds + ? containerTypeIds.split(",").filter(Boolean) + : undefined, + containerSizes: containerSizes + ? containerSizes.split(",").filter(Boolean) + : undefined, + cargoTypeId: cargoTypeId || undefined, + cargoTypeCode: cargoTypeCode || undefined, + wagons: Number.isFinite(parsedWagons) && parsedWagons > 0 ? parsedWagons : undefined, + }); + } + @Post(":id/operation/review") @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ @@ -1271,6 +1323,20 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + @Post(":id/cancel-hold") + @ApiOperation({ + summary: + "Customer cancels an unpaid hold (SELECTED_FOR_BATCH → CANCELLED); " + + "reserved wagons release immediately", + }) + async cancelHold( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: CancelBookingDto, + ) { + const booking = await this.transitionService.cancelHold(id, dto.reason); + return this.transitionService.enrichBookingResponse(booking); + } + @Post(":id/consolidation") @ApiOperation({ summary: "Request freight consolidation" }) requestConsolidation(@Param("id", ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index b9ced4f96..b16febe2e 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -23,6 +23,7 @@ import { DocumentReviewStatus, } from './entities/booking-document-review.entity'; import { BookingContainer } from './entities/booking-container.entity'; +import { BookingContainerUnit } from './entities/booking-container-unit.entity'; import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity'; import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity'; import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; @@ -201,10 +202,12 @@ export class BookingsRepository extends BaseRepository { vgmPerUnitTons: number; hazardousQuantity?: number; reeferQuantity?: number; + containerNumbers?: string[]; weightResult: ContainerWeightResult; }>, ): Promise { const containerRepo = this.dataSource.getRepository(BookingContainer); + const unitRepo = this.dataSource.getRepository(BookingContainerUnit); const typeRepo = this.dataSource.getRepository(ContainerType); const saved: BookingContainer[] = []; @@ -230,7 +233,26 @@ export class BookingsRepository extends BaseRepository { isOverweight: item.weightResult.isOverweight, overweightExcessTons: item.weightResult.overweightExcessTons, }); - saved.push(await containerRepo.save(row)); + const savedRow = await containerRepo.save(row); + saved.push(savedRow); + + // Physical container numbers, one unit row each (capped to the line + // quantity; blanks skipped). Optional — units can also be entered later. + const numbers = (item.containerNumbers ?? []) + .map((n) => n.trim()) + .filter(Boolean) + .slice(0, item.quantity); + let sortOrder = 0; + for (const containerNumber of numbers) { + await unitRepo.save( + unitRepo.create({ + bookingContainerId: savedRow.id, + containerNumber, + vgmTons: item.vgmPerUnitTons, + sortOrder: sortOrder++, + }), + ); + } } return saved; @@ -1088,6 +1110,15 @@ export class BookingsRepository extends BaseRepository { * the whole (route, day) pool rather than bookings pre-targeted to one train. */ day?: string; + /** + * The schedule's ordered route stops. When given, the corridor filter + * replaces the exact origin/destination match: any booking whose BOTH yards + * lie on the route qualifies (sub-corridor bookings like Dire→DCT on a + * GMT→Dire→DCT train — the caller still checks stop ORDER). Dateless + * DOMESTIC (intercity) bookings also join the pool: they ride any train on + * their corridor. + */ + corridorYardIds?: string[]; }): Promise { const qb = this.repository .createQueryBuilder('booking') @@ -1111,8 +1142,11 @@ export class BookingsRepository extends BaseRepository { // single-schedule filter only when no day is supplied (e.g. a staff-pinned // booking that still carries train_schedule_id). if (options.day) { + // Dateless DOMESTIC (intercity) bookings ride any train on their corridor + // — no scheduled_date to match, so the day filter must not hide them. qb.andWhere( - `DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`, + `(DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day + OR (booking.trade_direction = 'DOMESTIC' AND booking.scheduled_date IS NULL))`, { day: options.day }, ); } else if (options.trainScheduleId) { @@ -1125,15 +1159,23 @@ export class BookingsRepository extends BaseRepository { qb.andWhere('booking.freightType = :freightType', { freightType: options.freightType }); } - if (options.originStationId) { - qb.andWhere('booking.originYardId = :originStationId', { - originStationId: options.originStationId, - }); - } - if (options.destinationStationId) { - qb.andWhere('booking.destinationYardId = :destinationStationId', { - destinationStationId: options.destinationStationId, + if (options.corridorYardIds?.length) { + qb.andWhere('booking.originYardId IN (:...corridorYardIds)', { + corridorYardIds: options.corridorYardIds, + }).andWhere('booking.destinationYardId IN (:...corridorYardIds)', { + corridorYardIds: options.corridorYardIds, }); + } else { + if (options.originStationId) { + qb.andWhere('booking.originYardId = :originStationId', { + originStationId: options.originStationId, + }); + } + if (options.destinationStationId) { + qb.andWhere('booking.destinationYardId = :destinationStationId', { + destinationStationId: options.destinationStationId, + }); + } } if (options.schedulingStatus) { qb.andWhere('booking.scheduling_status = :schedulingStatus', { @@ -1316,6 +1358,16 @@ export class BookingsRepository extends BaseRepository { .getMany(); } + /** Open unpaid holds (wagons reserved, pay window running) for a company. */ + countUnpaidHoldsForCompany(companyId: string): Promise { + return this.repository.count({ + where: { + companyId, + status: In(['SELECTED_FOR_BATCH', 'AWAITING_PAYMENT']), + }, + }); + } + /** Bookings currently reserved (SELECTED_FOR_BATCH) against a schedule. */ findReservedForSchedule(scheduleId: string): Promise { return this.repository diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index d40433f4e..b8f73dfc8 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -32,6 +32,8 @@ import { Yard } from '../rule-engine/entities/yard.entity'; import { ServiceType } from '../rule-engine/entities/service-type.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { Contract } from '../contracts/entities/contract.entity'; +import { BookingBatchService } from '../train-scheduling/booking-batch.service'; +import { BookingContractService } from './booking-contract.service'; import { BookingsRepository } from './bookings.repository'; import { ConsolidationService } from './consolidation.service'; import { VehiclesService } from '../vehicles/vehicles.service'; @@ -68,6 +70,29 @@ export interface PaginatedBookings { }; } +/** One wagon line on the carriage acceptance sheet (raw SQL projection). */ +interface CarriageAcceptanceWagonRow { + sequenceNo: number; + wagonType: string | null; + wagonNumber: string | null; + tareWeightTons: string | null; + equatedLength: string | null; + loadCapacityTons: string | null; + allocatedWeightTons: string | null; + trainNumber: string | null; + departureAt: Date | null; + marshalledAt: string | null; + arrivalAt: string | null; + containerNumbers: string | null; + sealNumbers: string | null; +} + +/** A received-but-not-yet-marshalled export line, standing in for a wagon row. */ +interface CarriageAcceptanceReceivedRow { + allocatedWeightTons: string | null; + containerNumbers: string | null; +} + const URGENT_PRIORITY_THRESHOLD = 1000; const NEEDS_ACTION_STATUSES = [ 'SUBMITTED', @@ -103,6 +128,10 @@ export class BookingsService { private readonly vehiclesService: VehiclesService, private readonly pdfRender: PdfRenderService, private readonly events: EventEmitter2, + @Inject(forwardRef(() => BookingContractService)) + private readonly bookingContractService: BookingContractService, + @Inject(forwardRef(() => BookingBatchService)) + private readonly bookingBatchService: BookingBatchService, ) {} async assignCustomerTruck( @@ -202,6 +231,284 @@ export class BookingsService { }; } + /** + * Carriage acceptance sheet — one per booking, listing every wagon the booking + * occupies. Handed to the customer when EDR accepts the cargo (export) and when + * the wagons are allocated before marshalling (import), so it is only available + * once the booking has wagon allocations. + */ + async carriageAcceptanceSheet(bookingId: string): Promise<{ filename: string; buffer: Buffer }> { + const booking = await this.findById(bookingId); + let wagons: CarriageAcceptanceWagonRow[] = await this.dataSource.query( + `SELECT tsw.sequence_no AS "sequenceNo", + COALESCE(wt.code, wt.name) AS "wagonType", + w.wagon_number AS "wagonNumber", + wt.tare_weight_tons AS "tareWeightTons", + tsw.length_meters AS "equatedLength", + tsw.capacity_tons AS "loadCapacityTons", + a.allocated_weight_tons AS "allocatedWeightTons", + s.train_number AS "trainNumber", + s.scheduled_departure_date AS "departureAt", + so.label AS "marshalledAt", + sd.label AS "arrivalAt", + string_agg(DISTINCT ci.container_number, ', ') AS "containerNumbers", + string_agg(DISTINCT ci.seal_number, ', ') AS "sealNumbers" + FROM freight.wagon_booking_allocations a + JOIN freight.train_set_wagons tsw + ON tsw.id = a.train_set_wagon_id AND tsw.deleted_at IS NULL + LEFT JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id + LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id + LEFT JOIN freight.train_schedules s + ON s.train_set_id = tsw.train_set_id AND s.deleted_at IS NULL + LEFT JOIN freight.yards so ON so.id = s.origin_station_id + LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id + LEFT JOIN freight.wagon_allocation_container_items ci + ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL + WHERE a.booking_id = $1 AND a.deleted_at IS NULL + GROUP BY tsw.id, a.id, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons, + s.train_number, s.scheduled_departure_date, so.label, sd.label + ORDER BY tsw.sequence_no`, + [bookingId], + ); + // Export acceptance happens at the warehouse gate, not at marshalling: EDR + // takes custody of the cargo when it receives it, and the customer is handed + // this sheet then — before the booking is put on a train. So a received + // export booking gets its sheet off the received cargo, wagon columns blank + // until the consist exists. Import keeps the allocation gate: nothing is + // accepted from the customer before the wagons carry it. + // + // Receipt is proven by the warehouse GRN, but the GRN is warehouse paperwork + // and never appears on this sheet — it is only the signal that EDR has taken + // the cargo, which is what the customer's sheet attests to. + const pendingWagons = wagons.length === 0; + if (pendingWagons) { + const receivedLines: CarriageAcceptanceReceivedRow[] = + booking.tradeDirection === 'EXPORT' + ? await this.dataSource.query( + `SELECT inv.weight AS "allocatedWeightTons", + c.container_number AS "containerNumbers" + FROM freight.warehouse_inventory inv + LEFT JOIN freight.containers c + ON c.id = inv.container_id AND c.deleted_at IS NULL + WHERE inv.booking_id = $1 AND inv.deleted_at IS NULL + AND COALESCE(NULLIF(TRIM(inv.grn_number), ''), '') <> '' + ORDER BY inv.created_at`, + [bookingId], + ) + : []; + if (receivedLines.length === 0) { + throw new BadRequestException( + booking.tradeDirection === 'EXPORT' + ? 'This export booking has no GRN yet — receive the cargo at the warehouse before issuing the carriage acceptance sheet' + : 'No wagons are allocated to this booking yet — the carriage acceptance sheet is issued after wagon allocation', + ); + } + wagons = receivedLines.map((row, index) => ({ + sequenceNo: index + 1, + wagonType: null, + wagonNumber: null, + tareWeightTons: null, + equatedLength: null, + loadCapacityTons: null, + allocatedWeightTons: row.allocatedWeightTons, + trainNumber: null, + departureAt: null, + marshalledAt: null, + arrivalAt: null, + containerNumbers: row.containerNumbers, + sealNumbers: null, + })); + } + + const html = this.buildCarriageAcceptanceSheetHtml(booking, wagons, { pendingWagons }); + const buffer = await this.pdfRender.htmlToPdfBuffer(html, { + label: 'carriage acceptance sheet', + fallback: (prepared) => buildTabularFallbackPdf(prepared), + }); + return { + filename: `carriage-acceptance-${booking.reference.replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, + buffer, + }; + } + + /** + * Split the booking amount across its wagons, proportional to allocated weight + * (equal shares when no weights are recorded). The last row absorbs the rounding + * remainder so the Price column always sums to the Total Amount on the sheet. + */ + private splitAmountAcrossWagons(total: number, weights: number[]): number[] { + const sum = weights.reduce((acc, w) => acc + w, 0); + const shares = weights.map((w) => + Math.round((sum > 0 ? (total * w) / sum : total / weights.length) * 100) / 100, + ); + const drift = Math.round((total - shares.reduce((a, b) => a + b, 0)) * 100) / 100; + shares[shares.length - 1] = Math.round((shares[shares.length - 1] + drift) * 100) / 100; + return shares; + } + + private buildCarriageAcceptanceSheetHtml( + booking: Booking, + wagons: CarriageAcceptanceWagonRow[], + { pendingWagons }: { pendingWagons: boolean }, + ): string { + const esc = (v: unknown) => this.escapeHtml(String(v ?? '-')); + const num = (v: unknown, digits = 3) => (Number(v) || 0).toFixed(digits); + const money = (v: number) => + v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + + const departureStation = booking.originYard?.label ?? booking.originYard?.code ?? '-'; + const arrivalStation = booking.destinationYard?.label ?? booking.destinationYard?.code ?? '-'; + const cargoName = booking.cargoType?.cargoTypeName ?? booking.cargoFreeText ?? '-'; + const currency = booking.paymentCurrency ?? 'ETB'; + const totalAmount = Number(booking.adjustedTotalAmount ?? booking.totalAmount) || 0; + const prices = this.splitAmountAcrossWagons( + totalAmount, + wagons.map((w) => Number(w.allocatedWeightTons) || 0), + ); + const header = wagons[0]; + const sheetDate = header.departureAt ? new Date(header.departureAt) : new Date(); + + const totals = wagons.reduce( + (acc, w) => ({ + tare: acc.tare + (Number(w.tareWeightTons) || 0), + capacity: acc.capacity + (Number(w.loadCapacityTons) || 0), + load: acc.load + (Number(w.allocatedWeightTons) || 0), + length: acc.length + (Number(w.equatedLength) || 0), + }), + { tare: 0, capacity: 0, load: 0, length: 0 }, + ); + // A wagon carrying no weight and no container is running empty under this booking. + const fullWagons = wagons.filter( + (w) => (Number(w.allocatedWeightTons) || 0) > 0 || Boolean(w.containerNumbers), + ).length; + + const rows = wagons + .map( + (w, i) => ` + ${i + 1} + ${esc(w.wagonType)} + ${esc(w.wagonNumber)} + ${num(w.tareWeightTons, 2)} + ${num(w.equatedLength)} + ${num(w.loadCapacityTons)} + ${esc(arrivalStation)} + ${esc(cargoName)} + ${esc(departureStation)} + ${esc(w.containerNumbers)} + ${esc(w.sealNumbers)} + ${money(prices[i])} + `, + ) + .join(''); + + return ` + + + + Carriage Acceptance Sheet + + + +
+
+
Ethio-Djibouti Railway S.C.
+

Carriage Acceptance Sheet

+
Booking ${esc(booking.reference)} — ${esc(booking.tradeDirection)}
+
+
+ Sheet No. + CAS-${esc(booking.reference)} + Generated: ${esc(new Date().toLocaleString('en-GB'))} +
+
+ +
+
Marshalled at${esc(header.marshalledAt ?? departureStation)}
+
Arrival at${esc(header.arrivalAt ?? arrivalStation)}
+
Date and time${esc(sheetDate.toLocaleString('en-GB'))}
+
Train No.${esc(header.trainNumber)}
+
Customer${esc(booking.company?.name)}
+
Cargo${esc(cargoName)}
+
+ + + + + + + + + + + + + + + + + + + + ${rows} + + + + + + + + + + + +
SNType of WagonWagon No.Tare WeightEquated LengthLoad CapacityArrival StationCargo NameDeparture StationContainer No.Seal No.Price (${esc(currency)})
${ + pendingWagons + ? `Received lines: ${wagons.length} — wagons pending marshalling` + : `Total wagons: ${wagons.length} (full ${fullWagons} / empty ${wagons.length - fullWagons})` + }${num(totals.tare, 2)}${num(totals.length)}${num(totals.capacity)}Gross weight (tare + load): ${num(totals.tare + totals.load)} T${money(totalAmount)}
+ +
+ ${ + pendingWagons + ? `The cargo listed above is accepted for carriage under booking ${esc(booking.reference)}. + Wagon identity and seal numbers are filled in when the booking is marshalled onto a train.` + : `The wagons listed above are accepted for carriage under booking ${esc(booking.reference)}. + Wagon identity, container and seal numbers must be verified against the physical consist + before the sheet is signed.` + } +
+ +
+
Signed by — EDR operations / date
+
Signed by — customer or agent / date
+
Signed by — marshalling yard / date
+
+ +`; + } + /** Resolve trade direction from yard countries; reject client mismatch. */ /** * An intercity corridor is valid when both yards are Ethiopian and at least @@ -602,6 +909,24 @@ export class BookingsService { return result.booking; } + /** + * A company with an open unpaid hold (SELECTED_FOR_BATCH — wagons reserved, + * pay window running) may not take more capacity until it pays or the hold + * dies: otherwise one customer can lock a train's wagons over and over + * without ever paying. EXPIRED / CANCELLED holds free the lock. + */ + async assertNoUnpaidHold(companyId?: string | null): Promise { + if (!companyId) return; + const holds = + await this.bookingsRepository.countUnpaidHoldsForCompany(companyId); + if (holds > 0) { + throw new ConflictException( + 'You already have a booking waiting for payment. Pay it or cancel it ' + + 'before making a new booking.', + ); + } + } + /** Create a new freight booking. */ async create( dto: CreateBookingDto, @@ -664,6 +989,10 @@ export class BookingsService { companyId = company.id; } + // Government bookings allocate without paying, so the unpaid-hold lock + // only applies to commercial companies. + if (!isGovernment) await this.assertNoUnpaidHold(companyId); + if (dto.trainScheduleId) { // Staff manual pin: the schedule must be OPEN and on the same route. const schedule = await this.dataSource @@ -857,6 +1186,9 @@ export class BookingsService { cargoFreeText: dto.cargoFreeText, shippingLineId: dto.shippingLineId, cargoTotalWeightVgm: dto.cargoTotalWeightVgm, + // Break-bulk actual tonnage (PER_ITEM cargo); meaningless outside BULK. + bulkTotalWeightTons: + dto.freightType === 'BULK' ? (dto.bulkTotalWeightTons ?? null) : null, isHazardous: dto.isHazardous ?? false, // Bulk reefer is the customer's toggle; container reefer is derived from // the container type at pricing time, so the booking-level flag stays off @@ -903,6 +1235,7 @@ export class BookingsService { vgmPerUnitTons: c.vgmPerUnitTons, hazardousQuantity: c.hazardousQuantity, reeferQuantity: c.reeferQuantity, + containerNumbers: c.containerNumbers, weightResult: ruleResult.containerWeightResults[i], })), ); @@ -957,6 +1290,21 @@ export class BookingsService { warnings.push(...consolidation.messages); } + // Government bookings pass every customer step at creation: the server + // expedites them to PAID/Eligible, generates the contract (signable at any + // time) and queues priority placement. Best-effort — the booking row is + // already inserted, so a late failure must not 500 the whole create; the + // idempotent expedite endpoint remains the retry path. + if (isGovernment) { + try { + full = await this.governmentExpedite(booking.id, userId ?? 'system'); + } catch (err) { + warnings.push( + `Government expedite incomplete — retry via the expedite action: ${(err as Error).message}`, + ); + } + } + return { booking: full, warnings }; } @@ -1048,6 +1396,11 @@ export class BookingsService { ...dto, freightType, cargoTypeId: freightType === 'BULK' ? cargoTypeId : null, + // Break-bulk actual tonnage; cleared when the booking leaves BULK. + bulkTotalWeightTons: + freightType === 'BULK' + ? (dto.bulkTotalWeightTons ?? existing.bulkTotalWeightTons ?? null) + : null, // Booking-level reefer is only meaningful for bulk; container reefer is // derived from the container type at pricing time. isReefer: @@ -1800,13 +2153,22 @@ export class BookingsService { return false; } - /** Staff expedite: mark a government booking PAID and ready for scheduling (no commercial hold). */ + /** + * Expedite a government booking past every customer step: PAID + Eligible + * (no commercial hold, no payment), contract generated server-side (signable + * at any time), and the (route, day) fill kicked immediately so it grabs a + * seat on any open train — government-first, preempting commercial cargo if + * the day is full. Runs automatically at creation; the endpoint remains as a + * no-op-safe retry for older bookings. + */ async governmentExpedite(id: string, staffUserId: string): Promise { const booking = await this.findById(id); if (!booking.isGovernment) { throw new BadRequestException('Only government bookings can be expedited'); } - const blocked = ['PAID', 'IN_TRANSIT', 'ARRIVED', 'COMPLETED', 'CANCELLED', 'REJECTED']; + // Idempotent: create() already expedites — a repeat call changes nothing. + if (booking.status === 'PAID') return booking; + const blocked = ['IN_TRANSIT', 'ARRIVED', 'COMPLETED', 'CANCELLED', 'REJECTED']; if (blocked.includes(booking.status)) { throw new BadRequestException(`Cannot expedite booking in status ${booking.status}`); } @@ -1818,12 +2180,22 @@ export class BookingsService { holdStartedAt: null, holdExpiresAt: null, }); + await this.bookingContractService.generateContractForGovernment(id); await this.bookingsRepository.createReviewNote( id, `Government booking expedited to PAID by staff (${staffUserId})`, 'STAFF_NOTE', staffUserId, ); + // Priority placement: run the day-level fill now instead of waiting for a + // batch tick — the pool sorts government first and preempts if needed. + if (booking.scheduledDate) { + this.bookingBatchService.enqueueRouteDayProcessing( + booking.originYardId, + booking.destinationYardId, + eatDay(booking.scheduledDate), + ); + } return this.findById(id); } diff --git a/apps/edr-freight-api/src/modules/bookings/carriage-acceptance-price-split.spec.ts b/apps/edr-freight-api/src/modules/bookings/carriage-acceptance-price-split.spec.ts new file mode 100644 index 000000000..195e0cab0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/carriage-acceptance-price-split.spec.ts @@ -0,0 +1,26 @@ +import { BookingsService } from './bookings.service'; + +// The split is a pure helper on the prototype (never touches `this`), so it can be +// exercised without constructing the service and its dependency graph. +const split = (total: number, weights: number[]): number[] => + ( + BookingsService.prototype as unknown as { + splitAmountAcrossWagons(total: number, weights: number[]): number[]; + } + ).splitAmountAcrossWagons(total, weights); + +describe('carriage acceptance sheet — price split', () => { + it('splits proportionally to allocated weight', () => { + expect(split(100, [30, 10])).toEqual([75, 25]); + }); + + it('splits equally when no weights are recorded', () => { + expect(split(90, [0, 0, 0])).toEqual([30, 30, 30]); + }); + + it('always sums back to the booking total despite rounding', () => { + const shares = split(100, [1, 1, 1]); + expect(shares.reduce((a, b) => a + b, 0)).toBe(100); + expect(shares).toEqual([33.33, 33.33, 33.34]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts b/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts index 81e5c833a..e38715ae9 100644 --- a/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts @@ -11,10 +11,8 @@ describe('clearance.util — clearanceSettingCode', () => { expect(clearanceSettingCode('IMPORT', 'CONTAINER', true)).toBe( 'clearance_import_container_with_customs', ); - // Non-customs bookings self-clear with the same document set a ONE_TIME - // self-clear contract uses. expect(clearanceSettingCode('IMPORT', 'CONTAINER', false)).toBe( - 'contract_clearance_selfclear_import_container', + 'clearance_import_container_without_customs', ); }); @@ -23,7 +21,7 @@ describe('clearance.util — clearanceSettingCode', () => { 'clearance_export_bulk_with_customs', ); expect(clearanceSettingCode('EXPORT', 'BULK', false)).toBe( - 'contract_clearance_selfclear_export_bulk', + 'clearance_export_bulk_without_customs', ); }); diff --git a/apps/edr-freight-api/src/modules/bookings/clearance.util.ts b/apps/edr-freight-api/src/modules/bookings/clearance.util.ts index 242cee9d3..e28917715 100644 --- a/apps/edr-freight-api/src/modules/bookings/clearance.util.ts +++ b/apps/edr-freight-api/src/modules/bookings/clearance.util.ts @@ -39,12 +39,11 @@ export function clearanceSettingCode( const op = operationFor(tradeDirection); if (!op) return null; const freight = freightFor(freightType); - // Non-customs (Path A) bookings self-clear: the customer proves his own - // clearance with the SAME smaller document set a ONE_TIME self-clear - // contract uses (customs declaration, release permit, …) — not the - // GL-oriented booking sets. + // 4 import + 4 export cases (bulk/container × with/without customs) — each + // booking resolves to its own clearance_{op}_{freight}_{with|without}_customs + // set, independent of any contract-level clearance codes. if (!includesCustoms) { - return `contract_clearance_selfclear_${op}_${freight}`; + return `clearance_${op}_${freight}_without_customs`; } return `clearance_${op}_${freight}_with_customs`; } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/contract-view.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/contract-view.dto.ts index 919652af6..0dceadf43 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/contract-view.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/contract-view.dto.ts @@ -20,6 +20,9 @@ export class SavedSignatureViewDto { @ApiPropertyOptional() signatureImageUrl?: string | null; + + @ApiPropertyOptional() + stampImageUrl?: string | null; } export class ContractViewDto { diff --git a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts index c4d971f51..a9aca53dd 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts @@ -74,6 +74,17 @@ export class CreateBookingContainerDto { @Min(0) @Transform(({ value }) => Number(value ?? 0)) reeferQuantity?: number; + + @ApiPropertyOptional({ + description: + 'Physical container numbers for this line (each becomes a booking_container_unit; extras beyond `quantity` are ignored)', + type: [String], + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + @MaxLength(64, { each: true }) + containerNumbers?: string[]; } /** @@ -325,6 +336,21 @@ export class CreateBookingDto { @Transform(({ value }) => Number(value)) cargoTotalWeightVgm!: number; + /** + * Break-bulk only: actual total cargo weight in tons when the bulk cargo + * type is PER_ITEM — `cargoTotalWeightVgm` then carries the item count. + * Omit for PER_TON bulk and container freight. + */ + @ApiPropertyOptional({ + minimum: 0, + description: 'Break-bulk (PER_ITEM) total weight in tons; cargoTotalWeightVgm holds the item count', + }) + @IsOptional() + @IsNumber() + @Min(0) + @Transform(({ value }) => (value == null ? undefined : Number(value))) + bulkTotalWeightTons?: number; + @ApiPropertyOptional({ default: false }) @IsOptional() @IsBoolean() diff --git a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts index f27b375bb..63ad5b9f4 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts @@ -5,6 +5,7 @@ import { IsInt, IsOptional, IsString, + IsUUID, Max, Min, MinLength, @@ -93,6 +94,17 @@ export class RequestOperationDto { }) @IsDateString() scheduledDate!: string; + + @ApiPropertyOptional({ + description: + 'EXPORT rail only: the specific train (schedule id) the customer picked ' + + 'from GET /bookings/:id/export-trains. The reserve path locks onto this ' + + 'train instead of earliest-first; 409 if it no longer fits. Ignored for ' + + 'import/domestic/road bookings.', + }) + @IsOptional() + @IsUUID() + trainScheduleId?: string; } export class OperationReviewDto { diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 3d0603f5f..3d339fa19 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -365,6 +365,15 @@ export class Booking extends BaseEntity { @Column({ name: 'cargo_total_weight_vgm', type: 'numeric', precision: 12, scale: 3 }) cargoTotalWeightVgm!: number; + /** + * Break-bulk only: actual total cargo weight in tons when the bulk cargo + * type is PER_ITEM (`cargoTotalWeightVgm` then carries the item COUNT). + * Null for PER_TON bulk and all CONTAINER bookings. Wagon allocation uses + * weight ÷ count to size indivisible items per wagon. + */ + @Column({ name: 'bulk_total_weight_tons', type: 'numeric', precision: 12, scale: 3, nullable: true }) + bulkTotalWeightTons?: number | null; + @Column({ name: 'is_hazardous', type: 'boolean', default: false }) isHazardous!: boolean; @@ -499,6 +508,18 @@ export class Booking extends BaseEntity { @Column({ name: 'train_schedule_id', type: 'uuid', nullable: true }) trainScheduleId?: string | null; + /** + * EXPORT only: the specific train the customer picked at day-commit. + * pickExportSchedule reserves on this train (409 if it no longer fits) + * instead of falling back to earliest-departure-first. NULL = no preference. + */ + @Column({ name: 'requested_train_schedule_id', type: 'uuid', nullable: true }) + requestedTrainScheduleId?: string | null; + + /** Stamped when the one pre-deadline pay reminder went out (tick dedup). */ + @Column({ name: 'payment_reminder_sent_at', type: 'timestamptz', nullable: true }) + paymentReminderSentAt?: Date | null; + // ── Per-booking journey (segment corridor bookings) ──────────────────────── // A booking rides only its own origin→destination leg of the train's route, // so dispatch/arrival are per-booking facts, not train facts. Clearance gates diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 8e3be4d1a..ee75460c9 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -35,6 +35,10 @@ import { CreateExternalProfileDto } from "./dto/create-external-profile.dto"; import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto"; import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto"; import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto"; +import { + CompanyIdentityStateDto, + CompleteIdentityVerificationDto, +} from "./dto/complete-identity-verification.dto"; import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto"; import { StartOnboardingDto } from "./dto/start-onboarding.dto"; import { DashboardQueryDto } from "./dto/dashboard-query.dto"; @@ -188,9 +192,20 @@ export class CompaniesController { @Post("fetch-etrade-info") @ApiOperation({ summary: "Fetch company info from eTrade by TIN" }) async fetchETradeInfo( + @CurrentUser() user: CurrentIamUser, @Body() dto: FetchETradeDto, ): Promise { - const data = await this.companiesService.fetchETradeData(dto.tin); + // Best-effort: a first-run onboarding draft may not exist yet, in which + // case there is no company to exclude and `tinTaken` checks every row — + // the correct behaviour for a brand-new lookup. + const companyId = await this.companiesService + .getCompanyInfoByUserId(user.id) + .then(({ company }) => company.id) + .catch(() => undefined); + const data = await this.companiesService.fetchETradeData( + dto.tin, + companyId, + ); return new ETradeResponseDto(data); } @@ -378,6 +393,32 @@ export class CompaniesController { return this.companiesService.removePoaDelegationLetter(user.id, fileId); } + @Post("identity/fayda/complete") + @ApiOperation({ + summary: + "Bind a completed Fayda verification to the company's owner or Power of Attorney. " + + "Start the flow with POST /fayda/verification/start (platform=PORTAL), then post the returned code+state here. " + + "The verified name, phone, email and address are written from the Fayda payload; on an approved company the change is staged for backoffice review.", + }) + async completeIdentityVerification( + @CurrentUser() user: CurrentIamUser, + @Body() dto: CompleteIdentityVerificationDto, + ): Promise { + return this.companiesService.completeIdentityVerification(user.id, dto); + } + + @Delete("identity/fayda/poa") + @ApiOperation({ + summary: + "Remove the company's Power of Attorney — the verified identity, its details and the delegation paper together. " + + "Refused while the company holds a freight forwarder role, which cannot operate without a representative.", + }) + async removePoaIdentity( + @CurrentUser() user: CurrentIamUser, + ): Promise { + return this.companiesService.removePoaIdentity(user.id); + } + @Patch("onboarding-step") @ApiOperation({ summary: "Persist the user's current onboarding wizard step" }) @HttpCode(HttpStatus.NO_CONTENT) diff --git a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts new file mode 100644 index 000000000..c13f79f5e --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts @@ -0,0 +1,458 @@ +import { BadRequestException } from "@nestjs/common"; + +import { CompaniesService } from "./companies.service"; +import { CompanyNationality, CompanyStatus } from "./entities/company.entity"; +import { ProfileType } from "./entities/company-profile.entity"; +import { POA_DELEGATION_FILE_KEY } from "../file-upload-settings/poa-delegation.constants"; + +/** + * A person's identity is proved through Fayda: name, email, phone and address + * come from the verified payload, not typed. Fayda's userinfo carries no + * national ID number, so none is collected or derived here. + * + * Only the OWNER's credential varies by nationality: + * - Ethiopian company: the owner is verified through Fayda. + * - Foreign company: Fayda is an Ethiopian national ID, so the owner instead + * supplies a typed passport number — required on its own, whether or not the + * owner also completes a (purely optional) Fayda verification. + * + * The PoA does not vary. A representative acts for the company inside Ethiopia + * whoever owns it, so a PoA is always an Ethiopian holding a Fayda ID: once one + * is named, both nationalities must verify them, and their details come from + * the verified payload rather than the form. + * + * The owner is NOT the general manager — GM is a separate, plain typed role + * the portal offers a "same as owner" copy for, but it is never itself + * Fayda-verified or gated on. + */ + +interface Ctx { + attributes: Record; + files: { id: string; code: string; reviewStatus?: string | null }[]; + profileTypes: ProfileType[]; + status: CompanyStatus; + nationality: CompanyNationality; + verification: Record; +} + +const OWNER_VERIFIED = { + ownerFaydaSub: "owner-sub", + ownerFaydaVerifiedAt: "2026-07-01T00:00:00.000Z", + ownerName: "Abebe Bikila", +}; + +const POA_VERIFIED = { + poaFaydaSub: "poa-sub", + poaFaydaVerifiedAt: "2026-07-02T00:00:00.000Z", + poaName: "Tirunesh Dibaba", + poaEmail: "tirunesh@example.com", + poaPhone: "+251911000000", +}; + +const paper = () => ({ + id: "file-1", + code: POA_DELEGATION_FILE_KEY, + reviewStatus: null, +}); + +function makeService(overrides: Partial = {}) { + const ctx: Ctx = { + attributes: {}, + files: [], + profileTypes: [ProfileType.importer], + status: CompanyStatus.Pending, + nationality: CompanyNationality.Ethiopian, + verification: { + purpose: "VERIFY", + verified: true, + sub: "new-sub", + fullName: "Haile Gebrselassie", + email: "haile@example.com", + phoneNumber: "+251922000000", + address: "Addis Ababa", + birthdate: "1973-04-18", + gender: "Male", + }, + ...overrides, + }; + + const company = () => ({ + id: "company-1", + status: ctx.status, + nationality: ctx.nationality, + attributes: ctx.attributes, + companyProfiles: ctx.profileTypes.map((type, i) => ({ + id: `profile-${i}`, + type, + })), + type: "customer", + }); + + const deps = { + companiesRepo: { + findById: jest.fn(async () => company()), + update: jest.fn(async (_id: string, patch: Record) => { + if (patch.attributes) + ctx.attributes = patch.attributes as Record; + return company(); + }), + findByTin: jest.fn(async () => null), + }, + companyProfilesRepo: { + findByCompanyId: jest.fn(async () => + ctx.profileTypes.map((type, i) => ({ id: `profile-${i}`, type })), + ), + findByType: jest.fn(async (_id: string, type: ProfileType) => + ctx.profileTypes.includes(type) ? { id: "existing", type } : null, + ), + create: jest.fn(async (row: Record) => ({ + id: "new", + ...row, + })), + }, + changeRequestRepo: { + findPendingByCompanyId: jest.fn(async () => null), + findLatestOpenByCompanyId: jest.fn(async () => null), + findByCompanyId: jest.fn(async () => []), + create: jest.fn(async (row: Record) => ({ + id: "cr-1", + ...row, + })), + update: jest.fn(async () => ({ id: "cr-1" })), + }, + profilesRepo: { + findByCompanyId: jest.fn(async () => []), + findByUserId: jest.fn(async () => ({ + id: "external-1", + companyId: "company-1", + company: company(), + onboardingCompleted: false, + })), + }, + filesService: { + findByResource: jest.fn(async () => ctx.files), + findById: jest.fn(async () => null), + remove: jest.fn(async () => undefined), + }, + companyNotifier: { changeRequestSubmitted: jest.fn() }, + verifayda: { + completeVerification: jest.fn(async () => ctx.verification), + }, + }; + + const service = new CompaniesService( + deps.companiesRepo as never, + deps.companyProfilesRepo as never, + deps.changeRequestRepo as never, + deps.profilesRepo as never, + {} as never, + deps.filesService as never, + {} as never, + {} as never, + deps.companyNotifier as never, + {} as never, + deps.verifayda as never, + ); + + jest + .spyOn(service, "getCompanyInfoByUserId") + .mockImplementation( + async () => + ({ profile: { id: "external-1" }, company: company() }) as never, + ); + + return { service, ctx, deps, company }; +} + +describe("Fayda identity verification binds a person to the company", () => { + it("writes the verified identity", async () => { + const { service, ctx } = makeService(); + + const state = await service.completeIdentityVerification("user-1", { + subject: "owner", + code: "c", + state: "s", + }); + + expect(ctx.attributes.ownerFaydaSub).toBe("new-sub"); + expect(ctx.attributes.ownerName).toBe("Haile Gebrselassie"); + expect(state.owner.verified).toBe(true); + }); + + it("fills every PoA detail from the payload, address included", async () => { + const { service, ctx } = makeService(); + + await service.completeIdentityVerification("user-1", { + subject: "poa", + code: "c", + state: "s", + }); + + expect(ctx.attributes.poaName).toBe("Haile Gebrselassie"); + expect(ctx.attributes.poaEmail).toBe("haile@example.com"); + expect(ctx.attributes.poaPhone).toBe("+251922000000"); + expect(ctx.attributes.poaAddress).toBe("Addis Ababa"); + }); + + it("verifies successfully even though Fayda returns no national ID number", async () => { + // Fayda's userinfo carries no FAN/FIN claim at all — this must be the + // normal, successful path, not an error. + const { service } = makeService({ + verification: { + purpose: "VERIFY", + verified: true, + sub: "x", + fullName: "No Fan Here", + }, + }); + + const state = await service.completeIdentityVerification("user-1", { + subject: "owner", + code: "c", + state: "s", + }); + + expect(state.owner.verified).toBe(true); + }); + + it("refuses to make one identity both owner and PoA", async () => { + const { service } = makeService({ + attributes: { ownerFaydaSub: "same-person" }, + verification: { + purpose: "VERIFY", + verified: true, + sub: "same-person", + fullName: "Abebe Bikila", + }, + }); + + await expect( + service.completeIdentityVerification("user-1", { + subject: "poa", + code: "c", + state: "s", + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("stages an owner re-verification for review on an approved company", async () => { + // The owner is the live company's identity proof, so re-verifying one is + // exactly what the backoffice review exists for: it must not rewrite the + // row directly. + const { service, ctx, deps } = makeService({ + status: CompanyStatus.Active, + }); + + await service.completeIdentityVerification("user-1", { + subject: "owner", + code: "c", + state: "s", + }); + + expect(deps.changeRequestRepo.create).toHaveBeenCalled(); + expect(ctx.attributes.ownerFaydaSub).toBeUndefined(); + }); + + it("applies a PoA verification live on an approved company", async () => { + // The PoA is personnel the company names for itself — the delegation paper + // is what a reviewer actually judges — so it does not go to review. + const { service, ctx, deps } = makeService({ + status: CompanyStatus.Active, + }); + + await service.completeIdentityVerification("user-1", { + subject: "poa", + code: "c", + state: "s", + }); + + expect(deps.changeRequestRepo.create).not.toHaveBeenCalled(); + expect(ctx.attributes.poaFaydaSub).toBe("new-sub"); + }); + + it("refuses to rename a verified person by hand", async () => { + const { service } = makeService({ + attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED }, + files: [paper()], + }); + + await expect( + service.updateProfile("user-1", { poaName: "Someone Else" } as never), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("never locks or gates the general manager — it is not the verified subject", async () => { + // GM is a plain typed role; the portal offers a "same as owner" copy, but + // the backend must not treat it as identity-owned or require it verified. + const { service } = makeService({ + attributes: { ...OWNER_VERIFIED }, + }); + + await expect( + service.updateProfile("user-1", { + generalManagerName: "Someone Else", + generalManagerEmail: "someone@example.com", + generalManagerPhone: "+251911223344", + } as never), + ).resolves.toBeDefined(); + }); +}); + +describe("Ethiopian companies verify with Fayda; foreign companies verify identity by passport", () => { + // The company is applying for the forwarder role, so it must not already + // hold it — createCompanyProfileForUser short-circuits on an existing profile + // and would never reach the gate. + const applyingForFf = { + profileTypes: [ProfileType.importer], + attributes: { ...POA_VERIFIED }, + files: [paper()], + }; + + it("blocks the forwarder role while the owner is unverified", async () => { + const { service } = makeService(applyingForFf); + + await expect( + service.createCompanyProfileForUser( + "user-1", + ProfileType.freightForwarder, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("blocks the forwarder role while the PoA is unverified", async () => { + const { service } = makeService({ + profileTypes: [ProfileType.importer], + attributes: { + ...OWNER_VERIFIED, + poaName: "Tirunesh Dibaba", + poaEmail: "t@example.com", + poaPhone: "+251911000000", + }, + files: [paper()], + }); + + await expect( + service.createCompanyProfileForUser( + "user-1", + ProfileType.freightForwarder, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("grants the forwarder role once owner and PoA are both verified", async () => { + const { service } = makeService({ + profileTypes: [ProfileType.importer], + attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED }, + files: [paper()], + }); + + await expect( + service.createCompanyProfileForUser( + "user-1", + ProfileType.freightForwarder, + ), + ).resolves.toBeDefined(); + }); + + it("never asks a foreign company for Fayda, verified or not", async () => { + const { service } = makeService({ + nationality: CompanyNationality.Foreign, + }); + + const state = await service.completeIdentityVerification("user-1", { + subject: "owner", + code: "c", + state: "s", + }); + + // Still lets the owner verify — a foreign owner verifying is allowed, just + // never required — but the passport is the thing that actually gates it. + expect(state.owner.verified).toBe(true); + expect(state.faydaRequired).toBe(false); + expect(state.passportRequired).toBe(true); + }); + + it("blocks the forwarder role for a foreign company with no owner passport", async () => { + const { service } = makeService({ + profileTypes: [ProfileType.importer], + nationality: CompanyNationality.Foreign, + attributes: { + poaName: "Jean Dupont", + poaEmail: "jean@example.com", + poaPhone: "+33100000000", + }, + }); + + await expect( + service.createCompanyProfileForUser( + "user-1", + ProfileType.freightForwarder, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("grants the forwarder role to a foreign company whose owner has a passport and whose PoA is Fayda-verified", async () => { + const { service } = makeService({ + profileTypes: [ProfileType.importer], + nationality: CompanyNationality.Foreign, + attributes: { + ownerPassportNumber: "P1234567", + ...POA_VERIFIED, + }, + files: [paper()], + }); + + await expect( + service.createCompanyProfileForUser( + "user-1", + ProfileType.freightForwarder, + ), + ).resolves.toBeDefined(); + }); + + it("still requires a Fayda-verified PoA from a foreign company", async () => { + // The owner's credential is nationality-specific; the representative's is + // not. A PoA acts for the company inside Ethiopia whoever owns it, so a + // typed foreign name is not a representative the platform can accept. + const { service } = makeService({ + profileTypes: [ProfileType.importer], + nationality: CompanyNationality.Foreign, + attributes: { + ownerPassportNumber: "P1234567", + poaName: "Jean Dupont", + poaEmail: "jean@example.com", + poaPhone: "+33100000000", + }, + files: [paper()], + }); + + await expect( + service.createCompanyProfileForUser( + "user-1", + ProfileType.freightForwarder, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("still requires the passport for a foreign owner who chose to verify with Fayda too", async () => { + // Verifying is optional for a foreign owner, but it does not waive the + // passport requirement — the two are independent credentials. + const { service } = makeService({ + profileTypes: [ProfileType.importer], + nationality: CompanyNationality.Foreign, + attributes: { + ...OWNER_VERIFIED, + poaName: "Jean Dupont", + poaEmail: "jean@example.com", + poaPhone: "+33100000000", + }, + }); + + await expect( + service.createCompanyProfileForUser( + "user-1", + ProfileType.freightForwarder, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); +}); diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts index 73826689a..450222657 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.module.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -20,6 +20,7 @@ import { CompanyProfileRepository } from "./company-profile.repository"; import { CompanyChangeRequestRepository } from "./company-change-request.repository"; import { ETradeService } from "./services/etrade.service"; import { CompanyNotifierService } from "./company-notifier.service"; +import { VerifaydaModule } from "../verifayda/verifayda.module"; @Module({ imports: [ @@ -38,6 +39,8 @@ import { CompanyNotifierService } from "./company-notifier.service"; // imports this module back for portal recipient targeting, hence forwardRef. NotificationsModule, forwardRef(() => NotificationInboxModule), + // Fayda identity verification for the company's owner and PoA. + VerifaydaModule, ], controllers: [CompaniesController], providers: [ diff --git a/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts new file mode 100644 index 000000000..27c42e581 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts @@ -0,0 +1,245 @@ +import { BadRequestException } from "@nestjs/common"; + +import { CompaniesService } from "./companies.service"; +import { CompanyStatus } from "./entities/company.entity"; +import { ProfileType } from "./entities/company-profile.entity"; +import { POA_DELEGATION_FILE_KEY } from "../file-upload-settings/poa-delegation.constants"; + +/** + * EDRFREIGHT-358: a company that names a Power of Attorney must have the DARS + * delegation paper on file. The rule used to live only in the onboarding + * wizard's completion check, so every other write that could break the pairing + * — saving PoA details, deleting the paper, picking up the forwarder role — + * went unguarded. These cover those writes. + */ + +interface Ctx { + attributes: Record; + files: { id: string; code: string; reviewStatus?: string | null }[]; + profileTypes: ProfileType[]; + status: CompanyStatus; + pendingSnapshot: Record | null; +} + +const POA = { poaName: "Abebe", poaEmail: "a@b.com", poaPhone: "+251911000000" }; + +/** + * The forwarder role is gated on Fayda-verified identities as well as on the + * delegation paper. These tests are about the paper, so they run against a + * company whose identities are already verified — the identity rule itself is + * covered in companies.fayda-identity.spec.ts. + */ +const VERIFIED_IDENTITIES = { + ownerFaydaSub: "owner-sub", + poaFaydaSub: "poa-sub", +}; + +function makeService(overrides: Partial = {}) { + const ctx: Ctx = { + attributes: {}, + files: [], + profileTypes: [ProfileType.importer], + status: CompanyStatus.Pending, + pendingSnapshot: null, + ...overrides, + }; + + const company = () => ({ + id: "company-1", + status: ctx.status, + attributes: ctx.attributes, + companyProfiles: ctx.profileTypes.map((type, i) => ({ + id: `profile-${i}`, + type, + })), + type: "customer", + }); + + const deps = { + companiesRepo: { + findById: jest.fn(async () => company()), + update: jest.fn(async (_id: string, patch: Record) => { + ctx.attributes = (patch.attributes ?? + ctx.attributes) as Record; + return company(); + }), + findByTin: jest.fn(async () => null), + }, + companyProfilesRepo: { + findByCompanyId: jest.fn(async () => + ctx.profileTypes.map((type, i) => ({ id: `profile-${i}`, type })), + ), + findByType: jest.fn(async (_id: string, type: ProfileType) => + ctx.profileTypes.includes(type) ? { id: "existing", type } : null, + ), + create: jest.fn(async (row: Record) => ({ + id: "new", + ...row, + })), + }, + changeRequestRepo: { + findPendingByCompanyId: jest.fn(async () => + ctx.pendingSnapshot ? { id: "cr-1", snapshot: ctx.pendingSnapshot } : null, + ), + findLatestOpenByCompanyId: jest.fn(async () => + ctx.pendingSnapshot ? { id: "cr-1", snapshot: ctx.pendingSnapshot } : null, + ), + findByCompanyId: jest.fn(async () => []), + create: jest.fn(async (row: Record) => ({ + id: "cr-1", + ...row, + })), + update: jest.fn(async () => ({ id: "cr-1" })), + }, + profilesRepo: { + findByCompanyId: jest.fn(async () => []), + findByUserId: jest.fn(async () => ({ + id: "external-1", + companyId: "company-1", + company: company(), + onboardingCompleted: false, + })), + }, + filesService: { + findByResource: jest.fn(async () => ctx.files), + findById: jest.fn(async (id: string) => + ctx.files.find((f) => f.id === id) + ? { + ...ctx.files.find((f) => f.id === id), + resource: "companies", + resourceId: "company-1", + name: "dars.pdf", + } + : null, + ), + remove: jest.fn(async () => undefined), + }, + companyNotifier: { changeRequestSubmitted: jest.fn() }, + }; + + const service = new CompaniesService( + deps.companiesRepo as never, + deps.companyProfilesRepo as never, + deps.changeRequestRepo as never, + deps.profilesRepo as never, + {} as never, + deps.filesService as never, + {} as never, + {} as never, + deps.companyNotifier as never, + {} as never, + {} as never, + ); + + // getCompanyInfoByUserId does its own lookups; the stubs above are enough for + // the PoA paths, so short-circuit it rather than mock the whole graph. + jest + .spyOn(service, "getCompanyInfoByUserId") + .mockImplementation( + async () => + ({ profile: { id: "external-1" }, company: company() }) as never, + ); + + return { service, ctx, deps }; +} + +const paper = (reviewStatus: string | null = null) => ({ + id: "file-1", + code: POA_DELEGATION_FILE_KEY, + reviewStatus, +}); + +describe("PoA delegation paper is enforced wherever PoA state changes", () => { + it("rejects PoA details saved with no paper on file", async () => { + const { service } = makeService(); + + await expect( + service.updateProfile("user-1", POA as never), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("accepts PoA details once the paper is on file", async () => { + const { service } = makeService({ files: [paper()] }); + + await expect( + service.updateProfile("user-1", POA as never), + ).resolves.toBeDefined(); + }); + + it("rejects a paper the reviewer sent back for correction", async () => { + const { service } = makeService({ files: [paper("change_requested")] }); + + await expect( + service.updateProfile("user-1", POA as never), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("leaves edits that don't touch the PoA alone", async () => { + // A company carrying legacy details must not be locked out of every other + // field until it produces a paper. + const { service } = makeService({ attributes: { ...POA }, files: [] }); + + await expect( + service.updateProfile("user-1", { companyEmail: "x@y.com" } as never), + ).resolves.toBeDefined(); + }); + + it("refuses to remove the paper while the PoA is still named", async () => { + const { service } = makeService({ + attributes: { ...POA }, + files: [paper()], + }); + + await expect( + service.removePoaDelegationLetter("user-1", "file-1"), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("allows removing the paper once the PoA has been cleared", async () => { + const { service } = makeService({ attributes: {}, files: [paper()] }); + + await expect( + service.removePoaDelegationLetter("user-1", "file-1"), + ).resolves.toBeDefined(); + }); + + it("judges the removal against a staged clear, not the live row", async () => { + // An Active company's edits are staged for review rather than written, so + // the live attributes still carry the PoA the customer just cleared. + const { service } = makeService({ + status: CompanyStatus.Active, + attributes: { ...POA }, + pendingSnapshot: { poaName: "", poaEmail: "", poaPhone: "" }, + files: [paper()], + }); + + await expect( + service.removePoaDelegationLetter("user-1", "file-1"), + ).resolves.toBeDefined(); + }); + + it("refuses the forwarder role to a company with no PoA", async () => { + const { service } = makeService({ attributes: { ...VERIFIED_IDENTITIES } }); + + await expect( + service.createCompanyProfileForUser( + "user-1", + ProfileType.freightForwarder, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("grants the forwarder role once PoA details and paper are both in place", async () => { + const { service } = makeService({ + attributes: { ...POA, ...VERIFIED_IDENTITIES }, + files: [paper()], + }); + + await expect( + service.createCompanyProfileForUser( + "user-1", + ProfileType.freightForwarder, + ), + ).resolves.toBeDefined(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/companies/companies.repository.ts b/apps/edr-freight-api/src/modules/companies/companies.repository.ts index db8db0d2e..092ebc2c4 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.repository.ts @@ -75,8 +75,14 @@ export class CompaniesRepository extends BaseRepository { .getMany(); } - async existsByTin(tin: string): Promise { - const count = await this.repository.count({ where: { tin } as any }); + async existsByTin(tin: string, excludeCompanyId?: string): Promise { + const qb = this.repository + .createQueryBuilder('company') + .where('company.tin = :tin', { tin }); + if (excludeCompanyId) { + qb.andWhere('company.id != :excludeCompanyId', { excludeCompanyId }); + } + const count = await qb.getCount(); return count > 0; } diff --git a/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts new file mode 100644 index 000000000..f22123f61 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts @@ -0,0 +1,113 @@ +import { CompaniesService } from "./companies.service"; +import { CompanyType } from "./entities/company.entity"; +import { ProfileStatus, ProfileType } from "./entities/company-profile.entity"; + +/** + * EDRFREIGHT-416: onboarding asked for a deselected role's documents. + * + * Re-running role selection used to only ADD operational profiles, so a role + * the user unticked on the way back left its company_profile row behind — and + * every role-driven requirement (business license, forwarder PoA) is derived + * from those rows. startOnboarding now reconciles both directions. + */ + +interface ExistingProfile { + id: string; + type: ProfileType; + status: ProfileStatus; +} + +function makeService(existing: ExistingProfile[]) { + const companyProfilesRepo = { + findByCompanyId: jest.fn(async () => existing), + create: jest.fn(async (row: Record) => ({ + id: "new", + ...row, + })), + softDelete: jest.fn(async () => undefined), + }; + const companiesRepo = { update: jest.fn(async () => null) }; + const profilesRepo = { + findByUserId: jest.fn(async () => ({ + id: "external-1", + companyId: "company-1", + company: { id: "company-1" }, + })), + }; + + const service = new CompaniesService( + companiesRepo as never, + companyProfilesRepo as never, + {} as never, + profilesRepo as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + ); + + jest + .spyOn(service, "getCompanyInfoByUserId") + .mockImplementation( + async () => + ({ profile: { id: "external-1" }, company: { id: "company-1" } }) as never, + ); + + return { service, companyProfilesRepo }; +} + +const identity = { userId: "user-1", firstName: "Abebe", lastName: "K" }; + +const start = (service: CompaniesService, roles: ProfileType[]) => + service.startOnboarding(identity as never, CompanyType.Customer, roles); + +describe("re-running role selection reconciles the operational profiles", () => { + it("drops the profile for a role the user deselected", async () => { + const { service, companyProfilesRepo } = makeService([ + { id: "p-imp", type: ProfileType.importer, status: ProfileStatus.Pending }, + { + id: "p-ff", + type: ProfileType.freightForwarder, + status: ProfileStatus.Pending, + }, + ]); + + await start(service, [ProfileType.importer]); + + expect(companyProfilesRepo.softDelete).toHaveBeenCalledWith("p-ff"); + expect(companyProfilesRepo.softDelete).toHaveBeenCalledTimes(1); + expect(companyProfilesRepo.create).not.toHaveBeenCalled(); + }); + + it("keeps an already-approved profile even when it is unticked", async () => { + const { service, companyProfilesRepo } = makeService([ + { id: "p-imp", type: ProfileType.importer, status: ProfileStatus.Pending }, + { + id: "p-exp", + type: ProfileType.exporter, + status: ProfileStatus.Active, + }, + ]); + + await start(service, [ProfileType.importer]); + + expect(companyProfilesRepo.softDelete).not.toHaveBeenCalled(); + }); + + it("still adds a newly-picked role", async () => { + const { service, companyProfilesRepo } = makeService([ + { id: "p-imp", type: ProfileType.importer, status: ProfileStatus.Pending }, + ]); + + await start(service, [ProfileType.importer, ProfileType.exporter]); + + expect(companyProfilesRepo.softDelete).not.toHaveBeenCalled(); + expect(companyProfilesRepo.create).toHaveBeenCalledTimes(1); + expect(companyProfilesRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ type: ProfileType.exporter }), + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 6650dfca5..c198d149f 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -17,10 +17,23 @@ import { import { FilesService } from "../files/files.service"; import { FileRecord } from "../files/entities/file.entity"; import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service"; +import { + POA_DELEGATION_FILE_KEY, + POA_DELEGATION_LABEL, + POA_DELEGATION_PENDING_CODE, +} from "../file-upload-settings/poa-delegation.constants"; +import { VerifaydaService } from "../verifayda/verifayda.service"; +import { + buildCompanyIdentityState, + CompanyIdentityStateDto, + CompleteIdentityVerificationDto, + IdentitySubject, +} from "./dto/complete-identity-verification.dto"; import { ETradeService } from "./services/etrade.service"; import { CompanyNotifierService } from "./company-notifier.service"; import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto"; import { normalizeE164 } from "../../common/validators/is-phone-number.validator"; +import type { CompanyRegistrationData } from "@edr/types"; import { CreateCompanyDto } from "./dto/create-company.dto"; import { UpdateCompanyDto } from "./dto/update-company.dto"; import { CreateExternalProfileDto } from "./dto/create-external-profile.dto"; @@ -58,10 +71,6 @@ const LICENSE_CODE = "business_license"; /** Code for a license file staged in an open change request (not yet live). */ const LICENSE_PENDING_CODE = "business_license_pending"; -/** Mirrors the field seeded in seed/file-upload-settings.seeder.ts. */ -const POA_DELEGATION_FILE_KEY = "poa_delegation_letter"; -/** Code for a PoA letter staged in an open change request (not yet live). */ -const POA_DELEGATION_PENDING_CODE = "poa_delegation_letter_pending"; /** FileRecord resource that company-level documents are stored under. */ const COMPANY_RESOURCE = "companies"; /** company.attributes keys that together mean "a PoA was entered". */ @@ -72,6 +81,28 @@ const POA_ATTRIBUTES = [ "poaLocation", "poaAddress", ] as const; +/** + * Personnel an approved company maintains itself: its contact person, its + * general manager and its Power of Attorney. These name who to talk to, not + * what the company is allowed to do, so freezing the settings page until a + * reviewer gets to a new phone number costs more than it protects. They write + * straight to the live row even for an active company. + * + * The PoA's *delegation letter* is deliberately not here — the paper is the + * thing that actually evidences the delegation, so it still goes through + * review (see `uploadPoaDelegationLetter`), as does the owner's own identity. + */ +const SELF_SERVICE_ATTRIBUTES: readonly string[] = [ + "contactPersonName", + "contactPersonPosition", + "contactPersonEmail", + "contactPersonPhone", + "contactVerifiedPhone", + "generalManagerName", + "generalManagerEmail", + "generalManagerPhone", + ...POA_ATTRIBUTES, +]; /** Mandatory once the company operates as a freight forwarder. */ const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [ { key: "poaName", label: "PoA name" }, @@ -79,6 +110,62 @@ const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [ { key: "poaPhone", label: "PoA phone" }, ]; +/** + * `attributes` key prefix per verifiable person. The owner is NOT the general + * manager — GM is a plain typed role (the portal offers a "same as owner" copy + * once the owner is verified), while the owner is who this verification + * actually proves. They're very often the same human; that's what the copy is + * for. + */ +const IDENTITY_PREFIX: Record = { + owner: "owner", + poa: "poa", +}; + +const IDENTITY_LABEL: Record = { + owner: "owner", + poa: "Power of Attorney", +}; + +/** + * Identity fields a Fayda verification owns outright, per person. Once verified + * these can no longer be typed — the government IdP is the source, so an edit + * that disagrees with it is either a mistake or an attempt to launder the + * guarantee away. The GM fields are deliberately absent: GM is never itself + * Fayda-verified, so it stays freely editable regardless of the owner's state. + */ +const IDENTITY_OWNED_FIELDS: Record = { + owner: ["ownerName", "ownerEmail", "ownerPhone", "ownerAddress"], + poa: ["poaName", "poaEmail", "poaPhone", "poaAddress"], +}; + +/** + * `UpdateProfileDto` fields eTrade is the sole source of truth for. A request + * touching any of these must be re-checked against a fresh eTrade lookup — + * see `assertEtradeFieldsAuthentic`. + */ +const ETRADE_SOURCED_FIELDS = [ + "companyName", + "tin", + "licenceNumber", + "statusDescription", + "dateRegistered", + "renewedFrom", + "renewalDate", + "renewedTo", + "region", + "zone", + "woreda", + "kebele", + "houseNo", + "etradePhone", +] as const satisfies readonly (keyof UpdateProfileDto)[]; + +/** The attributes a verification writes, for one person. */ +interface VerifiedIdentityAttributes { + [key: string]: unknown; +} + export interface UserIdentity { userId: string; firstName: string; @@ -100,6 +187,7 @@ export class CompaniesService { private readonly etradeService: ETradeService, private readonly companyNotifier: CompanyNotifierService, private readonly dataSource: DataSource, + private readonly verifaydaService: VerifaydaService, ) { } /** @@ -255,8 +343,9 @@ export class CompaniesService { * chosen operational role(s) up front, so every subsequent wizard step can * save incrementally (PATCH /profile, /onboarding-step) against existing rows. * - * Idempotent: if the user already has a profile, returns it unchanged (only - * adding any newly-chosen roles). The draft company carries a placeholder TIN + * Idempotent: if the user already has a profile, returns it unchanged, with + * the operational profiles reconciled against the roles just chosen (added + * and — for still-pending ones — removed). The draft company carries a placeholder TIN * (the real one is filled on the Company Information step) and stays * status=pending / onboardingCompleted=false until the wizard finishes. */ @@ -271,7 +360,7 @@ export class CompaniesService { const existing = await this.profilesRepo.findByUserId(identity.userId); if (existing) { const companyId = existing.company?.id ?? existing.companyId; - await this.ensureCompanyProfiles(companyId, companyType, roles); + await this.syncCompanyProfiles(companyId, companyType, roles); if (nationality) { await this.companiesRepo.update(companyId, { nationality }); } @@ -302,25 +391,44 @@ export class CompaniesService { onboardingCompleted: false, }); - await this.ensureCompanyProfiles(company.id, companyType, chosenTypes); + await this.syncCompanyProfiles(company.id, companyType, chosenTypes); return this.getCompanyInfoByUserId(identity.userId); } - /** Create any of the requested operational profiles that don't exist yet. */ - private async ensureCompanyProfiles( + /** + * Reconcile the company's operational profiles with the roles the user has + * selected: create the missing ones, drop the ones they deselected. + * + * Dropping matters because every role-driven onboarding requirement — the + * per-profile business license, the freight-forwarder PoA rule, the license + * cards in the wizard — is derived from these rows. A row left behind after + * the user went back and unticked a role keeps asking for that role's + * documents (EDRFREIGHT-416). Only still-pending profiles are removed: an + * approved one is live (it can carry bookings and contracts) and re-running + * role selection must never delete it. + */ + private async syncCompanyProfiles( companyId: string, companyType: CompanyType, roles: ProfileType[], ): Promise { const allowedTypes = this.getProfileTypeForCompanyType(companyType); - for (const type of roles) { - if (!allowedTypes.includes(type)) continue; - const existing = await this.companyProfilesRepo.findByType( - companyId, - type, - ); - if (existing) continue; + const chosen = roles.filter((t) => allowedTypes.includes(t)); + const existing = await this.companyProfilesRepo.findByCompanyId(companyId); + + for (const profile of existing) { + if (chosen.includes(profile.type)) continue; + if (profile.status !== ProfileStatus.Pending) continue; + // The license files uploaded against this profile go with it: they are + // only ever read per company_profile id, so a soft-deleted profile + // leaves nothing behind to prompt for. Re-picking the role creates a + // fresh profile the user uploads against again. + await this.companyProfilesRepo.softDelete(profile.id); + } + + for (const type of chosen) { + if (existing.some((p) => p.type === type)) continue; // No reference yet — minted on backoffice approval (setCompanyProfileStatus). await this.companyProfilesRepo.create({ companyId, @@ -599,7 +707,9 @@ export class CompaniesService { */ private mapProfileDtoToCompanyUpdates( company: Company, - dto: Partial, + dto: Partial & { + faydaIdentity?: VerifiedIdentityAttributes; + }, ): Record { const companyUpdates: Record = {}; const attrUpdates: Record = { ...(company.attributes ?? {}) }; @@ -617,7 +727,6 @@ export class CompaniesService { if (dto.tin !== undefined && dto.tin !== company.tin) companyUpdates.tin = dto.tin; if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber; - if (dto.fanNumber !== undefined) companyUpdates.fanNumber = dto.fanNumber; if (dto.contactPersonName !== undefined) attrUpdates.contactPersonName = dto.contactPersonName; @@ -661,6 +770,72 @@ export class CompaniesService { if (dto.etradePhone !== undefined) companyUpdates.etradePhone = normalizeE164(dto.etradePhone); + // A plain typed field — never Fayda-verified, so no lock ever applies to + // it. Independent of the owner's verification: still required for a + // foreign company even if the owner also verifies with Fayda. + if (dto.ownerPassportNumber !== undefined) + attrUpdates.ownerPassportNumber = dto.ownerPassportNumber; + + // A verified identity overwrites the person's details. `faydaIdentity` + // never comes off the wire — the global validation pipe runs with + // forbidNonWhitelisted, so a client that sends it is rejected outright; it + // only reaches here from completeIdentityVerification, directly or through + // a staged snapshot. + if (dto.faydaIdentity) { + Object.assign(attrUpdates, dto.faydaIdentity); + } + + // companyEmail/companyPhone are the Company-column mirrors of the owner's + // verified contact details (the portal derives and submits them, it never + // lets the customer type them once verified) — lock them the same way + // ownerEmail/ownerPhone themselves are locked below, once there is a + // verified owner to lock them to. + if (attrUpdates.ownerFaydaSub) { + if ( + dto.companyEmail !== undefined && + dto.companyEmail !== attrUpdates.ownerEmail + ) { + throw new BadRequestException( + "companyEmail is set by the owner's Fayda verification and cannot be edited. Re-verify to change it.", + ); + } + if ( + dto.companyPhone !== undefined && + normalizeE164(dto.companyPhone) !== + normalizeE164(String(attrUpdates.ownerPhone ?? "")) + ) { + throw new BadRequestException( + "companyPhone is set by the owner's Fayda verification and cannot be edited. Re-verify to change it.", + ); + } + } + + // Renaming a Fayda-verified person by hand would launder the guarantee + // away, so the fields the verification owns are refused once it exists. + for (const subject of ["owner", "poa"] as IdentitySubject[]) { + if (!attrUpdates[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue; + for (const field of IDENTITY_OWNED_FIELDS[subject]) { + const incoming = (dto as Record)[field]; + if (incoming === undefined) continue; + // The verification itself is allowed to write them; anything else is + // compared against what is already stored, not against the value this + // same call just copied into the patch. Phones are compared normalized: + // a form that re-renders +251911000000 as 0911000000 is echoing the + // stored value back, not trying to change it. + if (dto.faydaIdentity && field in dto.faydaIdentity) continue; + const stored = company.attributes?.[field]; + const same = field.endsWith("Phone") + ? normalizeE164(String(incoming)) === + normalizeE164(String(stored ?? "")) + : incoming === stored; + if (!same) { + throw new BadRequestException( + `${field} is set by the Fayda verification of this company's ${IDENTITY_LABEL[subject]} and cannot be edited. Re-verify to change it.`, + ); + } + } + } + companyUpdates.attributes = attrUpdates; return companyUpdates; } @@ -691,10 +866,11 @@ export class CompaniesService { * * - Company not yet approved (onboarding) → write straight to the Company row, * as before. The company/role pending→approve gate already covers first-run. - * - Company already `active` → do NOT touch the live Company. Stage the edit in - * a pending change request (merging into any open one) so a backoffice - * reviewer can approve (apply) or reject (with a note). This locks the - * customer until the review resolves. + * - Company already `active` → personnel details (`SELF_SERVICE_ATTRIBUTES`) + * still write straight through; everything else does NOT touch the live + * Company but is staged in a pending change request (merging into any open + * one) so a backoffice reviewer can approve (apply) or reject (with a + * note). Only the staged half locks the customer until the review resolves. */ async updateProfile( userId: string, @@ -702,6 +878,21 @@ export class CompaniesService { ): Promise { const { profile, company } = await this.getCompanyInfoByUserId(userId); + await this.assertEtradeFieldsAuthentic(company, dto); + + // Naming (or renaming) a Power of Attorney is one of the writes that can + // leave the company with a representative and nothing evidencing them, so + // it is gated here. Edits that don't touch the PoA are left alone — a + // company carrying legacy details must not be locked out of every other + // field until it produces a paper. + if (POA_ATTRIBUTES.some((k) => dto[k] !== undefined)) { + const attributes = this.mapProfileDtoToCompanyUpdates(company, dto) + .attributes as Record; + await this.assertPoaDelegationSatisfied(company.id, attributes, { + requirePoa: await this.isFreightForwarder(company.id), + }); + } + if (company.status !== CompanyStatus.Active) { await this.assertTinAvailable(company, dto.tin); const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, dto); @@ -714,9 +905,37 @@ export class CompaniesService { return new ProfileResponseDto(profile, updated); } - // Approved company: stage the change for review, leaving the live row intact. + // Approved company: personnel details apply immediately, the rest is staged + // for review with the live row left intact. await this.assertTinAvailable(company, dto.tin); const fields = this.pickDefined(dto); + const selfService: Record = {}; + const staged: Record = {}; + for (const [key, value] of Object.entries(fields)) { + if (SELF_SERVICE_ATTRIBUTES.includes(key)) selfService[key] = value; + else staged[key] = value; + } + + let live = company; + if (Object.keys(selfService).length > 0) { + live = + (await this.companiesRepo.update( + company.id, + this.mapProfileDtoToCompanyUpdates(company, selfService), + )) ?? company; + live.companyProfiles = company.companyProfiles; + } + + if (Object.keys(staged).length === 0) { + // Nothing a reviewer needs to see. Any request already open (a document + // upload, an owner verification) still surfaces so its banner survives — + // it just no longer gains fields it was never asked to review. + return new ProfileResponseDto( + profile, + live, + await this.changeRequestRepo.findLatestOpenByCompanyId(company.id), + ); + } const existing = await this.changeRequestRepo.findPendingByCompanyId( company.id, @@ -726,7 +945,7 @@ export class CompaniesService { if (existing) { request = (await this.changeRequestRepo.update(existing.id, { - snapshot: { ...(existing.snapshot ?? {}), ...fields }, + snapshot: { ...(existing.snapshot ?? {}), ...staged }, submittedBy: userId, submittedAt: now, note: null, @@ -742,7 +961,7 @@ export class CompaniesService { ); request = await this.changeRequestRepo.create({ companyId: company.id, - snapshot: fields, + snapshot: staged, status: ChangeRequestStatus.Pending, submittedBy: userId, submittedAt: now, @@ -754,8 +973,9 @@ export class CompaniesService { ); } - // Live company is unchanged; surface the pending state for the settings page. - return new ProfileResponseDto(profile, company, request); + // Only the personnel half (if any) landed; surface the pending state for + // the settings page. + return new ProfileResponseDto(profile, live, request); } /** List a company's change requests, newest first (backoffice review). */ @@ -1127,11 +1347,24 @@ export class CompaniesService { // blacklist skip all this — staff must always be able to act against a bad // account. return this.dataSource.transaction(async (manager) => { - await manager.findOne(Company, { + const company = await manager.findOne(Company, { where: { id: existing.companyId }, lock: { mode: "pessimistic_write" }, }); + // Putting a forwarder into service without a Power of Attorney backed by + // a DARS paper is the thing EDRFREIGHT-358 forbids, so the approval is + // the last place it has to be checked — the role may have been applied + // for before the paper was withdrawn. + if (company && existing.type === ProfileType.freightForwarder) { + this.assertIdentityVerified(company, { requirePoa: true }); + await this.assertPoaDelegationSatisfied( + company.id, + company.attributes, + { requirePoa: true }, + ); + } + const [companyDocs, profileDocs] = await Promise.all([ this.filesService.findWithOpenChangeRequest( [existing.companyId], @@ -1382,6 +1615,18 @@ export class CompaniesService { ); if (existing) continue; + // A forwarder signs on other companies' behalf, so it cannot be taken on + // without a Power of Attorney and its DARS paper — checked here so the + // customer is told at the point of asking, not at review. + if (type === ProfileType.freightForwarder) { + this.assertIdentityVerified(company, { requirePoa: true }); + await this.assertPoaDelegationSatisfied( + companyId, + await this.effectivePoaAttributes(company), + { requirePoa: true }, + ); + } + // Self-service role adds start Pending and carry no reference — a reference // is minted only when a backoffice reviewer approves the role. await this.companyProfilesRepo.create({ @@ -1419,6 +1664,14 @@ export class CompaniesService { } let created = await this.companyProfilesRepo.findByType(companyId, type); + if (!created && type === ProfileType.freightForwarder) { + this.assertIdentityVerified(company, { requirePoa: true }); + await this.assertPoaDelegationSatisfied( + companyId, + await this.effectivePoaAttributes(company), + { requirePoa: true }, + ); + } if (!created) { // New self-service roles start Pending (awaiting backoffice approval) and // carry no reference until approved. @@ -1453,11 +1706,17 @@ export class CompaniesService { userId: string, ): Promise { const { profile, company } = await this.getCompanyInfoByUserId(userId); + const identity = this.getCompanyIdentityState(company); - // 1. Required company-information fields. - const missingInfo = this.REQUIRED_COMPANY_INFO.filter( - (f) => !f.get(company), - ).map((f) => ({ key: f.key, label: f.label })); + // 1. Required company-information fields. The FAN is never one of them — + // Fayda verification doesn't produce a FAN, so it's never collected as + // part of onboarding at all (see the identity block below). + const requiredInfo = this.REQUIRED_COMPANY_INFO.filter( + (f) => f.key !== "fanNumber", + ); + const missingInfo = requiredInfo + .filter((f) => !f.get(company)) + .map((f) => ({ key: f.key, label: f.label })); // 2. Nationality-based company documents + which are already uploaded. const documentSettingCode = this.documentSettingCodeFor(company.nationality); @@ -1504,26 +1763,26 @@ export class CompaniesService { // 4. Power of Attorney. Optional in general, but a freight forwarder acts on // other companies' behalf so its PoA is mandatory. Either way, a PoA that - // has been entered must be evidenced by the delegation letter. + // has been entered must be evidenced by the DARS delegation paper — a legal + // requirement, so unlike the documents above it does not depend on the + // upload set carrying a field for it (see poa-delegation.constants.ts). const poaRequired = (company.companyProfiles ?? []).some( (p) => p.type === ProfileType.freightForwarder, ); const poaProvided = POA_ATTRIBUTES.some((k) => (company.attributes?.[k] as string | undefined)?.trim(), ); - const missingPoaFields = poaRequired - ? REQUIRED_POA_FIELDS.filter( - (f) => !(company.attributes?.[f.key] as string | undefined)?.trim(), - ) - : []; - // Only gate on the letter once the document set actually carries the field. - const delegationField = (setting?.fields ?? []).find( - (f) => f.fileKey === POA_DELEGATION_FILE_KEY, - ); - const missingDelegation = - Boolean(delegationField) && - (poaRequired || poaProvided) && - !uploadedCodes.has(POA_DELEGATION_FILE_KEY); + // No company types its PoA details — they arrive from the Fayda + // verification whatever the nationality — so reporting them as missing + // fields would ask for something no form offers. The identity block below + // reports "verify your PoA" instead. + const missingPoaFields: typeof REQUIRED_POA_FIELDS = []; + const delegation = await this.getPoaDelegationState(company.id); + const delegationDue = poaRequired || poaProvided; + const missingDelegation = delegationDue && !delegation.onFile; + // A paper the reviewer sent back is not evidence — the customer has to + // replace it before the application counts as complete. + const flaggedDelegation = delegationDue && delegation.flagged; const outstanding = [ ...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`), @@ -1534,29 +1793,54 @@ export class CompaniesService { ), ...missingPoaFields.map((f) => `Add your ${f.label.toLowerCase()}`), ...(missingDelegation - ? ["Upload the delegation letter for your Power of Attorney"] + ? [`Upload the ${POA_DELEGATION_LABEL} for your Power of Attorney`] + : []), + ...(flaggedDelegation + ? [`Re-upload your ${POA_DELEGATION_LABEL} — EDR asked for a correction`] + : []), + ...(identity.faydaRequired && !identity.owner.verified + ? ["Verify the company owner's identity with Fayda"] + : []), + ...((poaRequired || poaProvided) && !identity.poa.verified + ? ["Verify your Power of Attorney's identity with Fayda"] + : []), + ...(identity.passportRequired && !identity.owner.passportNumber + ? ["Add the company owner's passport number"] : []), ]; // Progress spans every required item the user has to satisfy: company-info // fields, required documents, one license per operational profile, and the - // PoA details/letter whenever those are mandatory. + // PoA details/paper whenever those are mandatory. const requiredDocCount = documents.filter((d) => d.isRequired).length; - const poaItemCount = - (poaRequired ? REQUIRED_POA_FIELDS.length : 0) + - (delegationField && (poaRequired || poaProvided) ? 1 : 0); + const poaItemCount = delegationDue ? 1 : 0; + // One item per identity credential the company has to prove: the owner + // always (Fayda for Ethiopian, passport for foreign), plus the PoA once + // there is one — that one is Fayda whatever the nationality. + const ownerCredentialDue = + identity.faydaRequired || identity.passportRequired; + const ownerCredentialProven = identity.faydaRequired + ? identity.owner.verified + : Boolean(identity.owner.passportNumber); + const identityItemCount = + (ownerCredentialDue ? 1 : 0) + (delegationDue ? 1 : 0); + const missingIdentityCount = + (ownerCredentialDue && !ownerCredentialProven ? 1 : 0) + + (delegationDue && !identity.poa.verified ? 1 : 0); const total = - this.REQUIRED_COMPANY_INFO.length + + requiredInfo.length + requiredDocCount + licenseProfiles.length + - poaItemCount; + poaItemCount + + identityItemCount; const completed = total - (missingInfo.length + missingDocs.length + missingLicenses.length + missingPoaFields.length + - (missingDelegation ? 1 : 0)); + (missingDelegation || flaggedDelegation ? 1 : 0) + + missingIdentityCount); return new OnboardingRequirementsResponseDto({ documentSettingCode, @@ -1567,10 +1851,15 @@ export class CompaniesService { poa: { required: poaRequired, provided: poaProvided, - delegationLetterUploaded: uploadedCodes.has(POA_DELEGATION_FILE_KEY), + delegationLetterUploaded: delegation.onFile, + delegationLetterFlagged: delegation.flagged, missingFields: missingPoaFields, - complete: missingPoaFields.length === 0 && !missingDelegation, + complete: + missingPoaFields.length === 0 && + !missingDelegation && + !flaggedDelegation, }, + identity, progress: { completed, total }, isComplete: outstanding.length === 0, onboardingCompleted: profile.onboardingCompleted, @@ -2044,15 +2333,352 @@ export class CompaniesService { } // --------------------------------------------------------------------------- - // Power of Attorney delegation letter + // Power of Attorney delegation paper (DARS) // // A company-level document that follows the same staged-review model as the // business license: on an approved (Active) company an upload lands under the - // pending code and the live letter is flagged for removal, so the reviewer + // pending code and the live paper is flagged for removal, so the reviewer // sees both and approval swaps them atomically. During onboarding it goes live. // --------------------------------------------------------------------------- - /** The company's PoA letter(s), with each file's review status resolved. */ + /** + * What the company has on file towards its DARS delegation paper. A paper + * staged for review counts as "on file" — it is the customer's whole + * obligation discharged; whether it is good enough is the reviewer's call, + * recorded as `flagged`. + */ + private async getPoaDelegationState( + companyId: string, + ignoreFileIds: string[] = [], + ): Promise<{ onFile: boolean; flagged: boolean }> { + const records = ( + await this.filesService.findByResource(companyId, COMPANY_RESOURCE) + ).filter( + (r) => + (r.code === POA_DELEGATION_FILE_KEY || + r.code === POA_DELEGATION_PENDING_CODE) && + !ignoreFileIds.includes(r.id), + ); + return { + onFile: records.length > 0, + flagged: records.some((r) => r.reviewStatus === "change_requested"), + }; + } + + /** + * The rule behind EDRFREIGHT-358: a company that names a Power of Attorney + * must evidence it with a DARS delegation paper, and a freight forwarder — + * which signs on other companies' behalf — must have both, verified. + * + * This is enforced at every write that can break the pairing (PoA details + * saved, paper removed, forwarder role applied for or approved) rather than + * only at onboarding submission, which is what let a company that finished + * onboarding as an importer pick up the forwarder role with neither. + * + * `attributes` is the state being written, which is not always the state on + * the row yet — a staged change request carries it, and a removal has to be + * judged against the files that would survive it (`ignoreFileIds`). + */ + private async assertPoaDelegationSatisfied( + companyId: string, + attributes: Record | null | undefined, + opts: { requirePoa: boolean; ignoreFileIds?: string[] }, + ): Promise { + const read = (key: string) => + (attributes?.[key] as string | undefined)?.trim(); + const poaProvided = POA_ATTRIBUTES.some((k) => read(k)); + if (!opts.requirePoa && !poaProvided) return; + + if (opts.requirePoa) { + const missing = REQUIRED_POA_FIELDS.filter((f) => !read(f.key)); + if (missing.length > 0) { + throw new BadRequestException( + `A freight forwarder acts on other companies' behalf, so a Power of Attorney is required. ` + + `Add the ${missing.map((f) => f.label.toLowerCase()).join(", ")} first.`, + ); + } + } + + const { onFile, flagged } = await this.getPoaDelegationState( + companyId, + opts.ignoreFileIds, + ); + if (!onFile) { + throw new BadRequestException( + `Upload the ${POA_DELEGATION_LABEL} for the Power of Attorney` + + (opts.requirePoa ? " — it is required for freight forwarders." : "."), + ); + } + if (flagged) { + throw new BadRequestException( + `The ${POA_DELEGATION_LABEL} on file needs to be corrected. ` + + `Re-upload it before continuing.`, + ); + } + } + + /** Does this company operate as a freight forwarder? */ + private async isFreightForwarder(companyId: string): Promise { + const profiles = await this.companyProfilesRepo.findByCompanyId(companyId); + return profiles.some((p) => p.type === ProfileType.freightForwarder); + } + + // --------------------------------------------------------------------------- + // Fayda identity verification (owner / PoA) + // + // A completed VeriFayda verification proves a person's name, phone, email + // and address — Fayda's userinfo carries no national ID number, so none of + // that is collected here. For an Ethiopian company both the owner and its + // PoA (once named) must be verified before the company can trade. Fayda is + // an Ethiopian national ID system, so a foreign company's owner proves + // identity with a typed passport number instead — required on its own + // terms, not waived by an owner who happens to verify with Fayda too. + // --------------------------------------------------------------------------- + + /** + * Verification state for both people, plus whether it is mandatory here. + * `complete` answers the gate question directly so the portal, the onboarding + * requirements and the assertions below all read the same verdict — the + * derivation itself is shared with ProfileResponseDto. + */ + getCompanyIdentityState(company: Company): CompanyIdentityStateDto { + return buildCompanyIdentityState(company); + } + + /** + * Complete a Fayda verification and bind the identity to one of the company's + * people. The portal starts the flow through the shared + * `POST /fayda/verification/start` and only tells us which person it was for + * here, at completion — so the verifayda module stays generic and its session + * table needs no company-specific column. + */ + async completeIdentityVerification( + userId: string, + dto: CompleteIdentityVerificationDto, + ): Promise { + const { company } = await this.getCompanyInfoByUserId(userId); + const prefix = IDENTITY_PREFIX[dto.subject]; + + const result = await this.verifaydaService.completeVerification({ + code: dto.code, + state: dto.state, + }); + if (!result.verified || !result.sub) { + throw new BadRequestException( + "Fayda could not verify this identity. Start the verification again.", + ); + } + + // The owner delegating power of attorney to themselves is not a + // delegation — it would let one identity satisfy both halves of the check. + const other: IdentitySubject = dto.subject === "poa" ? "owner" : "poa"; + const otherSub = company.attributes?.[`${IDENTITY_PREFIX[other]}FaydaSub`]; + if (otherSub && otherSub === result.sub) { + throw new BadRequestException( + `This identity is already registered as the company's ${IDENTITY_LABEL[other]}. The Power of Attorney must be a different person from the owner.`, + ); + } + + const now = new Date().toISOString(); + const identity: VerifiedIdentityAttributes = { + [`${prefix}FaydaSub`]: result.sub, + [`${prefix}FaydaVerifiedAt`]: now, + [`${prefix}Birthdate`]: result.birthdate ?? null, + [`${prefix}Gender`]: result.gender ?? null, + // The verified payload owns the person's details from here on. + ...(result.fullName ? { [`${prefix}Name`]: result.fullName } : {}), + ...(result.email ? { [`${prefix}Email`]: result.email } : {}), + ...(result.phoneNumber ? { [`${prefix}Phone`]: result.phoneNumber } : {}), + ...(result.address ? { [`${prefix}Address`]: result.address } : {}), + }; + + // An approved company's *owner* is its identity proof, so re-verifying one + // is staged for backoffice review rather than quietly rewriting a live + // record. The PoA is personnel — the company names its own representative, + // and the delegation letter backing them is what the reviewer sees — so a + // PoA verification lands live, matching the typed PoA fields in + // `SELF_SERVICE_ATTRIBUTES`. + if (company.status === CompanyStatus.Active && dto.subject !== "poa") { + await this.stageIdentityChange(company, userId, identity); + return this.getCompanyIdentityState(company); + } + + const updated = await this.companiesRepo.update(company.id, { + attributes: { ...(company.attributes ?? {}), ...identity }, + }); + if (!updated) + throw new NotFoundException(`Company ${company.id} not found`); + updated.companyProfiles = company.companyProfiles; + return this.getCompanyIdentityState(updated); + } + + /** + * Drop the Power of Attorney entirely — the verified identity, the details it + * wrote and the delegation paper together. + * + * Only the PoA can go: a company always has an owner, and a freight forwarder + * always has a representative. Once a PoA is Fayda-verified its + * fields are locked, so blanking the form is no longer a way out — without + * this the customer would be stuck with a representative they cannot remove. + */ + async removePoaIdentity(userId: string): Promise { + const { company } = await this.getCompanyInfoByUserId(userId); + if ( + (company.companyProfiles ?? []).some( + (p) => p.type === ProfileType.freightForwarder, + ) + ) { + throw new BadRequestException( + "A freight forwarder must have a Power of Attorney. Remove the freight forwarder role first.", + ); + } + + const cleared: Record = {}; + for (const key of [ + ...POA_ATTRIBUTES, + "poaFaydaSub", + "poaFaydaVerifiedAt", + "poaBirthdate", + "poaGender", + ]) { + cleared[key] = null; + } + const attributes = { ...(company.attributes ?? {}), ...cleared }; + + // The paper evidences a representative who no longer exists. + const records = await this.filesService.findByResource( + company.id, + COMPANY_RESOURCE, + ); + for (const r of records) { + if ( + r.code === POA_DELEGATION_FILE_KEY || + r.code === POA_DELEGATION_PENDING_CODE + ) { + await this.filesService.remove(r.id); + await this.withdrawDocumentIntent(company.id, r.id); + } + } + + const updated = await this.companiesRepo.update(company.id, { attributes }); + if (!updated) + throw new NotFoundException(`Company ${company.id} not found`); + updated.companyProfiles = company.companyProfiles; + return this.getCompanyIdentityState(updated); + } + + /** Stage a verified identity onto the company's pending change request. */ + private async stageIdentityChange( + company: Company, + userId: string, + identity: VerifiedIdentityAttributes, + ): Promise { + const existing = await this.changeRequestRepo.findPendingByCompanyId( + company.id, + ); + const now = new Date(); + const snapshot = { + ...(existing?.snapshot ?? {}), + faydaIdentity: { + ...(((existing?.snapshot ?? {}) as Record) + .faydaIdentity ?? {}), + ...identity, + }, + }; + if (existing) { + await this.changeRequestRepo.update(existing.id, { + snapshot, + submittedBy: userId, + submittedAt: now, + note: null, + }); + this.companyNotifier.changeRequestSubmitted(company, existing.id, false); + return; + } + const history = await this.changeRequestRepo.findByCompanyId(company.id); + const resubmitted = history.some( + (r) => r.status === ChangeRequestStatus.Rejected, + ); + const request = await this.changeRequestRepo.create({ + companyId: company.id, + snapshot, + status: ChangeRequestStatus.Pending, + submittedBy: userId, + submittedAt: now, + }); + this.companyNotifier.changeRequestSubmitted( + company, + request.id, + resubmitted, + ); + } + + /** + * The gate: an Ethiopian company's owner must be Fayda-verified, and so must + * its Power of Attorney once it has one; a foreign company's owner must carry + * a passport number instead. Called from the same places as + * `assertPoaDelegationSatisfied` — the two rules describe the same moment + * (who may act for this company, and on what evidence) and drifting them + * apart is how one of them ends up unenforced. + */ + private assertIdentityVerified( + company: Company, + opts: { requirePoa: boolean }, + ): void { + const state = buildCompanyIdentityState(company); + + // Only the owner's credential is nationality-specific: Fayda for an + // Ethiopian company, a typed passport number for a foreign one. + if (state.passportRequired) { + if (!state.owner.passportNumber) { + throw new BadRequestException( + "Add the company owner's passport number before continuing.", + ); + } + } else if (!state.owner.verified) { + throw new BadRequestException( + "Verify the company owner's identity with Fayda before continuing.", + ); + } + + // The representative is not. A PoA acts for the company inside Ethiopia + // whoever owns it, so they are always an Ethiopian holding a Fayda ID — + // a foreign company nominates one rather than typing a name. + const poaNamed = POA_ATTRIBUTES.some((k) => + (company.attributes?.[k] as string | undefined)?.trim(), + ); + if (!opts.requirePoa && !poaNamed) return; + + if (!state.poa.verified) { + throw new BadRequestException( + opts.requirePoa + ? "Verify your Power of Attorney with Fayda — a freight forwarder cannot operate without one." + : "Verify the Power of Attorney you named with Fayda, or remove the representative.", + ); + } + } + + /** + * The PoA details the company is heading for: its live attributes with any + * pending change-request snapshot laid over them. An Active company's edits + * are staged rather than written, so the live row on its own would judge the + * customer against details they have already asked to change. + */ + private async effectivePoaAttributes( + company: Company, + ): Promise> { + const pending = await this.changeRequestRepo.findPendingByCompanyId( + company.id, + ); + const snapshot = (pending?.snapshot ?? {}) as Record; + const staged: Record = {}; + for (const key of POA_ATTRIBUTES) { + if (key in snapshot) staged[key] = snapshot[key]; + } + return { ...(company.attributes ?? {}), ...staged }; + } + + /** The company's PoA paper(s), with each file's review status resolved. */ async listPoaDelegationFiles( userId: string, ): Promise { @@ -2149,6 +2775,18 @@ export class CompaniesService { throw new NotFoundException(`Delegation letter ${fileId} not found`); } + // Taking the paper away is the other half of the pairing: allowed only once + // the representative it evidences is gone too (which, for an Active + // company, means the clearing edit is already staged). + await this.assertPoaDelegationSatisfied( + company.id, + await this.effectivePoaAttributes(company), + { + requirePoa: await this.isFreightForwarder(company.id), + ignoreFileIds: [fileId], + }, + ); + if (record.code === POA_DELEGATION_PENDING_CODE) { await this.filesService.remove(fileId); await this.withdrawDocumentIntent(company.id, fileId); @@ -2328,7 +2966,10 @@ export class CompaniesService { return match?.id ?? null; } - async fetchETradeData(tin: string) { + /** Resolve a TIN's live eTrade registration data. Throws when eTrade has no matching business licence. */ + private async resolveEtradeRegistration( + tin: string, + ): Promise { const { businessInfo, companyInfo } = await this.etradeService.resolveCompanyData(tin); if (!businessInfo) { @@ -2336,11 +2977,71 @@ export class CompaniesService { "We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.", ); } - const registrationData = this.etradeService.extractRegistrationData( - businessInfo, - companyInfo, + return this.etradeService.extractRegistrationData(businessInfo, companyInfo); + } + + async fetchETradeData(tin: string, excludeCompanyId?: string) { + const registrationData = await this.resolveEtradeRegistration(tin); + const tinTaken = await this.companiesRepo.existsByTin( + tin, + excludeCompanyId, ); - const tinTaken = await this.companiesRepo.existsByTin(tin); return { ...registrationData, tinTaken }; } + + /** + * An eTrade-sourced field can only ever hold what a fresh eTrade lookup for + * this TIN actually returns — the portal never lets the customer type these + * once eTrade has supplied them, so a mismatch here means either stale + * client state or a hand-crafted request, and either way the write is + * refused rather than silently trusting it. + */ + private async assertEtradeFieldsAuthentic( + company: Company, + dto: UpdateProfileDto, + ): Promise { + const touched = ETRADE_SOURCED_FIELDS.some( + (key) => dto[key] !== undefined, + ); + if (!touched) return; + + const tin = dto.tin ?? company.tin; + const registration = await this.resolveEtradeRegistration(tin); + const expected: Partial> = { + companyName: registration.companyName, + licenceNumber: registration.licenceNumber, + statusDescription: registration.statusDescription, + dateRegistered: registration.dateRegistered, + renewedFrom: registration.renewedFrom, + renewalDate: registration.renewalDate, + renewedTo: registration.renewedTo, + region: registration.region, + zone: registration.zone, + woreda: registration.woreda, + kebele: registration.kebele, + houseNo: registration.houseNo, + etradePhone: + registration.managerPhone || + registration.regularPhone || + registration.mobilePhone, + }; + + for (const key of ETRADE_SOURCED_FIELDS) { + const submitted = dto[key]; + if (submitted === undefined) continue; + const source = expected[key]; + // eTrade left this field blank — the onboarding/settings card falls back + // to letting the customer type it directly, so nothing to check against. + if (!source) continue; + const same = + key === "etradePhone" + ? normalizeE164(String(submitted)) === normalizeE164(source) + : submitted === source; + if (!same) { + throw new BadRequestException( + `${key} doesn't match eTrade's current record for this TIN. Re-verify with eTrade to pick up the latest details.`, + ); + } + } + } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts new file mode 100644 index 000000000..a9988cd28 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts @@ -0,0 +1,157 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsIn, IsString, IsNotEmpty } from "class-validator"; + +import { Company, CompanyNationality } from "../entities/company.entity"; +import { ProfileType } from "../entities/company-profile.entity"; + +/** + * The two people a company is verified through — its owner and its Power of + * Attorney. "Owner" is not the same as the General Manager: a company's GM is + * a plain typed role (with a "same as owner" copy the portal offers), while + * the owner is the person this verification proves. They're very often the + * same human, which is exactly what the copy is for. + */ +export const IDENTITY_SUBJECTS = ["owner", "poa"] as const; +export type IdentitySubject = (typeof IDENTITY_SUBJECTS)[number]; + +export class CompleteIdentityVerificationDto { + @ApiProperty({ + enum: IDENTITY_SUBJECTS, + description: "Which of the company's people this verification is for.", + }) + @IsIn(IDENTITY_SUBJECTS) + subject!: IdentitySubject; + + @ApiProperty({ description: "Authorization code from the Fayda redirect." }) + @IsString() + @IsNotEmpty() + code!: string; + + @ApiProperty({ description: "CSRF state from the Fayda redirect." }) + @IsString() + @IsNotEmpty() + state!: string; +} + +/** One person's verification state, as reported back to the portal. */ +export class IdentityVerificationStateDto { + @ApiProperty() verified!: boolean; + @ApiProperty({ nullable: true }) name!: string | null; + @ApiProperty({ nullable: true }) phone!: string | null; + @ApiProperty({ nullable: true }) email!: string | null; + @ApiProperty({ nullable: true }) address!: string | null; + @ApiProperty({ nullable: true }) verifiedAt!: string | null; + @ApiProperty({ nullable: true }) birthdate!: string | null; + @ApiProperty({ nullable: true }) gender!: string | null; +} + +export class OwnerIdentityStateDto extends IdentityVerificationStateDto { + @ApiProperty({ + nullable: true, + description: + "Typed passport number — the foreign-company identity credential. Independent of Fayda: never written by a verification, and still required even if the owner also verifies.", + }) + passportNumber!: string | null; +} + +export class CompanyIdentityStateDto { + @ApiProperty({ + description: + "True when Fayda verification of the owner (and PoA, once named) is mandatory — Ethiopian companies only.", + }) + faydaRequired!: boolean; + + @ApiProperty({ + description: + "True when the owner's passport number is mandatory — foreign companies only. Independent of faydaRequired: a foreign owner may verify with Fayda too, but the passport is still required.", + }) + passportRequired!: boolean; + + @ApiProperty({ type: OwnerIdentityStateDto }) + owner!: OwnerIdentityStateDto; + + @ApiProperty({ type: IdentityVerificationStateDto }) + poa!: IdentityVerificationStateDto; + + @ApiProperty({ + description: + "False while a mandatory requirement (Fayda for Ethiopian, passport for foreign) is still outstanding.", + }) + complete!: boolean; +} + +/** `attributes` key prefix per person. */ +const PREFIX: Record = { + owner: "owner", + poa: "poa", +}; + +/** company.attributes keys that together mean "a PoA was entered". */ +const POA_KEYS = [ + "poaName", + "poaPhone", + "poaEmail", + "poaLocation", + "poaAddress", +] as const; + +function stateFor( + attrs: Record, + subject: IdentitySubject, +): IdentityVerificationStateDto { + const p = PREFIX[subject]; + const read = (key: string) => (attrs[key] as string | undefined) ?? null; + return { + verified: Boolean(read(`${p}FaydaSub`)), + name: read(`${p}Name`), + phone: read(`${p}Phone`), + email: read(`${p}Email`), + address: read(`${p}Address`), + verifiedAt: read(`${p}FaydaVerifiedAt`), + birthdate: read(`${p}Birthdate`), + gender: read(`${p}Gender`), + }; +} + +/** + * Derive both people's verification state from the company row. + * + * Pure and shared: `CompaniesService` gates on it and `ProfileResponseDto` + * renders from it, so the settings page and the onboarding wizard can never + * disagree with the rule the API actually enforces. + */ +export function buildCompanyIdentityState( + company: Company, +): CompanyIdentityStateDto { + const attrs = company.attributes ?? {}; + const read = (key: string) => (attrs[key] as string | undefined) ?? null; + + // Fayda is an Ethiopian national ID — a foreign company's owner may not hold + // one, so a typed passport number is the mandatory credential there instead. + // The two are mutually exclusive by nationality but independently tracked, + // since a foreign owner verifying with Fayda doesn't waive the passport. + const foreign = company.nationality === CompanyNationality.Foreign; + const faydaRequired = !foreign; + const passportRequired = foreign; + + const owner: OwnerIdentityStateDto = { + ...stateFor(attrs, "owner"), + passportNumber: read("ownerPassportNumber"), + }; + const poa = stateFor(attrs, "poa"); + const poaDue = + (company.companyProfiles ?? []).some( + (p) => p.type === ProfileType.freightForwarder, + ) || POA_KEYS.some((k) => (attrs[k] as string | undefined)?.trim()); + + // Only the *owner's* credential is nationality-specific. A Power of Attorney + // acts for the company inside Ethiopia whoever owns it, so the PoA is always + // proven with Fayda — a foreign company nominates a representative who holds + // one rather than typing a name nothing backs. + const ownerProven = faydaRequired + ? owner.verified + : !passportRequired || Boolean(owner.passportNumber); + const complete = ownerProven && (!poaDue || poa.verified); + + return { faydaRequired, passportRequired, owner, poa, complete }; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts index da908a177..a8d2f24a2 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts @@ -8,6 +8,8 @@ * truth the wizard uses to auto-finish. */ +import { CompanyIdentityStateDto } from "./complete-identity-verification.dto"; + export interface OnboardingInfoField { key: string; label: string; @@ -40,11 +42,13 @@ export interface OnboardingPoaState { required: boolean; /** True once any PoA detail has been entered. */ provided: boolean; - /** True when the delegation letter is stored for the company. */ + /** True when the DARS delegation paper is stored for the company. */ delegationLetterUploaded: boolean; + /** True when a reviewer sent the paper back for correction. */ + delegationLetterFlagged: boolean; /** PoA details still missing (only populated when `required`). */ missingFields: OnboardingInfoField[]; - /** False while the PoA step still owes details or a delegation letter. */ + /** False while the PoA step still owes details or an uncorrected paper. */ complete: boolean; } @@ -68,6 +72,13 @@ export class OnboardingRequirementsResponseDto { /** Power of Attorney state, so the wizard needn't re-derive the rule. */ poa: OnboardingPoaState; + /** + * Fayda verification state for the company's people. `required` is false for + * a foreign company, which is never gated on it — the portal renders the + * typed personnel forms in that case and the verify panels otherwise. + */ + identity: CompanyIdentityStateDto; + /** Overall setup progress across fields + documents + licenses. */ progress: { completed: number; total: number }; @@ -87,6 +98,7 @@ export class OnboardingRequirementsResponseDto { this.documents = init.documents; this.licenseProfiles = init.licenseProfiles; this.poa = init.poa; + this.identity = init.identity; this.progress = init.progress; this.isComplete = init.isComplete; this.onboardingCompleted = init.onboardingCompleted; diff --git a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts index 89ab954e7..6072268dc 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts @@ -1,3 +1,7 @@ +import { + buildCompanyIdentityState, + CompanyIdentityStateDto, +} from "./complete-identity-verification.dto"; import { Company } from '../entities/company.entity'; import { ExternalProfile } from '../entities/external-profile.entity'; import { @@ -52,6 +56,16 @@ export class ProfileResponseDto { profileId: string; + /** + * Fayda verification state for the company's owner and PoA — not the general + * manager, which is a separate typed role. The settings tabs and the + * onboarding wizard render from `identity.faydaRequired` / + * `identity.passportRequired`: an Ethiopian company verifies the owner (and + * PoA) instead of typing their details; a foreign one requires a typed + * passport number instead. + */ + identity: CompanyIdentityStateDto; + /** * Open profile-edit review, if any. `reviewStatus === "pending"` locks the * settings page; `"rejected"` surfaces the note and prefills the (declined) @@ -124,5 +138,6 @@ export class ProfileResponseDto { : null; this.reviewNote = openReview?.note ?? null; this.pendingChanges = openReview?.snapshot ?? null; + this.identity = buildCompanyIdentityState(company); } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts index a05812558..60f9f9a34 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts @@ -9,6 +9,10 @@ import { ProfileLicenseFileView, } from '../entities/company-profile.entity'; import { ResponseExternalProfileDto } from './response-external-profile.dto'; +import { + buildCompanyIdentityState, + CompanyIdentityStateDto, +} from './complete-identity-verification.dto'; export class ResponseCompanyProfileDto { id: string; @@ -69,6 +73,28 @@ export class ResponseCompanyDto { * external profiles weren't loaded. */ onboardingCompleted?: boolean; + + // eTrade-sourced registration record — populated by the onboarding TIN + // lookup, locked/read-only on the portal from the moment it's fetched. + licenceNumber?: string | null; + statusDescription?: string | null; + dateRegistered?: string | null; + renewedFrom?: string | null; + renewalDate?: string | null; + renewedTo?: string | null; + region?: string | null; + zone?: string | null; + woreda?: string | null; + kebele?: string | null; + houseNo?: string | null; + + /** + * Owner/PoA Fayda verification state, shared with the portal + * (`buildCompanyIdentityState`) so backoffice never re-derives — or + * disagrees with — the rule the API actually enforces. + */ + identity: CompanyIdentityStateDto; + createdAt: Date; updatedAt: Date; @@ -95,6 +121,18 @@ export class ResponseCompanyDto { ? company.profiles.length === 0 || company.profiles.some((p) => p.onboardingCompleted) : undefined; + this.licenceNumber = company.licenceNumber; + this.statusDescription = company.statusDescription; + this.dateRegistered = company.dateRegistered; + this.renewedFrom = company.renewedFrom; + this.renewalDate = company.renewalDate; + this.renewedTo = company.renewedTo; + this.region = company.region; + this.zone = company.zone; + this.woreda = company.woreda; + this.kebele = company.kebele; + this.houseNo = company.houseNo; + this.identity = buildCompanyIdentityState(company); this.createdAt = company.createdAt; this.updatedAt = company.updatedAt; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts index ba3e27aeb..9f7d1ed39 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts @@ -44,10 +44,11 @@ export class UpdateProfileDto { @MaxLength(50) vatNumber?: string; - @IsOptional() - @IsString() - @MaxLength(16) - fanNumber?: string; + // `fanNumber` is deliberately absent: the FAN is the Fayda number of the + // company's PoA (or its general manager), so it is derived from a completed + // Fayda verification rather than typed. The global validation pipe runs with + // forbidNonWhitelisted, so a client that still sends it gets a 400 telling it + // so — see CompaniesService.completeIdentityVerification. @IsOptional() @IsString() @@ -110,6 +111,16 @@ export class UpdateProfileDto { @IsString() poaAddress?: string; + /** + * The owner's passport number — the identity credential for a foreign + * company, since Fayda is an Ethiopian national ID. Plain typed field, never + * written or locked by a Fayda verification: still required even if the + * owner also verifies. + */ + @IsOptional() + @IsString() + ownerPassportNumber?: string; + @IsOptional() @IsString() @MaxLength(100) diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index c567f71ce..7eb87bf86 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -109,6 +109,23 @@ export class ContractBookingService { private readonly bookingTransitionService: BookingTransitionService, ) {} + /** + * Mirrors BookingsService.assertNoUnpaidHold for the contract booking paths: + * a company sitting on an unpaid hold (SELECTED_FOR_BATCH) books nothing new + * until it pays or the hold dies. + */ + private async assertNoUnpaidHold(companyId?: string | null): Promise { + if (!companyId) return; + const holds = + await this.bookingsRepository.countUnpaidHoldsForCompany(companyId); + if (holds > 0) { + throw new ConflictException( + 'You already have a booking waiting for payment. Pay it or cancel it ' + + 'before making a new booking.', + ); + } + } + async createUnderContract( contractId: string, dto: CreateBookingUnderContractDto, @@ -169,6 +186,8 @@ export class ContractBookingService { // remainder; the customer cannot start any other booking on the contract. // If the remainder splits again the same rule repeats until the cap is // exhausted and the contract completes. + await this.assertNoUnpaidHold(contract.companyId); + if (contract.contractKind === 'ONE_TIME') { if (await this.hasSplitBooking(contractId)) { await this.assertExactRemainder(contract, dto); @@ -455,6 +474,7 @@ export class ContractBookingService { ); } } + await this.assertNoUnpaidHold(contract.companyId); const route = await this.resolveRoute(contract, dto.contractRouteId); @@ -846,6 +866,7 @@ export class ContractBookingService { const completed = await this.bookingTransitionService.requestOperation( booking.id, dto.scheduledDate, + dto.trainScheduleId ?? null, ); return { booking: completed, warnings }; } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts index 512ce5288..e5dd120ee 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts @@ -26,30 +26,22 @@ function freightFor(freightType: string): Freight { /** * The customer-input clearance setting code, or null when no gate applies. * - * - Path B (customs bundled): the customer uploads the documents GL needs to do - * the clearance work → `contract_clearance_{op}_{freight}`. - * - Path A (no customs): the customer clears the cargo himself and uploads his - * own (smaller) clearance proof set → `contract_clearance_selfclear_{op}_{freight}`, - * reviewed by Operations rather than GL. + * Contract-level IMPORT/EXPORT clearance has been removed — clearance is + * collected per booking instead (see bookings/clearance.util.ts), so this + * always returns null for IMPORT/EXPORT now. * * DOMESTIC/intercity has no border, but a ONE_TIME intercity contract still * collects the admin-configured intercity document set after both signatures - * (ops-reviewed, like Path A). GENERAL intercity contracts skip the contract - * gate and collect the same set per booking instead. + * (ops-reviewed). GENERAL intercity contracts skip the contract gate and + * collect the same set per booking instead. */ export function contractClearanceSettingCode( tradeDirection: string, - freightType: string, - includesCustoms: boolean, + _freightType: string, + _includesCustoms: boolean, ): string | null { if (tradeDirection === 'DOMESTIC') return INTERCITY_DOCUMENTS_SETTING_CODE; - const op = operationFor(tradeDirection); - if (!op) return null; - const freight = freightFor(freightType); - if (!includesCustoms) { - return `contract_clearance_selfclear_${op}_${freight}`; - } - return `contract_clearance_${op}_${freight}`; + return null; } /** The GL-output (customs output) setting code, keyed on op + freight. */ diff --git a/apps/edr-freight-api/src/modules/contracts/contract-duplicate-guard.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-duplicate-guard.spec.ts index 1290b2e90..f5e2f694c 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-duplicate-guard.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-duplicate-guard.spec.ts @@ -42,6 +42,7 @@ describe('ContractsService duplicate guard', () => { {} as never, {} as never, {} as never, + { buildBreakdown: async () => ({ lineItems: [] }) } as never, ); return ( service as unknown as { diff --git a/apps/edr-freight-api/src/modules/contracts/contract-pricing.lane-scope.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-pricing.lane-scope.spec.ts new file mode 100644 index 000000000..96a8a26ac --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-pricing.lane-scope.spec.ts @@ -0,0 +1,122 @@ +import { UnprocessableEntityException } from '@nestjs/common'; + +import { ContractPricingService } from './contract-pricing.service'; +import type { Contract } from './entities/contract.entity'; +import type { Rate } from '../rule-engine/entities/rate.entity'; + +const CT20 = 'ct-20'; +const CT40 = 'ct-40'; +const DCT = 'yard-dct'; +const SEBETA = 'yard-sebeta'; +const GMP = 'yard-gmp'; + +const rate = (over: Partial): Rate => + ({ + rateType: 'CONTAINER_IMPORT', + currency: 'USD', + rateValue: 1000, + rateUnit: 'PER_CONTAINER', + containerTypeId: null, + cargoTypeId: null, + originYardId: DCT, + destinationYardId: SEBETA, + ...over, + }) as Rate; + +const contract = (over: Partial): Contract => + ({ + freightType: 'CONTAINER', + tradeDirection: 'IMPORT', + paymentCurrency: 'USD', + customsClearingEnabled: false, + isHazardous: false, + isReefer: false, + routes: [{ originYardId: DCT, destinationYardId: SEBETA, sortOrder: 0 }], + cargoScope: [{ containerSize: '20ft' }], + ...over, + }) as Contract; + +const service = (liveRates: Rate[]): ContractPricingService => + new ContractPricingService( + {} as never, + { findLiveRates: async () => liveRates } as never, + { + findAll: async () => ({ + items: [ + { id: CT20, sizeFt: 20 }, + { id: CT40, sizeFt: 40 }, + ], + }), + } as never, + { getRate: async () => 1 } as never, + ); + +describe('contract base freight is priced on the contract lane only', () => { + it('prices from the contract route, never another lane (CTR-2026-00065)', async () => { + const breakdown = await service([ + // Same size, other lane — the leak that priced DCT → Sebeta at GMP rates. + rate({ containerTypeId: CT20, destinationYardId: GMP, rateValue: 1690 }), + rate({ containerTypeId: CT20, rateValue: 750 }), + ]).buildBreakdown(contract({})); + expect(breakdown.lineItems).toEqual([ + expect.objectContaining({ code: 'CONTAINER_20FT', unitPrice: 750 }), + ]); + }); + + it('blocks the contract when its lane has no container rate', async () => { + await expect( + service([ + rate({ containerTypeId: CT20, destinationYardId: GMP, rateValue: 1690 }), + ]).buildBreakdown(contract({})), + ).rejects.toThrow(UnprocessableEntityException); + }); + + it('freezes the OVERWEIGHT_PER_TON surcharge on export contracts only', async () => { + const overweight = rate({ + rateType: 'OVERWEIGHT_PER_TON', + trigger: 'OVERWEIGHT', + rateUnit: 'PER_TON', + rateValue: 25, + originYardId: null, + destinationYardId: null, + } as Partial); + + const exported = await service([ + rate({ rateType: 'CONTAINER_EXPORT', containerTypeId: CT20, rateValue: 900 }), + overweight, + ]).buildBreakdown(contract({ tradeDirection: 'EXPORT' })); + expect(exported.lineItems).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 'OVERWEIGHT_PER_TON', unitPrice: 25 }), + ]), + ); + + // Import derives overweight from the route's base freight — never frozen. + const imported = await service([ + rate({ containerTypeId: CT20, rateValue: 750 }), + overweight, + ]).buildBreakdown(contract({})); + expect( + imported.lineItems.some((li) => li.code === 'OVERWEIGHT_PER_TON'), + ).toBe(false); + }); + + it('blocks bulk contracts too instead of borrowing an arbitrary rate', async () => { + const bulk = contract({ freightType: 'BULK', cargoScope: [] }); + await expect( + service([ + rate({ + rateType: 'BULK_IMPORT', + rateUnit: 'PER_TON', + destinationYardId: GMP, + }), + ]).buildBreakdown(bulk), + ).rejects.toThrow(UnprocessableEntityException); + const priced = await service([ + rate({ rateType: 'BULK_IMPORT', rateUnit: 'PER_TON', rateValue: 32 }), + ]).buildBreakdown(bulk); + expect(priced.lineItems).toEqual([ + expect.objectContaining({ code: 'BULK_FREIGHT', unitPrice: 32 }), + ]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts index a4e4b43b5..d54b0b544 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts @@ -84,6 +84,26 @@ export class ContractPricingService { const lineItems: ContractUnitRateLineItem[] = []; const baseType = this.baseRateType(contract); + // Base rail freight is quoted per route (CK_rates_yard_scope) — only rates + // on the contract's own lane may price it. Matching without the yard filter + // is how a DCT → Sebeta contract froze DCT → GMP (Indode) prices, and the + // frozen snapshot then bills bookings that the route-scoped booking lookup + // would have hard-blocked (CTR-2026-00065). + // ponytail: multi-route contracts price the first lane (same as customs + // clearance below); per-lane pricing needs per-route breakdowns. + const route = [...(contract.routes ?? [])].sort( + (a, b) => a.sortOrder - b.sortOrder, + )[0]; + const onLane = route + ? liveRates.filter( + (r) => + r.rateType === baseType && + r.currency === 'USD' && + r.originYardId === route.originYardId && + r.destinationYardId === route.destinationYardId, + ) + : []; + if (contract.freightType === 'CONTAINER') { const sizes = (contract.cargoScope ?? []) .map((c) => c.containerSize) @@ -97,17 +117,14 @@ export class ContractPricingService { const matchedTypes = containerTypes.filter((ct) => ct.sizeFt === sizeFt); const matchedIds = new Set(matchedTypes.map((ct) => ct.id)); const rate = - liveRates.find( - (r) => - r.rateType === baseType && - r.currency === 'USD' && - r.containerTypeId && - matchedIds.has(r.containerTypeId), - ) ?? - liveRates.find( - (r) => r.rateType === baseType && r.currency === 'USD' && !r.containerTypeId, + onLane.find( + (r) => r.containerTypeId && matchedIds.has(r.containerTypeId), + ) ?? onLane.find((r) => !r.containerTypeId); + if (!rate || Number(rate.rateValue) <= 0) { + throw new UnprocessableEntityException( + `No rail freight rate is configured for ${size} containers on this direction and route — the contract cannot be priced. Ask the rates team to set a live ${baseType} rate for this container type and origin → destination.`, ); - if (!rate) continue; + } lineItems.push({ code: `CONTAINER_${size.toUpperCase()}`, label: `${size} container`, @@ -120,25 +137,26 @@ export class ContractPricingService { const cargoScope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId); // Freeze the rate for the contract's own commodity when one is configured // — a per-item machinery rate and a per-ton wheat rate live side by side. - const bulkRates = liveRates.filter( - (r) => r.rateType === baseType && r.currency === 'USD', - ); + // No arbitrary-rate fallback: another commodity's rate must never price + // this contract. const bulkRate = (cargoScope?.cargoTypeId - ? bulkRates.find((r) => r.cargoTypeId === cargoScope.cargoTypeId) + ? onLane.find((r) => r.cargoTypeId === cargoScope.cargoTypeId) : undefined) ?? - bulkRates.find((r) => !r.cargoTypeId) ?? - bulkRates[0] ?? + onLane.find((r) => !r.cargoTypeId) ?? null; - if (bulkRate) { - lineItems.push({ - code: 'BULK_FREIGHT', - label: cargoScope?.cargoType?.cargoTypeName ?? 'Bulk cargo', - unit: toContractUnit(bulkRate.rateUnit), - unitPrice: convert(Number(bulkRate.rateValue)), - cargoTypeCode: cargoScope?.cargoType?.code ?? null, - }); + if (!bulkRate || Number(bulkRate.rateValue) <= 0) { + throw new UnprocessableEntityException( + 'No bulk rail freight rate is configured for this cargo type on this direction and route — the contract cannot be priced. Ask the rates team to set a live rate for this commodity and origin → destination.', + ); } + lineItems.push({ + code: 'BULK_FREIGHT', + label: cargoScope?.cargoType?.cargoTypeName ?? 'Bulk cargo', + unit: toContractUnit(bulkRate.rateUnit), + unitPrice: convert(Number(bulkRate.rateValue)), + cargoTypeCode: cargoScope?.cargoType?.code ?? null, + }); } // First / last mile trucking unit rates — shown when the contract carries @@ -201,6 +219,26 @@ export class ContractPricingService { }); } } + // Overweight surcharge — EXPORT contracts freeze the OVERWEIGHT_PER_TON + // rate so booking pricing bills the contract's price on excess tons + // (frozenRateByCode wins over the live rate). Always included, no toggle: + // overweight is system-detected at booking, never customer-opted. IMPORT + // never reads this snapshot — its overweight price derives from the + // route's base container freight (see RuleEngineService). + if (contract.tradeDirection === 'EXPORT') { + const overweight = liveRates.find( + (r) => r.trigger === 'OVERWEIGHT' && r.currency === 'USD', + ); + if (overweight && Number(overweight.rateValue) > 0) { + lineItems.push({ + code: 'OVERWEIGHT_PER_TON', + label: 'Overweight surcharge (per excess ton)', + unit: toContractUnit(overweight.rateUnit), + unitPrice: convert(Number(overweight.rateValue)), + conditionalOn: 'is_overweight', + }); + } + } // Lashing / cargo securing — BULK only, shown when the contract's commodity // needs lashing (cargoType.hasLashing). The commodity-scoped rate for the // contract's direction wins over the commodity-wide catch-all; billed at @@ -241,9 +279,6 @@ export class ContractPricingService { // one display line per contract size that has a configured rate. A size // with no rate shows nothing here and hard-blocks at booking time. // ponytail: bookings bill the live route rate, not a frozen snapshot. - const route = [...(contract.routes ?? [])].sort( - (a, b) => a.sortOrder - b.sortOrder, - )[0]; const onLeg = route ? liveRates.filter( (r) => @@ -291,9 +326,6 @@ export class ContractPricingService { if (contract.customsClearingEnabled) { // Strict, no route-less fallback. // ponytail: multi-route contracts bill the first lane's fee; per-lane fees need per-route snapshots. - const route = [...(contract.routes ?? [])].sort( - (a, b) => a.sortOrder - b.sortOrder, - )[0]; const onLeg = route ? liveRates.filter( (r) => 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 24aa2b597..5c0b5e29e 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -28,6 +28,7 @@ import { ContractCargoScope } from './entities/contract-cargo-scope.entity'; import { isEffectivelyExpired } from './utils/contract-expiry.util'; import { diffContractFields } from './contract-document-diff.util'; import { ContractDocumentHistoryService } from './contract-document-history.service'; +import { ContractPricingService } from './contract-pricing.service'; import { FileRecord } from '../files/entities/file.entity'; /** Paginated contract list: flat `total` (backoffice) + `meta` block (portal). */ @@ -113,6 +114,7 @@ export class ContractsService { private readonly filesService: FilesService, private readonly minioService: MinioService, private readonly documentHistory: ContractDocumentHistoryService, + private readonly pricingService: ContractPricingService, ) {} /** Generate a unique contract reference number (CTR-YYYY-NNNNN). */ @@ -331,6 +333,33 @@ export class ContractsService { ); } + // Price the contract BEFORE anything persists: a lane with no configured + // rate 422s here and the wizard shows its blocking modal — with no orphan + // DRAFT row left behind for the customer to trip over on retry. The probe + // carries exactly the fields buildBreakdown prices from; relation-only + // niceties (cargoType labels) are absent, which only affects display + // lines, never the missing-rate gates. + await this.pricingService.buildBreakdown({ + tradeDirection: dto.tradeDirection, + freightType: dto.freightType, + paymentCurrency: 'USD', + customsClearingEnabled: includesCustoms, + isHazardous: dto.isHazardous ?? false, + isReefer: dto.isReefer ?? false, + equipmentReturn: dto.equipmentReturn ?? null, + firstMilePickupAddress: dto.firstMilePickupAddress ?? null, + lastMileDeliveryAddress: dto.lastMileDeliveryAddress ?? null, + routes: (dto.routes ?? []).map((r, i) => ({ + originYardId: r.originYardId, + destinationYardId: r.destinationYardId, + sortOrder: r.sortOrder ?? i, + })), + cargoScope: (dto.cargoScope ?? []).map((c) => ({ + containerSize: c.containerSize ?? null, + cargoTypeId: c.cargoTypeId ?? null, + })), + } as unknown as Contract); + // An explicit reference is caller-chosen — a collision there is a real // conflict and should surface. Auto-generated references retry past a // concurrent insert that grabbed the same sequence number. diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts index 2ab616c00..8c1bf763d 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts @@ -175,6 +175,16 @@ export class CreateBookingUnderContractDto { @IsDateString() scheduledDate?: string; + @ApiPropertyOptional({ + description: + 'EXPORT rail only: the specific train (schedule id) picked from ' + + 'GET /bookings/:id/export-trains for the shipment day. The reserve path ' + + 'locks onto this train; 409 when it no longer fits. Ignored otherwise.', + }) + @IsOptional() + @IsUUID() + trainScheduleId?: string; + @ApiPropertyOptional({ enum: SHIPMENT_EQUIPMENT_RETURNS, description: diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts index 947bb5ffb..9651d4f37 100644 --- a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts +++ b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts @@ -15,6 +15,11 @@ import { FILE_UPLOAD_SETTINGS_REPOSITORY, IFileUploadSettingsRepository, } from "./interfaces/file-upload-settings.repository.interface"; +import { + COMPANY_ONBOARDING_CODE_PREFIX, + POA_DELEGATION_FILE_KEY, + poaDelegationField, +} from "./poa-delegation.constants"; @Injectable() export class FileUploadSettingsService { @@ -40,6 +45,22 @@ export class FileUploadSettingsService { async getByCode(code: string): Promise { const setting = await this.repository.findByCode(code); if (!setting) throw new NotFoundException(`Setting "${code}" not found`); + return this.withPoaDelegationField(setting); + } + + /** + * Company onboarding sets always carry the DARS delegation paper, whether or + * not anyone configured a row for it — see poa-delegation.constants.ts. Every + * consumer (the portal's PoA step, the onboarding gate) reads the set through + * here, so this is the single place the field can be guaranteed. + */ + private withPoaDelegationField(setting: FileUploadSetting): FileUploadSetting { + if (!setting.code.startsWith(COMPANY_ONBOARDING_CODE_PREFIX)) return setting; + const fields = setting.fields ?? []; + if (fields.some((f) => f.fileKey === POA_DELEGATION_FILE_KEY)) return setting; + + const lastOrder = fields.reduce((max, f) => Math.max(max, f.displayOrder), 0); + setting.fields = [...fields, poaDelegationField(lastOrder + 1)]; return setting; } diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/poa-delegation.constants.ts b/apps/edr-freight-api/src/modules/file-upload-settings/poa-delegation.constants.ts new file mode 100644 index 000000000..9085cc018 --- /dev/null +++ b/apps/edr-freight-api/src/modules/file-upload-settings/poa-delegation.constants.ts @@ -0,0 +1,52 @@ +import { FileUploadField } from "./entities/file-upload-field.entity"; + +/** + * The DARS delegation paper — the document that evidences a company's Power of + * Attorney (EDRFREIGHT-358). + * + * Every other onboarding document is admin-managed: the rows in + * `file_upload_fields` are edited from the backoffice file-settings editor and + * the seeder deliberately inserts none. This one is different — a company that + * names a PoA must produce a delegation paper authenticated by the Documents + * Authentication and Registration Service, and that is a legal requirement + * rather than a configuration choice. So the field is defined here in code and + * injected into the company onboarding sets on read: no row to forget to seed, + * and deleting one in the editor cannot silently switch the requirement off. + */ + +/** FileRecord `code` (and upload field key) of the live delegation paper. */ +export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter"; + +/** Code for a delegation paper staged in an open change request (not yet live). */ +export const POA_DELEGATION_PENDING_CODE = "poa_delegation_letter_pending"; + +/** Customer-facing name of the document, used by the API and both web apps. */ +export const POA_DELEGATION_LABEL = "DARS Delegation Paper"; + +/** Prefix of the setting codes the field is injected into. */ +export const COMPANY_ONBOARDING_CODE_PREFIX = "company_onboarding_documents_"; + +const POA_DELEGATION_HELP = + "Delegation paper issued by the Documents Authentication and Registration " + + "Service (DARS) delegating the representative named above. Upload the " + + "authenticated copy — a plain letter is not accepted."; + +/** + * The field descriptor. `isRequired` stays false because the paper is only due + * once a PoA has actually been named (or the company operates as a freight + * forwarder) — a rule that spans form fields as well as files, so it is + * enforced in CompaniesService rather than by this flag. + */ +export function poaDelegationField(displayOrder: number): FileUploadField { + return { + fileKey: POA_DELEGATION_FILE_KEY, + fileLabel: POA_DELEGATION_LABEL, + helpText: POA_DELEGATION_HELP, + isRequired: false, + isMultiple: false, + maxFiles: 1, + allowedExtensions: ["pdf", "jpg", "jpeg", "png"], + maxSizeMb: 10, + displayOrder, + } as FileUploadField; +} diff --git a/apps/edr-freight-api/src/modules/last-mile/dto/set-warehouse-gate-times.dto.ts b/apps/edr-freight-api/src/modules/last-mile/dto/set-warehouse-gate-times.dto.ts new file mode 100644 index 000000000..ba980dfdc --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile/dto/set-warehouse-gate-times.dto.ts @@ -0,0 +1,22 @@ +import { Type } from 'class-transformer'; +import { IsArray, IsDateString, IsOptional, IsUUID, ValidateNested } from 'class-validator'; + +export class TruckWarehouseGateTimeInput { + @IsUUID() + vehicleId!: string; + + @IsOptional() + @IsDateString() + arrivedAt?: string | null; + + @IsOptional() + @IsDateString() + departedAt?: string | null; +} + +export class SetWarehouseGateTimesDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => TruckWarehouseGateTimeInput) + trucks!: TruckWarehouseGateTimeInput[]; +} diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts index e8fa57cdc..d7e2ba1ff 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts @@ -24,6 +24,7 @@ import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto'; import { SetVehiclesDto } from './dto/set-vehicles.dto'; import { SetDetentionTimesDto } from './dto/set-detention-times.dto'; +import { SetWarehouseGateTimesDto } from './dto/set-warehouse-gate-times.dto'; import { SetDistancesDto } from './dto/set-distances.dto'; import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto'; import { LastMileStatus } from './entities/last-mile.entity'; @@ -144,6 +145,18 @@ export class LastMileController { return this.lastMileService.setDetentionTimes(id, dto.trucks); } + @Post(':id/warehouse-gate-times') + @BookingStaff(FREIGHT_PERMS.lastMile.update) + @ApiOperation({ + summary: 'Set each truck\'s warehouse gate arrival/departure times', + }) + async setWarehouseGateTimes( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SetWarehouseGateTimesDto, + ) { + return this.lastMileService.setWarehouseGateTimes(id, dto.trucks); + } + @Post(':id/proof-of-delivery') @BookingStaff(FREIGHT_PERMS.lastMile.update) @UseInterceptors(AnyFilesInterceptor()) diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 6db55b66d..af6c920fc 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -913,6 +913,41 @@ export class LastMileService { return this.findById(id); } + async setWarehouseGateTimes( + id: string, + trucks: Array<{ + vehicleId: string; + arrivedAt?: string | null; + departedAt?: string | null; + }>, + ): Promise { + await this.findById(id); + + const invoices = await this.billing.findBySourceIds('last_mile', [id]); + if (invoices.length) { + throw new BadRequestException( + 'Warehouse gate times cannot be changed after the invoice is generated', + ); + } + + for (const t of trucks) { + const arrived = t.arrivedAt ? new Date(t.arrivedAt) : null; + const departed = t.departedAt ? new Date(t.departedAt) : null; + if (arrived && departed && departed.getTime() < arrived.getTime()) { + throw new BadRequestException( + 'A truck cannot depart before it arrived — check the warehouse gate times', + ); + } + await this.dataSource.manager.update( + LastMileVehicleAssignment, + { lastMileId: id, vehicleId: t.vehicleId }, + { arrivedAt: arrived, departedAt: departed }, + ); + } + + return this.findById(id); + } + async setDistances( id: string, distances: Array<{ vehicleId: string; distanceKm: number }>, diff --git a/apps/edr-freight-api/src/modules/payment/payment-client.service.ts b/apps/edr-freight-api/src/modules/payment/payment-client.service.ts index 4c2ebe971..c0fc6fc09 100644 --- a/apps/edr-freight-api/src/modules/payment/payment-client.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment-client.service.ts @@ -1,4 +1,9 @@ -import { BadGatewayException, Injectable, Logger } from "@nestjs/common"; +import { + BadGatewayException, + BadRequestException, + Injectable, + Logger, +} from "@nestjs/common"; import { HttpService } from "@nestjs/axios"; import { AxiosError } from "axios"; import { firstValueFrom } from "rxjs"; @@ -31,6 +36,24 @@ export class PaymentClientService { return this.call("POST", "/payments/initiate", request); } + /** + * POST /payments/reconcile — settlement check for a domain order + * (reconcile-before-cancel). Live-queries every non-failed intent at the + * provider and registers any late capture found (flips it to SUCCEEDED and + * emits payment.succeeded). `unverifiable: true` = could not confirm + * "not paid" — the caller must NOT cancel/expire the order. + */ + async reconcileReference( + referenceType: PaymentReferenceType, + referenceId: string, + ): Promise<{ paid: boolean; unverifiable: boolean }> { + return this.call("POST", "/payments/reconcile", { + service: PaymentService.FREIGHT, + referenceType, + referenceId, + }); + } + /** GET /payments/intents?… — active intent by domain reference; null when none exists. */ async getIntentByReference( referenceType: PaymentReferenceType, @@ -49,6 +72,33 @@ export class PaymentClientService { } } + /** + * POST /payments/intents/:id/confirm — submit an OTP for a COLLECT_OTP provider + * (CAC Bank). A wrong/expired OTP comes back as 400 from the payment service; + * surface that as a BadRequest (retryable) rather than a 502, so the payer can + * re-enter the code. + */ + async confirmOtp(intentId: string, otp: string): Promise { + try { + return await this.call( + "POST", + `/payments/intents/${intentId}/confirm`, + { otp }, + ); + } catch (err) { + // `call` re-throws raw 404s and masks every other 4xx as BadGateway; an + // unknown intent or a bad OTP is client-fixable, so translate both to 400. + if (err instanceof AxiosError && err.response?.status === 404) { + throw new BadRequestException("PaymentIntent not found"); + } + if (err instanceof BadGatewayException) { + const detail = err.message.replace(/^Payment service error: /, ""); + throw new BadRequestException(detail); + } + throw err; + } + } + private async call(method: "GET" | "POST", path: string, body?: unknown): Promise { const url = `${this.baseUrl}${path}`; try { diff --git a/apps/edr-freight-api/src/modules/payment/payment.module.ts b/apps/edr-freight-api/src/modules/payment/payment.module.ts index 05267746d..961aa32bd 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.module.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.module.ts @@ -56,7 +56,13 @@ function rabbitMQImport(): DynamicModule[] { @Module({ imports: [ - HttpModule.register({ timeout: 10_000 }), + // CAC Bank's initiate SMSes an OTP and routinely takes >10s, so the old + // 10s cap 502'd every CAC charge while the bank was still working — + // orphaning an intent the payer had already been texted about. Matches + // the passenger API's budget. + HttpModule.register({ + timeout: Number(process.env.PAYMENT_API_HTTP_TIMEOUT_MS) || 60_000, + }), ConfigModule, forwardRef(() => BillingModule), // forwardRef(() => TrainSchedulingModule), diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.spec.ts b/apps/edr-freight-api/src/modules/payment/payment.service.spec.ts new file mode 100644 index 000000000..ed0f8da58 --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment/payment.service.spec.ts @@ -0,0 +1,190 @@ +import { BadRequestException, NotFoundException } from "@nestjs/common"; +import { of, throwError } from "rxjs"; +import { AxiosError, AxiosHeaders } from "axios"; +import { PaymentReferenceType, ProviderPaymentStatus } from "@edr/types"; + +import { PaymentClientService } from "./payment-client.service"; +import { PaymentService } from "./payment.service"; + +/** Local intent projection row (the invoice's `paymentId` points at this). */ +function localIntent(overrides: Record = {}) { + return { + id: "intent-1", + refId: "booking-1", + referenceType: PaymentReferenceType.SHIPMENT, + status: "action-required", + method: "cac-bank", + merchantOrderId: "EDR_INV_1", + clientAction: { type: "COLLECT_OTP", providerOrderId: "471583397" }, + ...overrides, + }; +} + +function makeRepo(rows: Record[]) { + const store = [...rows]; + return { + findOneBy: jest.fn((where: Record) => + Promise.resolve( + store.find((r) => + Object.entries(where).every(([k, v]) => r[k] === v), + ) ?? null, + ), + ), + update: jest.fn((where: { id: string }, data: Record) => { + const row = store.find((r) => r.id === where.id); + if (row) Object.assign(row, data); + return Promise.resolve(undefined); + }), + }; +} + +describe("PaymentService.confirmOtp", () => { + const build = ( + client: Partial, + rows = [localIntent()], + ) => { + const repo = makeRepo(rows); + const billing = { settleByPaymentId: jest.fn().mockResolvedValue(null) }; + const service = new PaymentService( + repo as never, + client as never, + billing as never, + ); + return { service, repo, billing }; + }; + + it("settles the local intent and tells billing to settle the invoice on SUCCEEDED", async () => { + const paidAt = "2026-07-31T10:00:00.000Z"; + const { service, repo, billing } = build({ + getIntentByReference: jest + .fn() + .mockResolvedValue({ intentId: "gw-1", status: "REQUIRES_ACTION" }), + confirmOtp: jest.fn().mockResolvedValue({ + intentId: "gw-1", + status: ProviderPaymentStatus.SUCCEEDED, + providerTxnId: "11709363209530624", + paidAt, + }), + }); + + const result = await service.confirmOtp("intent-1", "8280"); + + expect(repo.update).toHaveBeenCalledWith( + { id: "intent-1" }, + expect.objectContaining({ + status: "success", + transactionId: "11709363209530624", + }), + ); + // Billing settles the invoice linked by this intent id. + expect(billing.settleByPaymentId).toHaveBeenCalledWith( + "intent-1", + "11709363209530624", + new Date(paidAt), + ); + expect(result.status).toBe(ProviderPaymentStatus.SUCCEEDED); + }); + + it("forwards the OTP against the GATEWAY intent id, not the local one", async () => { + const confirmOtp = jest + .fn() + .mockResolvedValue({ status: ProviderPaymentStatus.REQUIRES_ACTION }); + const { service } = build({ + getIntentByReference: jest.fn().mockResolvedValue({ intentId: "gw-1" }), + confirmOtp, + }); + + await service.confirmOtp("intent-1", "8280"); + + expect(confirmOtp).toHaveBeenCalledWith("gw-1", "8280"); + }); + + it("leaves the intent open and does not settle when the OTP is not accepted", async () => { + const { service, repo, billing } = build({ + getIntentByReference: jest.fn().mockResolvedValue({ intentId: "gw-1" }), + confirmOtp: jest.fn().mockResolvedValue({ + status: ProviderPaymentStatus.REQUIRES_ACTION, + failureMessage: "OTP confirmation failed", + }), + }); + + const result = await service.confirmOtp("intent-1", "0000"); + + expect(billing.settleByPaymentId).not.toHaveBeenCalled(); + expect(repo.update).toHaveBeenCalledWith( + { id: "intent-1" }, + expect.objectContaining({ status: "action-required" }), + ); + expect(result.status).toBe(ProviderPaymentStatus.REQUIRES_ACTION); + }); + + it("404s when the gateway has no active intent for the reference", async () => { + const { service } = build({ + getIntentByReference: jest.fn().mockResolvedValue(null), + confirmOtp: jest.fn(), + }); + + await expect(service.confirmOtp("intent-1", "8280")).rejects.toBeInstanceOf( + NotFoundException, + ); + }); +}); + +describe("PaymentClientService.confirmOtp", () => { + const axiosErr = (status: number, message: string) => + new AxiosError( + `Request failed with status code ${status}`, + undefined, + undefined, + undefined, + { + status, + statusText: "", + data: { message }, + headers: new AxiosHeaders(), + config: { headers: new AxiosHeaders() }, + }, + ); + + const build = (request: jest.Mock) => + new PaymentClientService({ request } as never); + + it("posts the OTP to the payment service intent-confirm route", async () => { + const request = jest + .fn() + .mockReturnValue(of({ data: { intentId: "gw-1", status: "SUCCEEDED" } })); + + const result = await build(request).confirmOtp("gw-1", "8280"); + + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + url: expect.stringContaining("/payments/intents/gw-1/confirm"), + data: { otp: "8280" }, + }), + ); + expect(result.status).toBe("SUCCEEDED"); + }); + + it("maps a rejected OTP (400) to BadRequest so the payer can retry", async () => { + const request = jest + .fn() + .mockReturnValue( + throwError(() => axiosErr(400, "OTP confirmation failed")), + ); + + await expect(build(request).confirmOtp("gw-1", "0000")).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it("maps an unknown intent (404) to BadRequest rather than a gateway error", async () => { + const request = jest + .fn() + .mockReturnValue(throwError(() => axiosErr(404, "PaymentIntent not found"))); + + await expect(build(request).confirmOtp("nope", "8280")).rejects.toBeInstanceOf( + BadRequestException, + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index efc4ac926..0f628e39c 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -193,6 +193,30 @@ export class PaymentService { * marked paid WITHOUT emitting — the caller (billing) settles inline after it * has stored the intent id, avoiding a settle-before-correlation race. */ + /** + * Reconcile-before-cancel: ask the payment service whether ANY intent for + * this shipment actually settled at the provider (bank/gateway). A late + * capture found there is registered as SUCCEEDED and emits payment.succeeded, + * which drives the normal paid flow. A network/provider error reports + * `unverifiable` — the caller must not expire the order on unknown. + */ + async reconcileShipment( + referenceId: string, + ): Promise<{ paid: boolean; unverifiable: boolean }> { + try { + const result = await this.paymentClient.reconcileReference( + PaymentReferenceType.SHIPMENT, + referenceId, + ); + return { paid: result.paid, unverifiable: result.unverifiable }; + } catch (err) { + this.logger.warn( + `Reconcile for shipment ${referenceId} failed: ${(err as Error).message}`, + ); + return { paid: false, unverifiable: true }; + } + } + async initiate(input: InitiateIntentInput): Promise { try { const isCbeBill = input.method === ProviderMethod.CBE_BILL; @@ -364,6 +388,53 @@ export class PaymentService { return this.formatIntentStatus(refreshed ?? local); } + /** + * Submit an OTP for a COLLECT_OTP provider (CAC Bank). Keyed by the LOCAL intent + * id (the invoice's `paymentId`) so the right invoice settles even when several + * invoices share a domain reference. The active gateway intent is looked up by + * reference, the OTP is forwarded, and the projection is refreshed. On success + * billing settles the linked invoice (idempotent — the outbox path converges too). + * A wrong/expired OTP bubbles up as a 400 and leaves the intent open for retry. + */ + async confirmOtp(intentId: string, otp: string): Promise { + const local = await this.paymentRepo.findOneBy({ id: intentId }); + if (!local) throw new NotFoundException("PaymentIntent not found"); + + const snapshot = await this.paymentClient.getIntentByReference( + (local.referenceType as PaymentReferenceType) ?? + PaymentReferenceType.SHIPMENT, + local.refId, + ); + if (!snapshot) { + throw new NotFoundException("No active payment to confirm"); + } + + const confirmed = await this.paymentClient.confirmOtp( + snapshot.intentId, + otp, + ); + + if (confirmed.status === ProviderPaymentStatus.SUCCEEDED) { + await this.markIntentSucceeded(local.id, { + providerTxnId: confirmed.providerTxnId, + paidAt: confirmed.paidAt ? new Date(confirmed.paidAt) : undefined, + notify: true, + }); + } else { + await this.paymentRepo.update( + { id: local.id }, + { + status: this.toLocalStatus(confirmed.status), + failerCode: confirmed.failureCode ?? undefined, + failureMessage: confirmed.failureMessage ?? undefined, + }, + ); + } + + const refreshed = await this.paymentRepo.findOneBy({ id: local.id }); + return this.formatIntentStatus(refreshed ?? local); + } + /** * Mark a gateway intent paid and (by default) notify billing to settle the * linked invoice. Idempotent — no-op when already success. Pass `notify: false` diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/yards.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/yards.repository.interface.ts index 75baca5de..104603c6d 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/yards.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/yards.repository.interface.ts @@ -6,6 +6,7 @@ import { Yard } from '../entities/yard.entity'; export interface IYardsRepository { findById(id: string): Promise; findByCode(code: string): Promise; + findByLabelInsensitive(label: string): Promise; findAll(options?: FindManyOptions): Promise; findAndCount(options?: FindManyOptions): Promise<[Yard[], number]>; findPaged(query: ListYardsQueryDto): Promise>; diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/yards.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/yards.repository.ts index 1cb74d9ce..5db5b72ae 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/yards.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/yards.repository.ts @@ -22,6 +22,15 @@ export class YardsRepository implements IYardsRepository { return this.repo.findOne({ where: { code } }); } + /** Case/whitespace-insensitive label lookup — backs the duplicate-yard guard. */ + findByLabelInsensitive(label: string): Promise { + return this.repo + .createQueryBuilder('yard') + .where('LOWER(TRIM(yard.label)) = LOWER(TRIM(:label))', { label }) + .andWhere('yard.deleted_at IS NULL') + .getOne(); + } + findAll(options?: FindManyOptions): Promise { return this.repo.find(options); } diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yards.duplicate-label.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yards.duplicate-label.spec.ts new file mode 100644 index 000000000..8affab199 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yards.duplicate-label.spec.ts @@ -0,0 +1,36 @@ +import { ConflictException } from '@nestjs/common'; + +import { YardsService } from './yards.service'; +import type { Yard } from '../entities/yard.entity'; + +const sebeta = { id: 'yard-1', code: 'LEGACY_DEST', label: 'Sebeta' } as Yard; + +const service = (): YardsService => + new YardsService( + { + findById: async (id: string) => ({ ...sebeta, id }), + findByCode: async () => null, + findByLabelInsensitive: async (label: string) => + label.trim().toLowerCase() === 'sebeta' ? sebeta : null, + create: async (d: Partial) => d as Yard, + update: async (_id: string, d: Partial) => d as Yard, + } as never, + { resolveCreateOrder: async () => 1 } as never, + ); + +describe('duplicate yard labels are rejected', () => { + it('blocks create even when the generated code differs (Sebeta vs LEGACY_DEST)', async () => { + await expect( + service().create({ label: ' sebeta ', country: 'ET' } as never), + ).rejects.toThrow(ConflictException); + }); + + it('blocks renaming a yard onto another yard label, allows renaming itself', async () => { + await expect( + service().update('yard-2', { label: 'SEBETA' } as never), + ).rejects.toThrow(ConflictException); + await expect( + service().update('yard-1', { label: 'Sebeta' } as never), + ).resolves.toBeTruthy(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts index b698b456c..7dd29ded1 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts @@ -31,6 +31,9 @@ export class YardsService { /** Create a yard. */ async create(dto: CreateYardDto): Promise { + // Label check first: the code check alone let "sebeta" in next to "Sebeta" + // when the existing yard's code didn't match its label (LEGACY_DEST). + await this.assertLabelAvailable(dto.label); const code = generateCode(dto.label).slice(0, 40); const existing = await this.repository.findByCode(code); if (existing) throw new ConflictException(`Yard with label "${dto.label}" conflicts with existing code "${code}"`); @@ -53,11 +56,20 @@ export class YardsService { /** Update a yard. */ async update(id: string, dto: UpdateYardDto): Promise { await this.findById(id); + if (dto.label !== undefined) await this.assertLabelAvailable(dto.label, id); const updated = await this.repository.update(id, dto); if (!updated) throw new NotFoundException(`Yard ${id} not found`); return updated; } + /** No two active yards may share a label (case/whitespace-insensitive). */ + private async assertLabelAvailable(label: string, exceptId?: string): Promise { + const dupe = await this.repository.findByLabelInsensitive(label); + if (dupe && dupe.id !== exceptId) { + throw new ConflictException(`A yard named "${dupe.label}" already exists`); + } + } + /** * Soft-delete a yard. The unique `code` (and the label) get a `@` * suffix first — e.g. SEBETA → SEBETA@1755612345678 — so a new yard with the diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts index e84eb6124..fefbbc868 100644 --- a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts @@ -139,6 +139,17 @@ export class SchedulingRescheduleService { actorUserId?: string, ) { const plan = await this.previewReschedule(scheduleId, dto); + // Gov bookings may never be pushed off a train. Checked here (not only in + // unassignBooking) because the displacement loop below swallows unassign + // errors and force-detaches the booking anyway. + const govDisplaced = plan.displaced.filter((b) => b.isGovernment); + if (govDisplaced.length) { + throw new BadRequestException( + `Government bookings cannot be removed from a train: ${govDisplaced + .map((b) => b.reference) + .join(', ')}`, + ); + } const expectedDisplaced = new Set(plan.displaced.map((b) => b.id)); const providedDisplaced = new Set(dto.displacedBookingIds); if ( diff --git a/apps/edr-freight-api/src/modules/signatures/dto/save-signature.dto.ts b/apps/edr-freight-api/src/modules/signatures/dto/save-signature.dto.ts index a5ae63100..a1f245e23 100644 --- a/apps/edr-freight-api/src/modules/signatures/dto/save-signature.dto.ts +++ b/apps/edr-freight-api/src/modules/signatures/dto/save-signature.dto.ts @@ -1,5 +1,5 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsString, MinLength } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, MinLength } from 'class-validator'; export class SaveSignatureDto { @ApiProperty() @@ -13,6 +13,15 @@ export class SaveSignatureDto { @IsString() @MinLength(20) signatureImageBase64!: string; + + @ApiPropertyOptional({ + description: + 'Company stamp/seal image as base64 (with or without data URL prefix). Omit to keep the existing saved stamp.', + }) + @IsOptional() + @IsString() + @MinLength(20) + stampImageBase64?: string; } export class SavedSignatureDto { @@ -21,4 +30,7 @@ export class SavedSignatureDto { @ApiProperty({ nullable: true }) signatureImageUrl!: string | null; + + @ApiProperty({ nullable: true }) + stampImageUrl!: string | null; } diff --git a/apps/edr-freight-api/src/modules/signatures/entities/saved-signature.entity.ts b/apps/edr-freight-api/src/modules/signatures/entities/saved-signature.entity.ts index 08263cf7d..3814eb017 100644 --- a/apps/edr-freight-api/src/modules/signatures/entities/saved-signature.entity.ts +++ b/apps/edr-freight-api/src/modules/signatures/entities/saved-signature.entity.ts @@ -22,4 +22,11 @@ export class SavedSignature extends BaseEntity { @ManyToOne(() => FileRecord, { nullable: true }) @JoinColumn({ name: 'signature_file_id' }) signatureFile?: FileRecord | null; + + @Column({ name: 'stamp_file_id', type: 'uuid', nullable: true }) + stampFileId?: string | null; + + @ManyToOne(() => FileRecord, { nullable: true }) + @JoinColumn({ name: 'stamp_file_id' }) + stampFile?: FileRecord | null; } diff --git a/apps/edr-freight-api/src/modules/signatures/signatures.controller.ts b/apps/edr-freight-api/src/modules/signatures/signatures.controller.ts index d112edef3..24dd0aa5c 100644 --- a/apps/edr-freight-api/src/modules/signatures/signatures.controller.ts +++ b/apps/edr-freight-api/src/modules/signatures/signatures.controller.ts @@ -33,6 +33,7 @@ export class SignaturesController { userId, signerDisplayName: dto.signerDisplayName, signatureImageBase64: dto.signatureImageBase64, + stampImageBase64: dto.stampImageBase64, }); return this.signaturesService.getForUser(userId); } diff --git a/apps/edr-freight-api/src/modules/signatures/signatures.repository.ts b/apps/edr-freight-api/src/modules/signatures/signatures.repository.ts index 70c04ad7b..de8007d65 100644 --- a/apps/edr-freight-api/src/modules/signatures/signatures.repository.ts +++ b/apps/edr-freight-api/src/modules/signatures/signatures.repository.ts @@ -16,7 +16,7 @@ export class SignaturesRepository extends BaseRepository { findByUserId(userId: string): Promise { return this.repository.findOne({ where: { userId } as never, - relations: ['signatureFile'], + relations: ['signatureFile', 'stampFile'], }); } diff --git a/apps/edr-freight-api/src/modules/signatures/signatures.service.ts b/apps/edr-freight-api/src/modules/signatures/signatures.service.ts index 7137ab6a5..836888dab 100644 --- a/apps/edr-freight-api/src/modules/signatures/signatures.service.ts +++ b/apps/edr-freight-api/src/modules/signatures/signatures.service.ts @@ -13,6 +13,8 @@ export interface UpsertSignatureInput { userId: string; signerDisplayName: string; signatureImageBase64: string; + /** Optional company stamp/seal; omitted = keep the existing saved stamp. */ + stampImageBase64?: string; } @Injectable() @@ -31,15 +33,65 @@ export class SignaturesService { return { signerDisplayName: saved.signerDisplayName, signatureImageUrl: await this.inlineImageUrl(saved.signatureFile?.url), + stampImageUrl: await this.inlineImageUrl(saved.stampFile?.url), }; } - /** Insert or update the user's reusable signature, storing the image in MinIO. */ + /** Insert or update the user's reusable signature (and optional stamp), storing the images in MinIO. */ async upsertForUser(input: UpsertSignatureInput): Promise { - const buffer = this.decodeSignatureImage(input.signatureImageBase64); - const file: Express.Multer.File = { - fieldname: 'signature', - originalname: `signature-${input.userId}.png`, + // Capture the previously referenced files so we can remove them only AFTER + // the saved_signatures row is repointed — deleting first would violate the + // FK constraint (saved_signatures.*_file_id -> files.id). + const existing = await this.signaturesRepository.findByUserId(input.userId); + const previousFileId = existing?.signatureFileId ?? null; + const previousStampFileId = existing?.stampFileId ?? null; + + const fileRecord = await this.filesService.upload({ + resourceId: input.userId, + resource: 'saved_signatures', + code: 'signature', + file: this.toUploadFile('signature', input.userId, input.signatureImageBase64), + }); + + const stampRecord = input.stampImageBase64 + ? await this.filesService.upload({ + resourceId: input.userId, + resource: 'saved_signatures', + code: 'stamp', + file: this.toUploadFile('stamp', input.userId, input.stampImageBase64), + }) + : null; + + const saved = await this.signaturesRepository.upsert({ + userId: input.userId, + signerDisplayName: input.signerDisplayName, + signatureFileId: fileRecord.id, + // Omitted stamp keeps whatever was saved before. + ...(stampRecord ? { stampFileId: stampRecord.id } : {}), + }); + + const staleIds = [ + previousFileId !== fileRecord.id ? previousFileId : null, + stampRecord && previousStampFileId !== stampRecord.id + ? previousStampFileId + : null, + ].filter((id): id is string => Boolean(id)); + if (staleIds.length) { + await this.dataSource.getRepository(FileRecord).delete(staleIds); + } + + return saved; + } + + private toUploadFile( + kind: 'signature' | 'stamp', + userId: string, + base64: string, + ): Express.Multer.File { + const buffer = this.decodeSignatureImage(base64); + return { + fieldname: kind, + originalname: `${kind}-${userId}.png`, encoding: '7bit', mimetype: 'image/png', size: buffer.length, @@ -49,33 +101,6 @@ export class SignaturesService { filename: '', path: '', }; - - // Capture the previously referenced file so we can remove it only AFTER the - // saved_signatures row is repointed — deleting it first would violate the - // FK constraint (saved_signatures.signature_file_id -> files.id). - const existing = await this.signaturesRepository.findByUserId(input.userId); - const previousFileId = existing?.signatureFileId ?? null; - - const fileRecord = await this.filesService.upload({ - resourceId: input.userId, - resource: 'saved_signatures', - code: 'signature', - file, - }); - - const saved = await this.signaturesRepository.upsert({ - userId: input.userId, - signerDisplayName: input.signerDisplayName, - signatureFileId: fileRecord.id, - }); - - if (previousFileId && previousFileId !== fileRecord.id) { - await this.dataSource - .getRepository(FileRecord) - .delete({ id: previousFileId }); - } - - return saved; } private async inlineImageUrl( diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/schedule-wagon-adjustment-log.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/schedule-wagon-adjustment-log.entity.ts index 485d29452..56459870c 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/schedule-wagon-adjustment-log.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/schedule-wagon-adjustment-log.entity.ts @@ -1,15 +1,17 @@ import { BaseEntity } from '@edr/api-common'; import { Column, Entity, Index } from 'typeorm'; -export const WAGON_ADJUSTMENT_ACTIONS = ['ADD', 'REMOVE'] as const; +export const WAGON_ADJUSTMENT_ACTIONS = ['ADD', 'REMOVE', 'SWITCH'] as const; export type WagonAdjustmentAction = (typeof WAGON_ADJUSTMENT_ACTIONS)[number]; /** * History row for a consist adjustment made from a schedule: staff coupled a - * wagon onto (ADD) or detached one from (REMOVE) the schedule's built train — - * e.g. trimming free wagons whose tare pushed gross weight over the - * locomotives' pull limit. Plain columns (no FK relations) so the history - * survives the wagon or train being deleted later. + * wagon onto (ADD), detached one from (REMOVE), or swapped the physical wagon + * under a loaded slot (SWITCH — wagonNumber reads "OLD → NEW") on the + * schedule's built train. `yardId` records WHERE it happened: the origin yard + * before departure, or the mid-route stop the train was standing at. Plain + * columns (no FK relations) so the history survives the wagon or train being + * deleted later. */ @Entity({ schema: 'freight', name: 'schedule_wagon_adjustment_logs' }) @Index(['trainScheduleId']) @@ -33,6 +35,9 @@ export class ScheduleWagonAdjustmentLog extends BaseEntity { @Column({ name: 'adjusted_by_user_id', type: 'uuid', nullable: true }) adjustedByUserId!: string | null; + @Column({ name: 'yard_id', type: 'uuid', nullable: true }) + yardId!: string | null; + @Column({ name: 'occurred_at', type: 'timestamptz', default: () => 'now()' }) occurredAt!: Date; } diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts index 2b9968f04..0581f6000 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts @@ -153,6 +153,14 @@ export class TrainSchedule extends BaseEntity { @Column({ name: 'rule_reopen_delay_minutes', type: 'int', nullable: true }) ruleReopenDelayMinutes?: number | null; + /** + * Per-schedule pay-window override (minutes). NULL = use the live global + * value for the schedule's direction. Unlike the other rule_* snapshots this + * is only written by an explicit staff override, never stamped at creation. + */ + @Column({ name: 'rule_payment_window_minutes', type: 'int', nullable: true }) + rulePaymentWindowMinutes?: number | null; + @Column({ name: 'rule_import_window_lead_days', type: 'int', nullable: true }) ruleImportWindowLeadDays?: number | null; diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts index 294fb93f1..b0cbeb886 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts @@ -39,7 +39,11 @@ export class TrainSchedulesRepository extends BaseRepository { physicalWagon: true, allocations: { booking: { company: true, bookingContainers: { containerType: true } }, - containerItems: true, + // Both size sources loaded: the item's own container_type_id FK + // (always set for a manually-entered item) and the booking-line + // fallback via bookingContainer.containerType — the marshalling + // document's 40ft/20ft tally reads whichever is present. + containerItems: { containerType: true, bookingContainer: { containerType: true } }, }, }, }, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts index 62ed50c0a..d43cff60b 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts @@ -9,6 +9,9 @@ export const BATCH_TIMEZONE = 'Africa/Addis_Ababa'; +/** How long before the pay deadline the one reminder notification goes out. */ +export const PAYMENT_REMINDER_LEAD_MS = 10 * 60_000; + /** Fallback wagons-per-booking when a booking has no computed `wagonsRequired`. */ export const DEFAULT_WAGONS_PER_BOOKING = 1; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index a402e6237..1a1a75275 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -109,6 +109,7 @@ describe('BookingBatchService — PAID reconcile', () => { windowDurationHours: 3, docReviewMinutes: 30, paymentWindowMinutes: 60, + exportPaymentWindowMinutes: 60, }), }; @@ -150,6 +151,8 @@ describe('BookingBatchService — PAID reconcile', () => { { issuePayable: jest.fn().mockResolvedValue(null), expirePayable: jest.fn().mockResolvedValue(undefined), + // Gateway reconcile-before-expire: default = verifiably unpaid. + reconcilePayable: jest.fn().mockResolvedValue({ paid: false, unverifiable: false }), } as never, { emitPhase: jest.fn() } as never, { computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never, @@ -708,7 +711,13 @@ describe('BookingBatchService — PAID reconcile', () => { notifier as never, { addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never, trainSchedulingService as never, - { issuePayable: jest.fn(), expirePayable: jest.fn() } as never, + { + issuePayable: jest.fn(), + expirePayable: jest.fn(), + reconcilePayable: jest + .fn() + .mockResolvedValue({ paid: false, unverifiable: false }), + } as never, { emitPhase: jest.fn() } as never, { computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never, undefined, @@ -731,7 +740,13 @@ describe('BookingBatchService — PAID reconcile', () => { notifier as never, { addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never, trainSchedulingService as never, - { issuePayable: jest.fn(), expirePayable: jest.fn() } as never, + { + issuePayable: jest.fn(), + expirePayable: jest.fn(), + reconcilePayable: jest + .fn() + .mockResolvedValue({ paid: false, unverifiable: false }), + } as never, { emitPhase: jest.fn() } as never, { computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never, undefined, @@ -762,7 +777,13 @@ describe('BookingBatchService — PAID reconcile', () => { notifier as never, { addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never, trainSchedulingService as never, - { issuePayable: jest.fn(), expirePayable: jest.fn() } as never, + { + issuePayable: jest.fn(), + expirePayable: jest.fn(), + reconcilePayable: jest + .fn() + .mockResolvedValue({ paid: false, unverifiable: false }), + } as never, { emitPhase: jest.fn() } as never, { computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never, undefined, @@ -1015,7 +1036,7 @@ describe('BookingBatchService — PAID reconcile', () => { ...(waiting as unknown as Record), status: 'SELECTED_FOR_BATCH', trainScheduleId: exportScheduleId, - paymentDeadline: new Date(Date.now() - 1_000), + paymentDeadline: new Date(Date.now() - 60_000), originYardId: 'yard-a', destinationYardId: 'yard-b', priorityScore: 0, @@ -1307,10 +1328,9 @@ describe('BookingBatchService — built-train wagon capacity', () => { await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false); }); - it('is FULL for the trade direction once the border edge is sold out, even with home legs free', async () => { - // Export b→c holds every wagon of the border crossing: no further export - // can board anywhere (they all must ride that edge), so the window closes — - // while intercity keeps booking the free a→b leg through the per-leg budget. + it('is NOT full when the border edge is sold out but a home leg still has room', async () => { + // FULL is corridor-wide now: b→dj holds every wagon, but a→b is empty, so + // sub-corridor bookings can still sell that leg — the window stays open. const { service } = buildService({ physicalWagons: 2, routeStops: ['yard-a', 'yard-b', 'yard-dj'], @@ -1324,6 +1344,23 @@ describe('BookingBatchService — built-train wagon capacity', () => { reservedBooking('b2', { origin: 'yard-b', dest: 'yard-dj' }), ], }); + await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false); + }); + + it('is FULL once every leg of the corridor is sold out', async () => { + const { service } = buildService({ + physicalWagons: 2, + routeStops: ['yard-a', 'yard-b', 'yard-dj'], + yardCountries: { + 'yard-a': 'ETHIOPIA', + 'yard-b': 'ETHIOPIA', + 'yard-dj': 'DJIBOUTI', + }, + reserved: [ + reservedBooking('b1', { origin: 'yard-a', dest: 'yard-dj' }), + reservedBooking('b2', { origin: 'yard-a', dest: 'yard-dj' }), + ], + }); await expect(service.isScheduleFull(scheduleId)).resolves.toBe(true); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 41c8023f0..5ce75abd2 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -27,6 +27,8 @@ import { BookingPricingService } from '../bookings/booking-pricing.service'; import { formatRouteLabel } from '../routes/entities/route.entity'; import { RouteMilestone } from '../routes/entities/route-milestone.entity'; import { Yard } from '../rule-engine/entities/yard.entity'; +import { CargoType } from '../rule-engine/entities/cargo-type.entity'; +import { ContainerType } from '../rule-engine/entities/container-type.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; @@ -61,11 +63,13 @@ import { DEFAULT_CONTAINER_WAGON_LENGTH_METERS, DEFAULT_CONTAINER_WAGON_TARE_TONS, DEFAULT_WAGONS_PER_BOOKING, + PAYMENT_REMINDER_LEAD_MS, } from "./booking-batch.constants"; import { LocomotiveLimits, WagonTypeDimensions, bookingCargoTons, + bulkItemWagonsRequired, bookingGrossWeightTons, deriveTrainCapacityFromLocomotive, sizePartialOfferWagons, @@ -115,6 +119,36 @@ export interface ExportSpaceReport { fullMessage: string | null; } +/** + * One export train the customer can pick for a shipment day: live free-wagon + * space measured against THE BOOKING'S allowed wagon types (so the per-type + * list doubles as "what cargo this train can take for you"). Unpaid holds + * count as taken; lapsed holds free up via the lazy-expiry capacity filter. + */ +export interface ExportTrainOption { + scheduleId: string; + /** Schedule's train number (falls back to the built train's number). */ + trainNumber: string | null; + /** Built train's name/code, when the schedule runs a Train Builder train. */ + trainName: string | null; + departure: Date; + /** Booking cutoff for this train (windowClosesAt), null on legacy rows. */ + bookingClosesAt: Date | null; + /** Whether the export FCFS window is open for booking right now. */ + isOpen: boolean; + /** Best bookable wagons across the booking's allowed types. */ + freeWagons: number; + /** Wagons this booking needs — `fits` = freeWagons >= neededWagons. */ + neededWagons: number; + fits: boolean; + byWagonType: Array<{ + wagonTypeId: string | null; + code: string | null; + name: string | null; + freeWagons: number; + }>; +} + /** A day-level pool key: all trains on this route departing on this EAT day. */ interface RouteDayGroup { originYardId: string; @@ -267,11 +301,20 @@ export interface BatchBoardSchedule { /** Train length used by allocated bookings (from wagon-type dimensions). */ allocatedLengthMeters: number; maxLengthMeters: number | null; - /** Weight committed on the train (allocated + selected-for-batch). */ + /** + * Weight committed on the train (allocated + selected-for-batch). On a + * multi-stop corridor this is the HEAVIEST single edge, not the sum — + * disjoint legs (intercity + export) never ride together, so summing + * them over-reports the train against the pull limit. + */ usedWeightTons: number; maxWeightTons: number | null; /** Wagon-slot cap for the train (locomotive/wagon-type derived). */ maxWagons: number | null; + /** Physical consist length of the built train (Train Builder), null without one. */ + trainLengthMeters: number | null; + /** Committed gross weight per corridor edge, in stop order; null on 2-stop routes. */ + legUsage: Array<{ from: string; to: string; usedWeightTons: number }> | null; }; counts: { allocated: number; @@ -431,6 +474,9 @@ export class BookingBatchService implements OnModuleInit { group.destinationYardId, group.day, ); + // Backstop: PAID bookings stranded without a schedule (hold expired before + // the payment landed) get re-placed onto whatever fits today. + await this.rescueStrandedPaidForDay(group.day); for (const scheduleId of scheduleIds) { await this.settleDueReservations(scheduleId); await this.reconcilePaidUnlinked(scheduleId); @@ -491,17 +537,26 @@ export class BookingBatchService implements OnModuleInit { }); if (!booking) return; if (!booking.trainScheduleId) { - // A paid booking with no train is money taken and nothing boarding — - // scream so staff pin it to a schedule manually (batch board / assign). + // A paid booking with no train is money taken and nothing boarding. The + // hold was expired before the payment landed (webhook lag beat the + // reconcile, or the stranding predates it) — try to re-place it on a + // fitting same-day train before falling back to a manual-assign scream. if (booking.paymentStatus === "PAID" || booking.status === "PAID") { - this.logger.error( - `PAID booking ${booking.reference ?? bookingId} has no train_schedule_id — ` + - `its reservation was likely expired before the payment landed. ` + - `Assign it to a schedule manually from the batch board.`, - ); + const rescuedScheduleId = await this.replaceStrandedPaidBooking(booking); + if (!rescuedScheduleId) { + this.logger.error( + `PAID booking ${booking.reference ?? bookingId} has no train_schedule_id — ` + + `its reservation was likely expired before the payment landed and no ` + + `same-day train fits it. Assign it to a schedule manually from the batch board.`, + ); + return; + } + booking.trainScheduleId = rescuedScheduleId; + } else { + return; } - return; } + if (!booking.trainScheduleId) return; // unreachable — narrows the rescue path for TS const isBatchPaid = booking.status === "SELECTED_FOR_BATCH" || @@ -559,6 +614,26 @@ export class BookingBatchService implements OnModuleInit { const linked = await this.trainScheduleBookingsRepository.existsForBooking(bookingId); + // Intercity is allocated MANUALLY: payment secures the ride, staff then + // place it on whichever same-route train suits (intercity panel). Unpin + // from the train it reserved against — that train may be the wrong one by + // the time it departs — and return it to the waiting pool as PAID. + if (!linked && booking.tradeDirection === "DOMESTIC" && !booking.isGovernment) { + await this.dataSource.getRepository(Booking).update(bookingId, { + trainScheduleId: null, + schedulingStatus: "ELIGIBLE", + paymentDeadline: null, + } as never); + this.logger.log( + `[BATCH] intercity ${booking.reference ?? bookingId} PAID — awaiting manual placement by staff`, + ); + void this.completeTrackingMilestones(bookingId, [ + "FREIGHT_PAYMENT_PENDING", + "FREIGHT_PAYMENT_SETTLED", + ]); + this.notifyBoardChanged(booking.trainScheduleId, "intercity_paid_unplaced"); + return; + } if (!linked) { if (await this.holdIfWagonShort(booking.trainScheduleId, booking)) return; await this.allocate(booking.trainScheduleId, booking, "paid"); @@ -616,6 +691,69 @@ export class BookingBatchService implements OnModuleInit { await this.ensurePaidBookingAllocated(bookingId); } + /** + * Day-level backstop for stranded PAID bookings: reconcilePaidUnlinked is + * keyed on train_schedule_id, so a booking whose hold was expired (schedule + * cleared) before its payment landed never re-enters it. Sweep the day's + * PAID-but-unscheduled bookings through ensurePaidBookingAllocated, which + * re-places them on a fitting train. + */ + private async rescueStrandedPaidForDay(day: string): Promise { + const stranded: Array<{ id: string }> = await this.dataSource.query( + `SELECT id FROM freight.bookings + WHERE deleted_at IS NULL + AND train_schedule_id IS NULL + AND (payment_status = 'PAID' OR status = 'PAID') + AND scheduled_date IS NOT NULL + AND DATE(scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = $1`, + [day], + ); + for (const { id } of stranded) { + await this.ensurePaidBookingAllocated(id).catch((err) => + this.logger.error( + `Stranded-PAID rescue failed for booking ${id}: ${(err as Error).message}`, + ), + ); + } + } + + /** + * Re-place a PAID booking whose hold was expired before the payment landed + * (trainScheduleId already cleared). Picks the earliest same-day train that + * still fits the booking's whole need on ITS OWN leg and pins the booking to + * it. Returns the schedule id, or null when no train fits (manual assign). + */ + private async replaceStrandedPaidBooking( + booking: Booking, + ): Promise { + if (!booking.scheduledDate) return null; + // The booking loaded by ensurePaidBookingAllocated carries no cargo + // relations; needFor/fittingTrainsForDay derive the wagon need from them. + const full = await this.dataSource.getRepository(Booking).findOne({ + where: { id: booking.id }, + relations: { + bookingContainers: { containerType: true }, + cargoType: true, + }, + }); + if (!full) return null; + const day = eatDay(new Date(booking.scheduledDate)); + const direction = booking.tradeDirection === "EXPORT" ? "EXPORT" : "IMPORT"; + const wagonDims = await this.loadWagonDims(); + const need = this.needFor(full, wagonDims); + const fitting = await this.fittingTrainsForDay(full, day, direction); + const target = fitting.find((t) => t.freeWagons >= need.wagons); + if (!target) return null; + await this.dataSource + .getRepository(Booking) + .update(booking.id, { trainScheduleId: target.scheduleId }); + this.logger.warn( + `[BATCH] re-placed stranded PAID booking ${booking.reference ?? booking.id} ` + + `onto schedule ${target.scheduleId} — its hold expired before the payment landed`, + ); + return target.scheduleId; + } + /** Open partial-capacity offer summary for booking detail payloads (null when none). */ async getOpenOfferSummary(bookingId: string): Promise<{ offeredWagons: number; @@ -663,12 +801,16 @@ export class BookingBatchService implements OnModuleInit { { status: TrainScheduleStatusEnum.Scheduled }, ], }); + // A customer-picked train narrows the scan to that ONE schedule: export + // FCFS honors the pick or fails loudly (exportFullMessage names it). + const requestedId = booking.requestedTrainScheduleId ?? null; const candidates = corridor .filter( (s) => s.scheduledDepartureDate != null && eatDay(s.scheduledDepartureDate) === day && - this.isFillable(s), + this.isFillable(s) && + (!requestedId || s.id === requestedId), ) .sort( (a, b) => @@ -755,13 +897,18 @@ export class BookingBatchService implements OnModuleInit { /** Customer-facing "train is full" copy carrying the bookable leftover. */ private exportFullMessage(booking: Booking, report: ExportSpaceReport): string { + const picked = Boolean(booking.requestedTrainScheduleId); if (!report.trainsForDay || !report.corridorMatched) { - return 'No export train is accepting bookings for this day'; + return picked + ? 'The selected train is no longer accepting bookings — pick another train or day.' + : 'No export train is accepting bookings for this day'; } const best = report.bestAvailable; - const base = - 'Not enough train space — an export booking must ride a single train whole, ' + - 'and no open train on this day can carry it. '; + const base = picked + ? 'Not enough space left on the selected train — an export booking must ' + + 'ride one train whole. ' + : 'Not enough train space — an export booking must ride a single train whole, ' + + 'and no open train on this day can carry it. '; if (!best || best.wagons <= 0) { return base + 'No capacity is left on this day — pick another shipment day.'; } @@ -864,6 +1011,162 @@ export class BookingBatchService implements OnModuleInit { return out; } + /** + * The export train picker: every export train on the booking's corridor/day + * with its live space, measured per allowed wagon type so the customer sees + * what each train can still take for THEIR cargo. Includes full/not-yet-open + * trains (freeWagons 0 / isOpen false) so the UI can show them disabled — + * the request-time gate (exportSpaceReport) stays the enforcement point. + */ + async exportTrainOptionsForDay( + booking: Booking, + day: string, + overrides?: { + /** Cargo the customer is entering on a form (bare contract instance — + * nothing persisted yet): container types drive the per-type space. */ + containerTypeIds?: string[]; + /** Size labels ("20ft"/"40ft") when the form has no type ids. */ + containerSizes?: string[]; + /** Bulk counterparts of the container inputs. */ + cargoTypeId?: string; + cargoTypeCode?: string; + /** Needed wagons estimate from the form (drives the `fits` flag). */ + wagons?: number; + }, + ): Promise { + const sizeFts = (overrides?.containerSizes ?? []) + .map((s) => parseInt(s, 10)) + .filter((n) => Number.isFinite(n) && n > 0); + if (overrides?.containerTypeIds?.length || sizeFts.length) { + const types = await this.dataSource.getRepository(ContainerType).find({ + where: overrides?.containerTypeIds?.length + ? { id: In(overrides.containerTypeIds) } + : { sizeFt: In(sizeFts) }, + relations: { wagonTypes: true }, + }); + booking = { + ...booking, + freightType: "CONTAINER", + bookingContainers: types.map((ct) => ({ containerType: ct })), + } as Booking; + } else if (overrides?.cargoTypeId || overrides?.cargoTypeCode) { + const cargoType = await this.dataSource.getRepository(CargoType).findOne({ + where: overrides.cargoTypeId + ? { id: overrides.cargoTypeId } + : { code: overrides.cargoTypeCode }, + relations: { wagonTypes: true }, + }); + booking = { + ...booking, + freightType: "BULK", + cargoType: cargoType ?? undefined, + } as Booking; + } + if (overrides?.wagons && overrides.wagons > 0) { + booking = { ...booking, wagonsRequired: overrides.wagons } as Booking; + } + const corridor = await this.trainSchedulesRepository.findAll({ + where: [ + { status: TrainScheduleStatusEnum.Draft }, + { status: TrainScheduleStatusEnum.Scheduled }, + ], + }); + const candidates = corridor + .filter( + (s) => + s.scheduledDepartureDate != null && + eatDay(s.scheduledDepartureDate) === day && + s.direction === 'EXPORT', + ) + .sort( + (a, b) => + a.scheduledDepartureDate!.getTime() - + b.scheduledDepartureDate!.getTime(), + ); + + const wagonDims = await this.loadWagonDims(); + const allowed = this.allowedDimsWithTypes(booking, wagonDims); + const neededWagons = this.wagonsFor(booking, wagonDims); + const typeIds = allowed + .map((a) => a.wagonTypeId) + .filter((id): id is string => Boolean(id)); + const types = typeIds.length + ? await this.dataSource + .getRepository(WagonType) + .find({ where: { id: In(typeIds) } }) + : []; + const typeById = new Map(types.map((t) => [t.id, t])); + + const out: ExportTrainOption[] = []; + for (const candidate of candidates) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( + candidate.id, + ); + const locomotive = trainSetLocomotiveLimits(schedule?.trainSet); + if (!schedule || !locomotive) continue; + const limits = await this.capacityLimits(locomotive); + const budget = await this.remainingBudget(schedule, limits, wagonDims); + const leg = budget.legOf(booking.originYardId, booking.destinationYardId); + if (!leg) continue; // this train's route doesn't carry the booking's leg + const room = budget.remainingFor(leg); + // The abstract budget can't tell wagon types apart — cap each type's free + // count with the PHYSICAL wagons of that type the train (or yard pool) + // actually holds on this leg, and on a built train hide types the consist + // doesn't carry at all. Otherwise a 47×NW5 train advertised "PW2: 47 free". + const stock = await this.trainSchedulingService.wagonStockForSchedule( + schedule.id, + schedule.originStationId, + budget.stops, + ); + const ledger = new WagonStockLedger( + stock.remainingByTypeId, + Math.max(1, budget.stops.length - 1), + ); + const byWagonType = allowed + .filter( + ({ wagonTypeId }) => + stock.mode !== 'TRAIN' || + !wagonTypeId || + (stock.remainingByTypeId.get(wagonTypeId) ?? 0) > 0, + ) + .map(({ wagonTypeId, dims }) => { + const type = wagonTypeId ? typeById.get(wagonTypeId) : undefined; + const roomWagons = this.bookableWithin(room, dims).wagons; + const physical = wagonTypeId + ? ledger.availableFor([wagonTypeId], leg) + : roomWagons; + return { + wagonTypeId, + code: type?.code ?? null, + name: type?.name ?? null, + freeWagons: Math.min(roomWagons, physical), + }; + }); + const freeWagons = byWagonType.reduce( + (best, t) => Math.max(best, t.freeWagons), + 0, + ); + const builtTrain = schedule.trainSet?.train; + out.push({ + scheduleId: schedule.id, + trainNumber: + schedule.trainNumber ?? + builtTrain?.exportTrainNumber ?? + builtTrain?.trainNumber ?? + null, + trainName: builtTrain?.trainName ?? builtTrain?.code ?? null, + departure: schedule.scheduledDepartureDate!, + bookingClosesAt: schedule.windowClosesAt ?? null, + isOpen: this.isFillable(schedule), + freeWagons, + neededWagons, + fits: freeWagons >= neededWagons, + byWagonType, + }); + } + return out; + } + /** * Advisory free-wagon count for an IMPORT/DOMESTIC booking on a given day, * summed across every train on the booking's corridor that day. Unlike the @@ -1192,7 +1495,9 @@ export class BookingBatchService implements OnModuleInit { const [schedules, total] = await this.trainSchedulesRepository.findAndCount({ where, relations: { - trainSet: { locomotive: true, train: true }, + // locomotives (plural) too — the caps SUM the whole set's pull; the + // single legacy column alone under-reports a two-loco train by half. + trainSet: { locomotive: true, locomotives: { locomotive: true }, train: true }, originStation: true, destinationStation: true, // Yards supply the route's display name for `routeName` below; @@ -1255,7 +1560,21 @@ export class BookingBatchService implements OnModuleInit { }; }); - board.push(this.buildScheduleSummary(s, items)); + board.push( + this.buildScheduleSummary( + s, + items, + new Map( + bookings.map((b) => [ + b.id, + { + originYardId: b.originYardId ?? null, + destinationYardId: b.destinationYardId ?? null, + }, + ]), + ), + ), + ); } return { items: board, meta: buildPaginationMeta(total, page, pageSize) }; @@ -1286,6 +1605,40 @@ export class BookingBatchService implements OnModuleInit { (s.scheduleBookings ?? []).map((l) => l.bookingId), ); const bookings = await this.bookingsRepository.findAllBySchedule(s.id); + // Under day-level pooling a booking is only pinned to a schedule by + // reserve() — until then its train_schedule_id is NULL and the query above + // misses it. Merge in the corridor-day candidates so staff see the whole + // waiting pool (the 7 that lost the batch), not just the winners. These are + // display-only candidates: they are excluded from the capacity meters below. + const pinnedIds = new Set(bookings.map((b) => b.id)); + // Corridor stops drive both the day-pool candidate merge and the per-leg + // capacity meters below; a failed lookup degrades to whole-route math. + let stops: string[] = []; + try { + stops = await this.stopsForSchedule(s); + } catch (err) { + this.logger.warn( + `Stop lookup failed for schedule ${s.id}: ${(err as Error).message}`, + ); + } + if (s.scheduledDepartureDate && stops.length) { + try { + const candidates = + await this.bookingsRepository.findBatchPoolByCorridorDay( + stops, + eatDay(s.scheduledDepartureDate), + ); + for (const b of candidates) { + if (!pinnedIds.has(b.id)) bookings.push(b); + } + } catch (err) { + // The board must still render the pinned bookings. + this.logger.warn( + `Corridor-day candidate merge failed for schedule ${s.id}: ` + + `${(err as Error).message}`, + ); + } + } let allocationPreview: Awaited< ReturnType @@ -1398,6 +1751,18 @@ export class BookingBatchService implements OnModuleInit { const windowBookings = items.filter((i) => i.fullyExecutedAt); const pendingBookings = items.filter((i) => !i.fullyExecutedAt); + const stopLabels = + stops.length > 2 ? await this.yardLabels(stops) : new Map(); + const yardsByBookingId = new Map( + bookings.map((b) => [ + b.id, + { + originYardId: b.originYardId ?? null, + destinationYardId: b.destinationYardId ?? null, + }, + ]), + ); + return { scheduleId: s.id, scheduleReference: s.reference ?? null, @@ -1437,7 +1802,19 @@ export class BookingBatchService implements OnModuleInit { maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), } : null, - capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null), + // Capacity holds come from bookings actually pinned to this train — + // unpinned day-pool candidates are shown in the lists but hold nothing. + capacity: this.computeBoardCapacity( + items.filter((i) => pinnedIds.has(i.id)), + loco, + s.maxWagons ?? null, + { + stops, + labelByYardId: stopLabels, + yardsByBookingId, + trainLengthMeters: this.builtTrainLengthOf(s), + }, + ), counts: { allocated: items.filter((i) => i.state === "ALLOCATED").length, selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH") @@ -1477,6 +1854,7 @@ export class BookingBatchService implements OnModuleInit { */ private computeBoardCapacity( items: Array<{ + id: string; state: BatchBoardBookingState; wagons: number; weightTons: number; @@ -1484,6 +1862,16 @@ export class BookingBatchService implements OnModuleInit { }>, loco: LocomotiveLimits | null, maxWagons: number | null, + legCtx?: { + /** Ordered corridor stop yard ids; per-leg math needs 3+ stops. */ + stops: string[]; + labelByYardId: Map; + yardsByBookingId: Map< + string, + { originYardId: string | null; destinationYardId: string | null } + >; + trainLengthMeters: number | null; + }, ): BatchBoardSchedule["capacity"] { const allocated = items.filter((i) => i.state === "ALLOCATED"); // Every booking still targeting this train holds gross weight — including @@ -1501,23 +1889,128 @@ export class BookingBatchService implements OnModuleInit { : null; const round2 = (value: number) => Math.round(value * 100) / 100; + // Per-leg committed usage: a booking holds capacity only on the edges it + // rides, so every meter compares the HEAVIEST single edge against its cap + // — weight, wagons and length alike. Whole-route bookings (or yards + // missing from the stop list) load every edge — never under-reported. + const stops = legCtx?.stops ?? []; + let usedWeightTons = round2( + committed.reduce((sum, i) => sum + i.weightTons, 0), + ); + let allocatedWagons = allocated.reduce((sum, i) => sum + i.wagons, 0); + let allocatedLengthMeters = round2( + allocated.reduce((sum, i) => sum + i.lengthMeters, 0), + ); + let legUsage: BatchBoardSchedule["capacity"]["legUsage"] = null; + if (legCtx && stops.length > 2) { + const stopIndex = new Map(stops.map((yardId, i) => [yardId, i])); + const edgeCount = stops.length - 1; + const legOf = (bookingId: string): { from: number; to: number } => { + const yards = legCtx.yardsByBookingId.get(bookingId); + const from = yards?.originYardId + ? stopIndex.get(yards.originYardId) + : undefined; + const to = yards?.destinationYardId + ? stopIndex.get(yards.destinationYardId) + : undefined; + return from != null && to != null && from < to + ? { from, to } + : { from: 0, to: edgeCount }; + }; + const weightEdges = new Array(edgeCount).fill(0); + for (const item of committed) { + const leg = legOf(item.id); + for (let e = leg.from; e < leg.to; e += 1) weightEdges[e] += item.weightTons; + } + const wagonEdges = new Array(edgeCount).fill(0); + const lengthEdges = new Array(edgeCount).fill(0); + for (const item of allocated) { + const leg = legOf(item.id); + for (let e = leg.from; e < leg.to; e += 1) { + wagonEdges[e] += item.wagons; + lengthEdges[e] += item.lengthMeters; + } + } + const label = (yardId: string) => + legCtx.labelByYardId.get(yardId) ?? yardId; + legUsage = weightEdges.map((weight, i) => ({ + from: label(stops[i]), + to: label(stops[i + 1]), + usedWeightTons: round2(weight), + })); + usedWeightTons = round2(Math.max(0, ...weightEdges)); + allocatedWagons = Math.max(0, ...wagonEdges); + allocatedLengthMeters = round2(Math.max(0, ...lengthEdges)); + } + return { - allocatedWagons: allocated.reduce((sum, i) => sum + i.wagons, 0), - allocatedLengthMeters: round2( - allocated.reduce((sum, i) => sum + i.lengthMeters, 0), - ), + allocatedWagons, + allocatedLengthMeters, maxLengthMeters: caps ? caps.maxLengthMeters : null, - usedWeightTons: round2(committed.reduce((sum, i) => sum + i.weightTons, 0)), + usedWeightTons, maxWeightTons: caps ? caps.maxWeightTons : null, maxWagons: maxWagons ?? null, + trainLengthMeters: legCtx?.trainLengthMeters ?? null, + legUsage, }; } + /** Built consist's physical length (what Train Builder shows), null without a built train. */ + private builtTrainLengthOf(s: TrainSchedule): number | null { + const raw = s.trainSet?.totalLengthMeters; + const value = raw != null ? Number(raw) : NaN; + return Number.isFinite(value) && value > 0 ? value : null; + } + + /** Yard display labels for corridor stops (falls back to the yard id). */ + private async yardLabels(yardIds: string[]): Promise> { + if (!yardIds.length) return new Map(); + const yards = await this.dataSource + .getRepository(Yard) + .find({ where: { id: In(yardIds) } }); + return new Map(yards.map((y) => [y.id, y.label ?? y.code])); + } + + /** + * Corridor stops + labels from the already-loaded route graph (milestones + * with yards) — the list flow must not fire a query per schedule row. + */ + private stopsFromGraph(s: TrainSchedule): { + stops: string[]; + labelByYardId: Map; + } { + const milestones = [...(s.route?.milestones ?? [])].sort( + (a, b) => a.sequenceNo - b.sequenceNo, + ); + const stops: string[] = []; + const labelByYardId = new Map(); + const push = (yardId?: string | null, label?: string | null) => { + if (!yardId || labelByYardId.has(yardId)) return; + stops.push(yardId); + labelByYardId.set(yardId, label ?? yardId); + }; + if (milestones.length >= 2) { + for (const m of milestones) push(m.yardId, m.yard?.label ?? m.yard?.code); + } else { + push(s.originStationId, s.originStation?.label ?? s.originStation?.code); + push( + s.destinationStationId, + s.destinationStation?.label ?? s.destinationStation?.code, + ); + } + return { stops, labelByYardId }; + } + private buildScheduleSummary( s: TrainSchedule, items: BatchBoardBooking[], + yardsByBookingId: Map< + string, + { originYardId: string | null; destinationYardId: string | null } + >, ): BatchBoardSchedule { const loco = trainSetLocomotiveLimits(s.trainSet); + const { stops, labelByYardId } = this.stopsFromGraph(s); return { scheduleId: s.id, @@ -1559,7 +2052,12 @@ export class BookingBatchService implements OnModuleInit { maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), } : null, - capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null), + capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null, { + stops, + labelByYardId, + yardsByBookingId, + trainLengthMeters: this.builtTrainLengthOf(s), + }), counts: { allocated: items.filter((i) => i.state === "ALLOCATED").length, selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH") @@ -2050,14 +2548,16 @@ export class BookingBatchService implements OnModuleInit { * partial (split-on-payment). Consolidated pairs never split (both-or-neither * shared wagon) and government bookings never split (they preempt). * - * IMPORT is always eligible. EXPORT is eligible only when export split is - * enabled: export historically rides one train whole, so splitting it changes - * the FCFS money path — each split part still rides ONE train whole, and the - * leftover becomes its own booking on the next train. + * IMPORT and DOMESTIC (intercity ride-along) are always eligible. EXPORT is + * eligible only when export split is enabled: export historically rides one + * train whole, so splitting it changes the FCFS money path — each split part + * still rides ONE train whole, and the leftover becomes its own booking on + * the next train. */ private isSplitEligible(booking: Booking, isPair: boolean): boolean { const directionOk = booking.tradeDirection === "IMPORT" || + booking.tradeDirection === "DOMESTIC" || (booking.tradeDirection === "EXPORT" && this.exportSplitEnabled); return ( !isPair && @@ -2176,7 +2676,10 @@ export class BookingBatchService implements OnModuleInit { }; if (!this.fits(offeredNeed, budget)) return null; - const deadline = new Date(Date.now() + (await this.paymentWindowMs())); + const deadline = new Date( + Date.now() + + (await this.paymentWindowMsFor(await this.scheduleById(scheduleId))), + ); await this.splitService.createOffer(booking, scheduleId, sized, deadline); // Reserve like a normal batch selection, but the partial invoice + partial // pay-now notification were already produced by createOffer. @@ -2185,6 +2688,7 @@ export class BookingBatchService implements OnModuleInit { status: "SELECTED_FOR_BATCH", selectedForBatchAt: new Date(), paymentDeadline: deadline, + paymentReminderSentAt: null, } as never); booking.trainScheduleId = scheduleId; return offeredNeed; @@ -2213,6 +2717,8 @@ export class BookingBatchService implements OnModuleInit { const isPaid = (b: Booking) => b.paymentStatus === "PAID" || b.status === "PAID"; + // Deadline is the line — no fixed slack. A payment that beat the deadline + // but whose webhook is late is caught by expire()'s gateway reconcile. const isExpired = (b: Booking) => b.paymentDeadline ? b.paymentDeadline.getTime() <= now @@ -2513,6 +3019,39 @@ export class BookingBatchService implements OnModuleInit { this.notifyBoardChanged(newScheduleId, "booking_moved"); } + /** + * One reminder per hold, shortly before its pay deadline (the window tick + * calls this every pass; `payment_reminder_sent_at` dedups). Skips paid + * bookings — a landed payment the settle hasn't processed yet needs no nag. + */ + async sendPaymentReminders(): Promise { + const now = new Date(); + const due = await this.dataSource + .getRepository(Booking) + .createQueryBuilder("b") + .leftJoinAndSelect("b.company", "company") + .where(`b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`) + .andWhere(`b.payment_status != 'PAID'`) + .andWhere("b.payment_reminder_sent_at IS NULL") + .andWhere("b.payment_deadline > :now", { now }) + .andWhere("b.payment_deadline <= :soon", { + soon: new Date(now.getTime() + PAYMENT_REMINDER_LEAD_MS), + }) + .getMany(); + for (const booking of due) { + // Stamp BEFORE sending so a slow notifier can't double-send next tick. + await this.bookingsRepository.update(booking.id, { + paymentReminderSentAt: new Date(), + } as never); + if (booking.paymentDeadline) { + await this.notifier.payDeadlineApproaching( + booking, + booking.paymentDeadline, + ); + } + } + } + /** Staff "expire" override → free a reservation now (booking becomes EXPIRED). */ async expireReservation(bookingId: string): Promise { const booking = await this.dataSource @@ -2533,6 +3072,52 @@ export class BookingBatchService implements OnModuleInit { } } + /** + * Customer cancel of an unpaid hold: the same immediate release as + * expireReservation, but the booking ends CANCELLED (the customer chose to + * walk away — "payment window missed" copy would be wrong). Consolidated + * pairs are rejected by the caller: the shared wagon is both-or-neither. + */ + async cancelReservation(bookingId: string): Promise { + const booking = await this.dataSource + .getRepository(Booking) + .findOne({ where: { id: bookingId } }); + if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); + const freedScheduleId = booking.trainScheduleId; + await this.bookingsRepository.update(booking.id, { + trainScheduleId: null, + requestedTrainScheduleId: null, + status: "CANCELLED", + schedulingStatus: "ELIGIBLE", + paymentDeadline: null, + selectedForBatchAt: null, + paymentReminderSentAt: null, + } as never); + // An unpaid partial offer dies with the hold — same as expire(). + if (this.splitService) { + await this.splitService.expireOpenOffer(booking.id); + } + await this.billing.expirePayable( + Freight.InvoiceSource.Booking, + booking.id, + "PREPAID", + ); + if (freedScheduleId) { + // Same release choreography as expireReservation: reopen a FULL window, + // top up from the waiting list, push one board update with final state. + await this.refreshWindowStatus(freedScheduleId); + const topUpReserved = await this.topUpFill(freedScheduleId); + if (topUpReserved > 0) { + await this.extendPaymentPhaseForTopUp(freedScheduleId); + } + this.notifyBoardChanged(freedScheduleId, "reservation_expired"); + } + this.logger.log( + `[BATCH] CANCELLED hold ${booking.reference} — customer released the ` + + `reservation before paying; wagons freed`, + ); + } + // ---- intercity ride-along API --------------------------------------------- /** @@ -2578,11 +3163,59 @@ export class BookingBatchService implements OnModuleInit { this.notifyBoardChanged(scheduleId, 'intercity_accepted'); return; } + // Manual placement of an ALREADY-PAID intercity booking: payment landed + // earlier (and unpinned it back to the pool) — staff are now choosing its + // train, so link directly. No new pay window; wagon assignment stays with + // staff in the workspace. + if (booking.paymentStatus === 'PAID' || booking.status === 'PAID') { + await this.dataSource + .getRepository(Booking) + .update(booking.id, { trainScheduleId: scheduleId }); + booking.trainScheduleId = scheduleId; + await this.allocate(scheduleId, booking, 'paid'); + this.notifyBoardChanged(scheduleId, 'intercity_accepted'); + return; + } await this.reserve(booking, scheduleId); this.armSettle(scheduleId); this.notifyBoardChanged(scheduleId, 'intercity_accepted'); } + /** + * Intercity booking that does not fit its leg whole: offer the largest part + * that does (split-on-payment, customer notified with a pay window), sized + * against the leg's remaining room AND the train's physical wagon stock. + * Returns true when an offer was opened. The caller's budget is mutated so + * later bookings in the same accept pass see the offer's consumption. + */ + async offerIntercityPartial( + booking: Booking, + scheduleId: string, + budget: CorridorBudget, + ): Promise { + const wagonDims = await this.loadWagonDims(); + const need = this.needFor(booking, wagonDims); + const allowed = await this.loadAllowedWagonTypeIds(); + const wagonTypeIds = this.allowedWagonTypeIdsFor(booking, allowed); + const schedule = + await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) return false; + const stock = await this.stockLedgerFor(schedule, budget); + const cand = { id: scheduleId, budget, armed: false, stock }; + const offered = await this.maybeOfferPartial( + booking, + false, + [cand], + need, + wagonTypeIds, + ); + if (offered && cand.armed) { + this.armSettle(scheduleId); + this.notifyBoardChanged(scheduleId, 'intercity_partial_offered'); + } + return offered; + } + // ---- mutations ------------------------------------------------------------ /** @@ -2617,14 +3250,14 @@ export class BookingBatchService implements OnModuleInit { return; } const now = new Date(); - let deadline = new Date(now.getTime() + (await this.paymentWindowMs())); + const targetSchedule = await this.scheduleById(scheduleId); + let deadline = new Date( + now.getTime() + (await this.paymentWindowMsFor(targetSchedule)), + ); // EXPORT parity: pay windows on an export train never outlive its booking // window — export bookings expire at close, so anything reserved onto the // same train (FCFS export or an intercity ride-along) must too. Import // keeps the plain payment window; its cycles re-fill after settle. - const targetSchedule = await this.dataSource - .getRepository(TrainSchedule) - .findOne({ where: { id: scheduleId } }); if (targetSchedule?.direction === "EXPORT") { const cutoff = targetSchedule.windowClosesAt ?? targetSchedule.scheduledDepartureDate; @@ -2642,6 +3275,7 @@ export class BookingBatchService implements OnModuleInit { status: "SELECTED_FOR_BATCH", selectedForBatchAt: now, paymentDeadline: deadline, + paymentReminderSentAt: null, } as never); booking.trainScheduleId = scheduleId; // The invoice was generated DRAFT at booking creation / operation-accept, @@ -2721,6 +3355,22 @@ export class BookingBatchService implements OnModuleInit { booking: Booking, reason: "paid" | "gov", ): Promise { + // Stamp the computed wagon need on the link. Several callers pass a booking + // loaded without cargo relations (ensurePaidBookingAllocated), and a NULL + // wagonsRequired makes every capacity/occupancy reader miscount this + // booking as 1 wagon — reload with the relations wagonsFor sizes from. + const wagonDims = await this.loadWagonDims(); + const full = + booking.bookingContainers || booking.cargoType + ? booking + : await this.dataSource.getRepository(Booking).findOne({ + where: { id: booking.id }, + relations: { + bookingContainers: { containerType: true }, + cargoType: true, + }, + }); + const wagonsRequired = this.wagonsFor(full ?? booking, wagonDims); await this.dataSource.transaction(async (manager) => { const exists = await this.trainScheduleBookingsRepository.existsForBooking( @@ -2737,6 +3387,7 @@ export class BookingBatchService implements OnModuleInit { status: reason === "paid" ? "PAID" : booking.status, schedulingStatus: "SCHEDULED", scheduledAt: new Date(), + wagonsRequired, paymentDeadline: null, selectedForBatchAt: null, } as never); @@ -2745,7 +3396,11 @@ export class BookingBatchService implements OnModuleInit { `[BATCH] ALLOCATED ${booking.reference} (${reason}) to train on schedule ${scheduleId}`, ); this.notifier.secured(booking, reason, scheduleId); - void this.triggerWagonAllocation(scheduleId); + // Intercity rides are placed on wagons BY STAFF (workspace wizard) — auto + // wagon assignment is for the import/export batch flow only. + if (booking.tradeDirection !== 'DOMESTIC') { + void this.triggerWagonAllocation(scheduleId); + } void this.markWagonAllocatedMilestone(booking.id); // Customer tracking: freight payment settled (commercial pay-window path). // Government allocations don't pay upfront — theirs stay pending. @@ -2825,14 +3480,42 @@ export class BookingBatchService implements OnModuleInit { } return; } + // Reconcile-before-expire (only when a pay window was actually open): + // no webhook arrived, so ask the gateway DIRECTLY whether the money + // landed. A late capture found there is registered as SUCCEEDED and + // emits payment.succeeded — that event marks the booking PAID and + // allocates it, so we just leave the hold alone here. `unverifiable` + // (provider query errored / payment still in flight) means we could not + // confirm "not paid" — never expire on unknown; the next settle tick + // asks again. + if (reason === "payment" && (fresh?.paymentDeadline ?? booking.paymentDeadline)) { + const reconcile = await this.billing.reconcilePayable(booking.id); + if (reconcile.paid) { + this.logger.log( + `[BATCH] expire skipped for ${booking.reference} — gateway ` + + `reconcile found a settled payment; payment.succeeded will allocate it`, + ); + return; + } + if (reconcile.unverifiable) { + this.logger.warn( + `[BATCH] expire deferred for ${booking.reference} — settlement ` + + `unverifiable at the gateway; retrying next settle tick`, + ); + return; + } + } } const freedScheduleId = booking.trainScheduleId; await this.bookingsRepository.update(booking.id, { trainScheduleId: null, + // The customer's train pick died with the hold — a rebook re-picks. + requestedTrainScheduleId: null, status: "EXPIRED", schedulingStatus: "ELIGIBLE", paymentDeadline: null, selectedForBatchAt: null, + paymentReminderSentAt: null, } as never); booking.trainScheduleId = null; // The wagons this reservation held are back — a schedule parked at FULL @@ -3375,7 +4058,11 @@ export class BookingBatchService implements OnModuleInit { const byWeight = cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0; - return Math.max(DEFAULT_WAGONS_PER_BOOKING, stored, byLength, byWeight); + // Break-bulk (PER_ITEM): indivisible items can need more wagons than raw + // tonnage suggests (floor items-per-wagon loses the fractional capacity). + const byItems = bulkItemWagonsRequired(booking, capacityTons); + + return Math.max(DEFAULT_WAGONS_PER_BOOKING, stored, byLength, byWeight, byItems); } /** @@ -3571,6 +4258,18 @@ export class BookingBatchService implements OnModuleInit { * representative dims when no allowed type is configured. */ private dimsForAllowed(booking: Booking, wagonDims: WagonDims): PerWagonDims[] { + return this.allowedDimsWithTypes(booking, wagonDims).map((p) => p.dims); + } + + /** + * Same allowed set as {@link dimsForAllowed} but keeping each wagon-type id, + * so callers (the export train picker) can label per-type availability. + * `wagonTypeId` is null only on the unconfigured fallback entry. + */ + private allowedDimsWithTypes( + booking: Booking, + wagonDims: WagonDims, + ): Array<{ wagonTypeId: string | null; dims: PerWagonDims }> { const fallback = booking.freightType === "BULK" ? wagonDims.bulk : wagonDims.container; const ids = @@ -3580,19 +4279,23 @@ export class BookingBatchService implements OnModuleInit { .flatMap((line) => line.containerType?.wagonTypes ?? []) .map((wt) => wt.id); const seen = new Set(); - const dims: PerWagonDims[] = []; + const out: Array<{ wagonTypeId: string | null; dims: PerWagonDims }> = []; for (const id of ids) { if (!id || seen.has(id)) continue; seen.add(id); const d = wagonDims.byWagonTypeId.get(id); if (d) { - dims.push({ - ...d, - capacityTons: d.capacityTons > 0 ? d.capacityTons : fallback.capacityTons, + out.push({ + wagonTypeId: id, + dims: { + ...d, + capacityTons: + d.capacityTons > 0 ? d.capacityTons : fallback.capacityTons, + }, }); } } - return dims.length ? dims : [fallback]; + return out.length ? out : [{ wagonTypeId: null, dims: fallback }]; } /** @@ -3777,8 +4480,20 @@ export class BookingBatchService implements OnModuleInit { const allocated = (schedule.scheduleBookings ?? []) .map((sb) => sb.booking) .filter((b): b is Booking => Boolean(b)); - const reserved = await this.bookingsRepository.findReservedForSchedule( - schedule.id, + // Lazy-expiry guard: a hold whose deadline lapsed no longer blocks + // capacity, even before the 10s sweep flips it to EXPIRED — availability + // shown to the next customer is honest between ticks. A late capture the + // gateway reconcile later confirms lands as PAID and, if the wagons went + // meanwhile, degrades to WAITING_FOR_WAGON for manual placement. + const deadlineCutoff = Date.now(); + const reserved = ( + await this.bookingsRepository.findReservedForSchedule(schedule.id) + ).filter( + (b) => + b.paymentStatus === "PAID" || + b.status === "PAID" || + b.paymentDeadline == null || + b.paymentDeadline.getTime() > deadlineCutoff, ); for (const b of [...allocated, ...reserved]) { budget.subtract( @@ -3879,14 +4594,12 @@ export class BookingBatchService implements OnModuleInit { } /** - * FULL is DIRECTIONAL: the schedule's trade direction is full when the - * border-crossing edge (which every export/import must ride) can't take one + * FULL is CORRIDOR-WIDE: the train is full only when NO leg can take one * more minimal wagon on any axis — slots for built trains (the consist is * the capacity, weight/length settled at build), all three axes otherwise * (PW2: weight binds at 37 wagons = 3522.4T of 3500+90T, slots bind at 44). - * Home-side legs may still run empty; intercity ride-alongs keep filling - * them via the per-leg budget and never consult this flag. Domestic routes - * (no border) are full only when every edge is closed. + * A full DCT→Dire leg alone does NOT close the window while Dire→GMP still + * has room — sub-corridor bookings keep selling the open legs. */ async isScheduleFull(scheduleId: string): Promise { const schedule = @@ -3937,13 +4650,9 @@ export class BookingBatchService implements OnModuleInit { /** See {@link isScheduleFull} — same check for callers that already hold the full graph. */ private async isTrainFull(schedule: TrainSchedule): Promise { - // "Full" means full FOR THE TRAIN'S TRADE DIRECTION. Every export and - // every import must cross the ET↔DJ border edge, so once that edge can't - // take one more minimal wagon the booking window may close — even while - // home-side legs still run empty. Intercity ride-alongs never consult this - // flag; they keep booking the free legs through the per-leg budget. - // A single-country (domestic) corridor has no mandatory edge, so it is - // full only when EVERY edge is closed on some axis. + // Full only when EVERY edge is closed on some axis: a full border edge + // still leaves the home-side legs bookable by sub-corridor cargo, so the + // window must stay open until not even the smallest wagon fits anywhere. const wagonDims = await this.loadWagonDims(); const physicalWagons = await this.builtTrainWagonCount(schedule); let limits: TrainLimits; @@ -3966,42 +4675,9 @@ export class BookingBatchService implements OnModuleInit { } const budget = await this.remainingBudget(schedule, limits, wagonDims); const minNeed = this.minPerWagonNeed(wagonDims); - const border = await this.borderLeg(budget.stops); - if (border) { - return !budget.fits( - { - wagons: 1, - weightTons: minNeed.grossWeightTons, - lengthMeters: minNeed.lengthMeters, - }, - border, - ); - } return budget.isExhausted(minNeed); } - /** - * The corridor's single border-crossing edge (last home-country stop → first - * far-country stop), or null when every stop is in one country. This is the - * edge every EXPORT and IMPORT booking must ride, whichever sub-corridor it - * books — which makes it the train's directional fullness gauge. - */ - private async borderLeg(stops: string[]): Promise { - if (stops.length < 2) return null; - const yards = await this.dataSource - .getRepository(Yard) - .find({ where: { id: In(stops) } }); - const countryOf = new Map(yards.map((y) => [y.id, y.country])); - const first = countryOf.get(stops[0]); - if (!first) return null; - const crossIdx = stops.findIndex((id) => { - const country = countryOf.get(id); - return country != null && country !== first; - }); - if (crossIdx <= 0) return null; - return { fromEdge: crossIdx - 1, toEdge: crossIdx }; - } - /** * Smallest gross weight / shortest length one more wagon could add: the * lightest wagon type at its rated payload. Feeds CorridorBudget.isExhausted, @@ -4047,6 +4723,31 @@ export class BookingBatchService implements OnModuleInit { // nothing can board. if (await this.isTrainFull(schedule)) return; + // FULL concluded the cycle (phase DONE) and DONE rows are skipped by the + // window tick forever — so when wagons free up before departure, restart + // the cycle or nobody (customer or batch) can ever book the freed space. + // ponytail: reopens now and closes at departure; the office-hours clamp + // reapplies on the next conclude cycle. + const departure = schedule.scheduledDepartureDate; + if ( + schedule.windowPhase === "DONE" && + ["DRAFT", "SCHEDULED"].includes(schedule.status) && + departure && + departure.getTime() > Date.now() + ) { + await this.dataSource.getRepository(TrainSchedule).update(scheduleId, { + windowPhase: "PRE_WINDOW", + windowOpensAt: new Date(), + windowClosesAt: departure, + }); + await this.setWindow(scheduleId, "OPEN"); + this.logger.log( + `[BATCH] ${scheduleId} FULL cleared after wagons freed — window revived ` + + `(PRE_WINDOW, reopens immediately, closes at departure)`, + ); + return; + } + const customerWindowOpen = schedule.windowPhase == null || schedule.windowPhase === "OPEN"; await this.setWindow(scheduleId, customerWindowOpen ? "OPEN" : "CLOSED"); @@ -4058,10 +4759,33 @@ export class BookingBatchService implements OnModuleInit { // ---- timer plumbing ------------------------------------------------------- - /** Configured customer pay window in ms (global rules, with defaults). */ - private async paymentWindowMs(): Promise { + /** + * Effective customer pay window in ms for a target schedule: the staff + * per-schedule override wins, else the global value for the schedule's + * direction (export and import pay windows are tuned independently). + * No schedule (unknown target) falls back to the import global. + */ + private async paymentWindowMsFor( + schedule?: Pick< + TrainSchedule, + "direction" | "rulePaymentWindowMinutes" + > | null, + ): Promise { + if (schedule?.rulePaymentWindowMinutes != null) { + return schedule.rulePaymentWindowMinutes * 60_000; + } const cfg = await this.trainSchedulingService.getWindowConfig(); - return cfg.paymentWindowMinutes * 60_000; + const minutes = + schedule?.direction === "EXPORT" + ? cfg.exportPaymentWindowMinutes + : cfg.paymentWindowMinutes; + return minutes * 60_000; + } + + private scheduleById(id: string): Promise { + return this.dataSource + .getRepository(TrainSchedule) + .findOne({ where: { id } }); } private timeoutName(scheduleId: string): string { @@ -4073,8 +4797,9 @@ export class BookingBatchService implements OnModuleInit { * engine's minute tick calling settleDueReservations off `paymentDeadline`. */ private armSettle(scheduleId: string): void { - void this.paymentWindowMs() - .then((delayMs) => { + void this.scheduleById(scheduleId) + .then((schedule) => this.paymentWindowMsFor(schedule)) + .then((delayMs: number) => { this.removeTimeout(scheduleId); const handle = setTimeout(() => { void this.settleBatch(scheduleId).catch((err) => @@ -4107,7 +4832,7 @@ export class BookingBatchService implements OnModuleInit { .getRepository(TrainSchedule) .findOne({ where: { id: scheduleId } }); if (!schedule || schedule.windowPhase !== "PAYMENT") return; - const windowMs = await this.paymentWindowMs(); + const windowMs = await this.paymentWindowMsFor(schedule); let target = new Date(Date.now() + windowMs); if ( schedule.scheduledDepartureDate && diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts index b4520a43c..87e756ba6 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts @@ -83,6 +83,11 @@ export class BookingJourneyService { loadedByUserId: userId ?? null, } as never); await this.setAllocationStatuses(manager, scheduleId, bookingId, 'LOADED'); + // Keep the schedule↔booking link's tracking flag in sync — the dispatch + // readiness warnings and workspace badges read loading_status, not loadedAt. + await manager + .getRepository(TrainScheduleBooking) + .update({ trainScheduleId: scheduleId, bookingId }, { loadingStatus: 'LOADED' }); // The facility handed the cargo over — raise its GRN. No-ops for yards // without a facility (import/export terminals), which keep their own flow. await this.facilityHandling.recordHandling(manager, { @@ -153,6 +158,14 @@ export class BookingJourneyService { this.events.emit('booking.completed', { bookingId }); } + // The cargo is physically off the train at its own yard — mid-corridor or + // final. WarehouseInventoryService picks this up to create the warehouse + // record (import/intercity only; export already has one from receive). + this.events.emit('booking.unloadedAtYard', { + bookingId, + tradeDirection: booking.tradeDirection, + }); + // Customer tracking: THIS booking arrived (train may still be rolling). void this.completeMilestones(booking, [ ...(booking.tradeDirection === 'IMPORT' @@ -236,7 +249,10 @@ export class BookingJourneyService { return { scheduleId, scheduleStatus: schedule.status, - trainAtYardId: latest?.yardId ?? (schedule.status === 'DISPATCHED' ? null : schedule.originStationId), + // No checkpoint yet ⇒ the train is still at its origin, even just after + // dispatch — assertTrainAtYard allows origin loading in that state, so + // the UI position must agree or origin Load buttons grey out wrongly. + trainAtYardId: latest?.yardId ?? schedule.originStationId, yards: [...byYard.values()], }; } @@ -311,11 +327,19 @@ export class BookingJourneyService { RETURNING b.id, b.trade_direction`, [schedule.id, schedule.destinationStationId, now], ); - // Intercity rows just completed — let a ONE_TIME contract close on delivery. for (const row of rows) { + // Intercity rows just completed — let a ONE_TIME contract close on delivery. if (row.trade_direction === 'DOMESTIC') { this.events.emit('booking.completed', { bookingId: row.id }); } + // Same event the per-booking unloadBooking() path emits — WarehouseInventoryService + // listens for this to auto-create the warehouse_inventory row (import/intercity only, + // it filters EXPORT itself). The bulk SQL update above skipped this entirely, so + // bookings caught by this fallback never left "awaiting unload". + this.events.emit('booking.unloadedAtYard', { + bookingId: row.id, + tradeDirection: row.trade_direction, + }); } return rows.map((r) => r.id); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index e17445b4d..b806de5ca 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -137,6 +137,25 @@ export class BookingNotifierService { }); } + /** One warning shortly before the pay window closes (sent once per hold). */ + async payDeadlineApproaching(b: Booking, deadline: Date): Promise { + const minutesLeft = Math.max( + 1, + Math.round((deadline.getTime() - Date.now()) / 60_000), + ); + const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' }); + const msg = + `Payment reminder: about ${minutesLeft} minute${minutesLeft === 1 ? '' : 's'} left ` + + `to pay for booking ${b.reference ?? b.id}. Deadline: ${eat} EAT — ` + + `unpaid reservations are released and the wagons go back on sale.`; + await this.notifyContact(b, msg, 'PAY REMINDER'); + // HIGH: minutes from losing the reserved wagons — must reach SMS/email. + this.inApp(b, 'Payment deadline approaching', msg, { + type: NotificationType.INVOICE_ISSUED, + priority: NotificationPriority.HIGH, + }); + } + /** * Partial-capacity offer: only `offeredWagons` of the booking's `totalWagons` fit * this train. Paying accepts the split; letting the deadline pass keeps the diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.spec.ts index fc47d8a70..50cba543f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.spec.ts @@ -72,7 +72,12 @@ describe('BookingSplitService — applySplit split marking', () => { dataSource as never, {} as never, {} as never, - { expirePayable: jest.fn() } as never, + { + expirePayable: jest.fn(), + reconcilePayable: jest + .fn() + .mockResolvedValue({ paid: false, unverifiable: false }), + } as never, { payNowPartial: jest.fn() } as never, ); return { service, bookingRepo, contractRepo }; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts index 617d83561..74e9a0bff 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts @@ -18,7 +18,10 @@ export interface BookingWindowConfig { windowDurationHours: number; /** Max staff document-review time after the window closes. */ docReviewMinutes: number; + /** Pay window for IMPORT/DOMESTIC bookings (also part of the reopen gap). */ paymentWindowMinutes: number; + /** Pay window for EXPORT bookings — independent of the import value. */ + exportPaymentWindowMinutes: number; /** * Minutes before departure the IMPORT/DOMESTIC booking window shuts. When set * (> 0), the effective booking cutoff is `departure − this`, capping the first diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts index 79b279393..9dff62261 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts @@ -36,6 +36,7 @@ describe('BookingWindowService — window state machine', () => { windowDurationHours: 1, docReviewMinutes: 30, paymentWindowMinutes: 60, + exportPaymentWindowMinutes: 60, }; const baseSchedule = (over: Partial): TrainSchedule => diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index 5ae751164..6cdef8c05 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -141,6 +141,13 @@ export class BookingWindowService implements OnModuleInit { await this.settleOverdueReservations(); + // One pre-deadline pay reminder per hold (deduped via reminder stamp). + await this.bookingBatchService.sendPaymentReminders().catch((err) => + this.logger.warn( + `Payment reminder sweep failed: ${(err as Error).message}`, + ), + ); + // Legacy fill (DOMESTIC / pre-migration schedules) every 5 minutes // (30 ticks at the 10-second cadence). this.tickCount += 1; @@ -595,6 +602,8 @@ export class BookingWindowService implements OnModuleInit { .createQueryBuilder('b') .select('DISTINCT b.train_schedule_id', 'scheduleId') .where(`b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`) + // Deadline is the line — expire() itself reconciles against the gateway + // before actually expiring, so a late in-window payment is still caught. .andWhere('b.payment_deadline <= now()') .andWhere('b.train_schedule_id IS NOT NULL') .getRawMany<{ scheduleId: string }>(); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/adjust-schedule-consist.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/adjust-schedule-consist.dto.ts index e031bf9f0..215b6c806 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/adjust-schedule-consist.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/adjust-schedule-consist.dto.ts @@ -1,5 +1,16 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsArray, IsOptional, IsUUID } from 'class-validator'; +import { Type } from 'class-transformer'; +import { IsArray, IsOptional, IsUUID, ValidateNested } from 'class-validator'; + +export class ConsistWagonSwitchDto { + @ApiPropertyOptional({ format: 'uuid', description: 'Coupled wagon being taken out of the consist.' }) + @IsUUID() + fromWagonId!: string; + + @ApiPropertyOptional({ format: 'uuid', description: 'AVAILABLE same-type wagon from the current yard that takes its place (and its slot, cargo included).' }) + @IsUUID() + toWagonId!: string; +} export class AdjustScheduleConsistDto { @ApiPropertyOptional({ @@ -23,4 +34,15 @@ export class AdjustScheduleConsistDto { @IsArray() @IsUUID('all', { each: true }) removeWagonIds?: string[]; + + @ApiPropertyOptional({ + type: [ConsistWagonSwitchDto], + description: + "Wagon swaps: the replacement takes over the outgoing wagon's position AND its slot, so cargo allocations ride the new wagon. This is how a LOADED wagon leaves the train — removal is blocked for it, switching is not. Replacement must be the same wagon type, AVAILABLE, standing in the train's current yard.", + }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ConsistWagonSwitchDto) + switches?: ConsistWagonSwitchDto[]; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/switch-government-booking.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/switch-government-booking.dto.ts new file mode 100644 index 000000000..c72a1879b --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/switch-government-booking.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ArrayNotEmpty, IsArray, IsUUID } from 'class-validator'; + +export class SwitchGovernmentBookingDto { + @ApiProperty({ format: 'uuid', description: 'Government booking to allocate onto the train' }) + @IsUUID() + governmentBookingId!: string; + + @ApiProperty({ + format: 'uuid', + isArray: true, + description: 'Assigned commercial bookings to switch out in its place', + }) + @IsArray() + @ArrayNotEmpty() + @IsUUID('4', { each: true }) + removeBookingIds!: string[]; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts index b10736f0f..74aba1383 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts @@ -61,13 +61,20 @@ export class UpdateTrainSchedulingGlobalRulesDto { @Min(0) docReviewMinutes?: number; - @ApiPropertyOptional({ example: 60 }) + @ApiPropertyOptional({ example: 60, description: 'IMPORT/DOMESTIC customer pay window, minutes' }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) paymentWindowMinutes?: number; + @ApiPropertyOptional({ example: 60, description: 'EXPORT customer pay window, minutes' }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + exportPaymentWindowMinutes?: number; + // Booking-close offsets: minutes before departure the window shuts. The UI // enters days/hours/minutes and converts to minutes. 0 or null clears the // offset (close at departure). Nullable so it can be explicitly cleared. 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 caa3ce24f..94882833b 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 @@ -77,9 +77,14 @@ export class TrainSchedulingGlobalRules extends BaseEntity { @Column({ name: 'doc_review_minutes', type: 'int', default: 30 }) docReviewMinutes!: number; + /** Pay window for IMPORT/DOMESTIC bookings (also feeds the window reopen delay). */ @Column({ name: 'payment_window_minutes', type: 'int', default: 60 }) paymentWindowMinutes!: number; + /** Pay window for EXPORT bookings — tunable independently of import. */ + @Column({ name: 'export_payment_window_minutes', type: 'int', default: 60 }) + exportPaymentWindowMinutes!: number; + /** * Minutes before departure the IMPORT/DOMESTIC booking window shuts. When set, * the window's close (first cycle and every reopen) is capped at diff --git a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts index cacffaba4..631078ef6 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts @@ -1,4 +1,4 @@ -import { bookingCargoTons } from './train-capacity.util'; +import { bookingCargoTons, bulkItemWagonsRequired } from './train-capacity.util'; import type { Booking } from '../bookings/entities/booking.entity'; import type { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { @@ -51,8 +51,12 @@ export function sortBookingsForScheduling(bookings: Booking[]): Booking[] { export function wagonsRequiredForBooking(booking: Booking, bulkWagonCapacity?: number): number { if (booking.freightType === 'BULK') { - const weight = Number(booking.cargoTotalWeightVgm ?? 0); const capacity = bulkWagonCapacity && bulkWagonCapacity > 0 ? bulkWagonCapacity : 1; + // Break-bulk (PER_ITEM) sizes by indivisible items; `cargoTotalWeightVgm` + // holds the item count there, not tons. + const byItems = bulkItemWagonsRequired(booking, capacity); + if (byItems > 0) return byItems; + const weight = Number(booking.cargoTotalWeightVgm ?? 0); return Math.max(1, Math.ceil(weight / capacity)); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts index 293fc8801..5775148d7 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts @@ -217,10 +217,20 @@ export class IntercityService { // board a train that is full only on other legs. const leg = budget.legForYards(booking.originYardId, booking.destinationYardId); if (!budget.fits(need, leg)) { + // Offer the part that DOES fit the leg (split-on-payment): customer is + // notified with a pay window for the fitting wagons; the remainder can + // be re-booked on a later train. Budget is consumed by the offer so the + // next booking in this pass sees the reduced room. + const offered = await this.bookingBatchService.offerIntercityPartial( + booking, + scheduleId, + budget, + ); rejected.push({ bookingId, - reason: - 'Does not fit the remaining wagon/weight/length capacity for this train', + reason: offered + ? 'Does not fit whole — a partial offer for the wagons that fit was sent to the customer' + : 'Does not fit the remaining wagon/weight/length capacity for this train', }); continue; } @@ -320,8 +330,10 @@ export class IntercityService { .leftJoinAndSelect('booking.destinationYard', 'destinationYard') .where(`booking.trade_direction = 'DOMESTIC'`) .andWhere('booking.train_schedule_id IS NULL') + // PAID = customer paid but staff have not placed it on a train yet + // (intercity allocation is manual) — it stays in the pool until they do. .andWhere( - `((booking.is_government = false AND booking.status = 'FULLY_EXECUTED') + `((booking.is_government = false AND booking.status IN ('FULLY_EXECUTED', 'PAID')) OR (booking.is_government = true AND booking.status = 'APPROVED'))`, ) .orderBy('booking.is_government', 'DESC') @@ -372,8 +384,12 @@ export class IntercityService { if (booking.trainScheduleId) { return 'Already assigned to a train'; } - const readyStatus = booking.isGovernment ? 'APPROVED' : 'FULLY_EXECUTED'; - if (booking.status !== readyStatus) { + // Commercial: FULLY_EXECUTED opens a pay window; PAID (payment landed, + // awaiting manual placement) links straight onto the chosen train. + const readyStatuses = booking.isGovernment + ? ['APPROVED'] + : ['FULLY_EXECUTED', 'PAID']; + if (!readyStatuses.includes(booking.status)) { return `Not ready to board (status ${booking.status})`; } if (!this.corridorOnRoute(booking, milestoneSeq)) { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts index 0b8d4ad05..fd5fe05ed 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts @@ -1,6 +1,8 @@ import { + bookingCargoTons, bookingGrossWeightTons, bookingTrainLengthMeters, + bulkItemWagonsRequired, consistUsage, consistViolations, deriveTrainCapacityFromLocomotive, @@ -30,6 +32,66 @@ describe('train-capacity.util', () => { cargoTons, })); + describe('bulkItemWagonsRequired (break-bulk PER_ITEM)', () => { + // cargoTotalWeightVgm carries the ITEM COUNT for PER_ITEM cargo; the real + // tonnage rides in bulkTotalWeightTons. + const breakBulk = (quantity: number, weightTons: number) => ({ + freightType: 'BULK', + cargoTotalWeightVgm: quantity, + bulkTotalWeightTons: weightTons, + }); + + it('floors items per wagon, then ceils wagons: 400 items / 800T on 69T wagons → 12', () => { + // 800/400 = 2T per item; floor(69/2) = 34 per wagon; ceil(400/34) = 12. + expect(bulkItemWagonsRequired(breakBulk(400, 800), 69)).toBe(12); + }); + + it('needs more wagons than raw tonnage suggests when the floor loses capacity', () => { + // 3 items × 40T on 69T wagons: by weight ceil(120/69) = 2, but only ONE + // whole 40T item fits a wagon → 3 wagons. + expect(bulkItemWagonsRequired(breakBulk(3, 120), 69)).toBe(3); + }); + + it('charges one wagon per item when a single item outweighs a wagon', () => { + expect(bulkItemWagonsRequired(breakBulk(2, 200), 69)).toBe(2); + }); + + it('returns 0 for PER_TON bulk (no stored weight) and container bookings', () => { + expect( + bulkItemWagonsRequired( + { freightType: 'BULK', cargoTotalWeightVgm: 500, bulkTotalWeightTons: null }, + 69, + ), + ).toBe(0); + expect( + bulkItemWagonsRequired( + { freightType: 'CONTAINER', cargoTotalWeightVgm: 100, bulkTotalWeightTons: 100 }, + 69, + ), + ).toBe(0); + }); + + it('returns 0 on zero/invalid capacity or amounts', () => { + expect(bulkItemWagonsRequired(breakBulk(400, 800), 0)).toBe(0); + expect(bulkItemWagonsRequired(breakBulk(0, 800), 69)).toBe(0); + expect(bulkItemWagonsRequired(breakBulk(400, 0), 69)).toBe(0); + }); + }); + + describe('bookingCargoTons (break-bulk weight preference)', () => { + it('prefers bulkTotalWeightTons over the item-count VGM column', () => { + expect( + bookingCargoTons({ cargoTotalWeightVgm: 400, bulkTotalWeightTons: 800 }), + ).toBe(800); + }); + + it('falls back to cargoTotalWeightVgm when no break-bulk weight is stored', () => { + expect( + bookingCargoTons({ cargoTotalWeightVgm: 500, bulkTotalWeightTons: null }), + ).toBe(500); + }); + }); + describe('deriveTrainCapacityFromLocomotive', () => { it('derives wagon slots from train length, not a fixed 53', () => { const shortLoco = deriveTrainCapacityFromLocomotive( diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts index 7f9586c3c..061dc0dbd 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts @@ -91,14 +91,21 @@ function num(value: unknown, fallback = 0): number { * its container lines (quantity × VGM per unit). The portal's container flow * stores per-line VGM and leaves `cargoTotalWeightVgm` at 0 — reading the * total alone made every such booking weigh only its tare. + * + * Break-bulk (PER_ITEM) bookings overload `cargoTotalWeightVgm` with the ITEM + * COUNT, so their real tonnage lives in `bulkTotalWeightTons` — prefer it, or + * a 400-item / 800T booking would "weigh" 400T against the pull limit. */ export function bookingCargoTons(booking: { cargoTotalWeightVgm?: number | string | null; + bulkTotalWeightTons?: number | string | null; bookingContainers?: Array<{ quantity?: number | null; vgmPerUnitTons?: number | string | null; }> | null; }): number { + const itemTons = num(booking.bulkTotalWeightTons); + if (itemTons > 0) return itemTons; const total = num(booking.cargoTotalWeightVgm); if (total > 0) return total; return (booking.bookingContainers ?? []).reduce( @@ -107,6 +114,32 @@ export function bookingCargoTons(booking: { ); } +/** + * Wagons a break-bulk (PER_ITEM) bulk booking needs. Items are indivisible, so + * floor how many whole items fit one wagon, then ceil the wagon count: + * 400 items / 800T on 69T wagons → 2T per item → 34 items per wagon → 12 wagons. + * Returns 0 when the booking is not item-counted (PER_TON bulk, containers) — + * callers then fall back to the pooled-tonnage math. + */ +export function bulkItemWagonsRequired( + booking: { + freightType?: string | null; + cargoTotalWeightVgm?: number | string | null; + bulkTotalWeightTons?: number | string | null; + }, + capacityTons: number, +): number { + if (booking.freightType !== 'BULK' || !(capacityTons > 0)) return 0; + const quantity = num(booking.cargoTotalWeightVgm); + const totalWeightTons = num(booking.bulkTotalWeightTons); + if (!(quantity > 0) || !(totalWeightTons > 0)) return 0; + const perItemTons = totalWeightTons / quantity; + // ponytail: an item heavier than a whole wagon still charges 1 wagon per + // item; reject such bookings at creation time if the case turns real. + const itemsPerWagon = Math.max(1, Math.floor(capacityTons / perItemTons)); + return Math.max(1, Math.ceil(quantity / itemsPerWagon)); +} + /** Gross weight of one loaded wagon: it hauls itself plus its cargo. */ export function grossWagonWeightTons(slot: Pick): number { return num(slot.tareWeightTons) + num(slot.cargoTons); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index b7e9a750f..7b0f37b66 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -19,6 +19,7 @@ import { import { AcceptIntercityBookingsDto } from "./dto/accept-intercity-bookings.dto"; import { AssignBookingsDto } from "./dto/assign-bookings.dto"; import { AssignUnassignedBookingDto } from "./dto/assign-unassigned-booking.dto"; +import { SwitchGovernmentBookingDto } from "./dto/switch-government-booking.dto"; import { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.dto"; import { GetEligibleBookingsDto } from "./dto/get-eligible-bookings.dto"; import { GetEligibleBulkBookingsDto } from "./dto/get-eligible-bulk-bookings.dto"; @@ -194,6 +195,16 @@ export class TrainSchedulingController { ); } + @Get("schedules/:id/history") + @TrainSchedulingView() + @ApiOperation({ + summary: + "Unified change history for a schedule: wagon consist adjustments (add/remove/switch, with the stop they happened at) merged with booking composition removals, newest first", + }) + getScheduleHistory(@Param("id", ParseUUIDPipe) id: string) { + return this.trainSchedulingService.getScheduleHistory(id); + } + @Get("bookable-schedules") // No staff guard: customers hit this while creating a booking to find OPEN // same-route schedules. Do not attach train_scheduling permissions here. @@ -408,6 +419,25 @@ export class TrainSchedulingController { ); } + @Post("schedules/:id/switch-government-booking") + @TrainSchedulingUpdate() + @ApiOperation({ + summary: + "Switch out commercial bookings to allocate a government booking in their place", + }) + switchGovernmentBooking( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: SwitchGovernmentBookingDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.trainSchedulingService.switchGovernmentBooking( + id, + dto.governmentBookingId, + dto.removeBookingIds, + resolveAuthUserId(user), + ); + } + @Get("schedules/:id/composition-removals") @TrainSchedulingView() @ApiOperation({ summary: "Get removal log for a schedule" }) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index 0d724a040..80030c6c3 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -1351,4 +1351,88 @@ describe('TrainSchedulingService', () => { ).rejects.toThrow(/over its/); }); }); + + describe('government booking protection', () => { + const scheduleId = 'sched-gov-1'; + const govBooking = makeBooking('gov-1', 'BKG-GOV', 200, 10, '20FT', 10, undefined, undefined, undefined, { + isGovernment: true, + wagonsRequired: 10, + }); + const commercial = makeBooking('bk-1', 'BKG-COM', 100, 5, '20FT', 5, undefined, undefined, undefined, { + wagonsRequired: 5, + }); + + const scheduleGraph = { + id: scheduleId, + status: 'DRAFT', + scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'), + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + trainSetId: 'ts-1', + trainSet: { + id: 'ts-1', + locomotive, + wagons: [{ id: 'tsw-1' }, { id: 'tsw-2' }], + }, + scheduleBookings: [{ bookingId: 'gov-1' }, { bookingId: 'bk-1' }], + }; + + it('unassignBooking rejects a government booking', async () => { + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(scheduleGraph); + bookingsRepository.findById = jest.fn().mockResolvedValue(govBooking); + + await expect(service.unassignBooking(scheduleId, 'gov-1')).rejects.toThrow( + /Government bookings cannot be removed/, + ); + }); + + it('switchGovernmentBooking rejects a non-government incoming booking', async () => { + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(scheduleGraph); + bookingsRepository.findByIdsForScheduling.mockResolvedValueOnce([commercial]); + + await expect( + service.switchGovernmentBooking(scheduleId, 'bk-1', ['gov-1']), + ).rejects.toThrow(/Only government bookings/); + }); + + it('switchGovernmentBooking rejects when the freed wagons are fewer than the government booking needs', async () => { + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(scheduleGraph); + dataSource.getRepository.mockImplementation((entity: unknown) => { + if (entity === WagonBookingAllocation) { + return { find: jest.fn().mockResolvedValue([{ bookingId: 'bk-1' }]) }; + } + return { find: jest.fn().mockResolvedValue([]) }; + }); + bookingsRepository.findByIdsForScheduling.mockImplementation((ids: string[]) => + Promise.resolve( + ids.map((id) => (id === 'gov-1' ? govBooking : commercial)), + ), + ); + jest + .spyOn(service as never as { resolveTrainLimitConfig: () => unknown }, 'resolveTrainLimitConfig') + .mockResolvedValue({} as never); + // Gov booking fits the plan (10 slots) but the switched-out booking only + // frees 5 wagons — the user-facing wagon rule must still reject it. + jest + .spyOn( + service as never as { validateBookingsForScheduling: () => unknown }, + 'validateBookingsForScheduling', + ) + .mockResolvedValue({ + valid: true, + violations: [], + warnings: [], + deferredBookings: [], + bookings: [govBooking], + wagonPlan: Array.from({ length: 10 }, (_, i) => ({ + sequenceNo: i + 1, + allocations: [{ bookingId: 'gov-1' }], + })), + } as never); + + await expect( + service.switchGovernmentBooking(scheduleId, 'gov-1', ['bk-1']), + ).rejects.toThrow(/free only 5/); + }); + }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 599e1ed72..a23e763f2 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -43,6 +43,7 @@ import { Booking } from '../bookings/entities/booking.entity'; import { BookingContainer } from '../bookings/entities/booking-container.entity'; import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity'; import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; +import { Contract } from '../contracts/entities/contract.entity'; import { Container } from '../container-management/entities/container.entity'; import { Locomotive } from '../locomotives/entities/locomotive.entity'; import { LocomotivesRepository } from '../locomotives/locomotives.repository'; @@ -63,6 +64,7 @@ import { TrainCompositionRemovalLogRepository } from '../train-schedules/train-c 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'; +import { Yard } from '../rule-engine/entities/yard.entity'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { WagonTypesRepository } from '../wagon-types/wagon-types.repository'; import { Wagon } from '../wagons/entities/wagon.entity'; @@ -123,11 +125,13 @@ import { sumWagonsRequired, type TrainLimitConfig, maxEdgeConsistUsage, + perEdgeConsistUsage, validateContainerPlacements, validateMixedTrainLimitsPerEdge, type ContainerPlacementInput, type WagonPlanSlot, } from './wagon-plan.util'; +import { CorridorBudget } from './corridor-capacity.util'; import { deriveScheduleDirection } from './derive-schedule-direction.util'; import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util'; import { @@ -213,6 +217,7 @@ export function effectiveWindowConfig( ruleWindowCloseHour?: number | null; ruleWindowDurationHours?: number | null; ruleReopenDelayMinutes?: number | null; + rulePaymentWindowMinutes?: number | null; ruleImportWindowLeadDays?: number | null; ruleExportBookingLeadHours?: number | null; ruleImportCloseOffsetMinutes?: number | null; @@ -232,7 +237,14 @@ export function effectiveWindowConfig( ? Number(schedule.ruleWindowDurationHours) : liveCfg.windowDurationHours, docReviewMinutes: liveCfg.docReviewMinutes, - paymentWindowMinutes: liveCfg.paymentWindowMinutes, + // Pay windows read live values unless staff explicitly overrode this ONE + // schedule (rule_payment_window_minutes is only ever written by that + // override, never stamped at creation). The override wins for whichever + // direction the schedule runs. + paymentWindowMinutes: + schedule.rulePaymentWindowMinutes ?? liveCfg.paymentWindowMinutes, + exportPaymentWindowMinutes: + schedule.rulePaymentWindowMinutes ?? liveCfg.exportPaymentWindowMinutes, // The close offset is frozen per-schedule: a snapshot value of null means // "created with no offset" and must NOT inherit a later live offset (that // would retro-shrink an open train's window). Only a truly legacy row that @@ -630,23 +642,41 @@ export class TrainSchedulingService { let day: string | undefined; let originStationId = query.originStationId; let destinationStationId = query.destinationStationId; + // Corridor mode: a schedule with intermediate stops pools every booking + // whose leg lies ON its route (Dire→DCT on a GMT→Dire→DCT train), not just + // exact endpoint matches — otherwise a mid-corridor booking unassigned from + // a wagon vanishes from the "Paid · unassigned" pool forever. + let corridorStops: string[] | undefined; if (query.trainScheduleId) { const schedule = await this.trainSchedulesRepository.findById(query.trainScheduleId); if (schedule?.scheduledDepartureDate) { day = eatDay(schedule.scheduledDepartureDate); originStationId = originStationId ?? schedule.originStationId; destinationStationId = destinationStationId ?? schedule.destinationStationId; + const stops = await this.stopYardsForSchedule(schedule); + if (stops.length > 2) corridorStops = stops; } } - const bookings = await this.bookingsRepository.findEligibleForScheduling({ + let bookings = await this.bookingsRepository.findEligibleForScheduling({ freightType: query.freightType, originStationId, destinationStationId, schedulingStatus: query.schedulingStatus, trainScheduleId: query.trainScheduleId, day, + corridorYardIds: corridorStops, }); + if (corridorStops) { + // The IN-filter admits both yards anywhere on the route; only origin + // strictly before destination is actually rideable on this train. + const stopIdx = new Map(corridorStops.map((yardId, i) => [yardId, i])); + bookings = bookings.filter((b) => { + const from = stopIdx.get(b.originYardId); + const to = stopIdx.get(b.destinationYardId); + return from != null && to != null && from < to; + }); + } const tareDims = await this.loadWagonTareDims(); return { count: bookings.length, @@ -696,6 +726,8 @@ export class TrainSchedulingService { if (dto.windowDurationHours != null) row.windowDurationHours = dto.windowDurationHours; if (dto.docReviewMinutes != null) row.docReviewMinutes = dto.docReviewMinutes; if (dto.paymentWindowMinutes != null) row.paymentWindowMinutes = dto.paymentWindowMinutes; + if (dto.exportPaymentWindowMinutes != null) + row.exportPaymentWindowMinutes = dto.exportPaymentWindowMinutes; // Store 0 as null so "no offset" is a single canonical value. if (dto.importCloseOffsetMinutes !== undefined) row.importCloseOffsetMinutes = dto.importCloseOffsetMinutes || null; @@ -790,7 +822,14 @@ export class TrainSchedulingService { // The reopen gap is doc review + payment; keep the config values unless the // override changes them, so the derived snapshot delay stays consistent. docReviewMinutes: dto.docReviewMinutes ?? liveCfg.docReviewMinutes, - paymentWindowMinutes: dto.paymentWindowMinutes ?? liveCfg.paymentWindowMinutes, + paymentWindowMinutes: + dto.paymentWindowMinutes ?? + schedule.rulePaymentWindowMinutes ?? + liveCfg.paymentWindowMinutes, + exportPaymentWindowMinutes: + dto.paymentWindowMinutes ?? + schedule.rulePaymentWindowMinutes ?? + liveCfg.exportPaymentWindowMinutes, // A per-schedule override isn't a close-offset control, so inherit the // offset already frozen on the schedule (null = none), or the live one for // legacy rows — the override must not silently drop the global offset. @@ -853,11 +892,17 @@ export class TrainSchedulingService { } } + // The pay-window override persists only when staff actually sent it (or the + // schedule already had one) — windowRuleSnapshot never stamps it, so NULL + // keeps meaning "follow the live global value for my direction". + const rulePaymentWindowMinutes = + dto.paymentWindowMinutes ?? schedule.rulePaymentWindowMinutes ?? null; for (const t of targets) { await repo.update(t.id, { windowOpensAt: cap(times.windowOpensAt, t.departure), windowClosesAt: cap(times.windowClosesAt, t.departure), ...ruleFields, + rulePaymentWindowMinutes, }); } this.logger.log( @@ -1199,13 +1244,37 @@ export class TrainSchedulingService { windowDurationHours: num(row?.windowDurationHours, 3), docReviewMinutes: num(row?.docReviewMinutes, 30), paymentWindowMinutes: num(row?.paymentWindowMinutes, 60), + exportPaymentWindowMinutes: num(row?.exportPaymentWindowMinutes, 60), importCloseOffsetMinutes: offset(row?.importCloseOffsetMinutes), exportCloseOffsetMinutes: offset(row?.exportCloseOffsetMinutes), }; } + /** + * Limits for a preview aimed at an EXISTING schedule must be the schedule's + * own: its locomotive set and its built-consist wagon cap. Resolving from + * the dto alone re-derived the global wagon cap (53) and rejected a + * physically-coupled 54-wagon train the assign path would accept. + */ + private async resolvePreviewLimitConfig(dto: { + targetScheduleId?: string; + maxTrainWeightTons?: number; + maxTrainLengthMeters?: number; + maxWagonsPerTrain?: number; + }): Promise> { + const target = dto.targetScheduleId + ? await this.trainSchedulesRepository.findByIdWithFullGraph(dto.targetScheduleId) + : null; + if (!target) return this.resolveTrainLimitConfig(dto); + return this.resolveTrainLimitConfig( + dto, + combinedLocomotiveLimits(this.locomotivesOfTrainSet(target.trainSet)), + target.maxWagons ?? undefined, + ); + } + async previewTrainSchedule(dto: PreviewTrainScheduleDto) { - const limits = await this.resolveTrainLimitConfig(dto); + const limits = await this.resolvePreviewLimitConfig(dto); return this.buildPreviewResponse( await this.validateBookingsForScheduling( dto, @@ -1220,7 +1289,7 @@ export class TrainSchedulingService { } async previewContainerTrainSchedule(dto: PreviewContainerTrainScheduleDto) { - const limits = await this.resolveTrainLimitConfig(dto); + const limits = await this.resolvePreviewLimitConfig(dto); return this.buildPreviewResponse( await this.validateBookingsForScheduling( dto, @@ -1235,7 +1304,7 @@ export class TrainSchedulingService { } async previewBulkTrainSchedule(dto: PreviewBulkTrainScheduleDto) { - const limits = await this.resolveTrainLimitConfig(dto); + const limits = await this.resolvePreviewLimitConfig(dto); return this.buildPreviewResponse( await this.validateBookingsForScheduling( dto, @@ -1469,7 +1538,9 @@ export class TrainSchedulingService { originStationId: route.originYardId, destinationStationId: route.destinationYardId, scheduledDepartureDate: departure, - status: TrainScheduleStatusEnum.Draft, + // Born SCHEDULED: there is no draft/finalize phase — a created train + // is immediately visible and bookable to customers. + status: TrainScheduleStatusEnum.Scheduled, direction, trainNumber: pairTrainNumber ?? undefined, maxWagons, @@ -1573,7 +1644,11 @@ export class TrainSchedulingService { const setLocomotives = this.locomotivesOfTrainSet(schedule.trainSet); const limitLoco = combinedLocomotiveLimits(setLocomotives) ?? undefined; - const limits = await this.resolveTrainLimitConfig(previewDto, limitLoco); + const limits = await this.resolveTrainLimitConfig( + previewDto, + limitLoco, + schedule.maxWagons ?? undefined, + ); // Callers that add bookings without hand-picking container slots (the // workspace "Add from pool" button, re-adding a removed booking) send no @@ -1727,20 +1802,38 @@ export class TrainSchedulingService { // hauled at the same time. Coupled-but-unplanned wagons ride every edge, // so their tare rides on top of the binding edge. const emptyConsistTareTons = Math.max(0, consistTareTons - planTareTons); - const edgeUsage = maxEdgeConsistUsage( - wagonPlan, - await this.stopYardsForSchedule(schedule), - ); - const grossWeightTons = roundTons(edgeUsage.grossWeightTons + emptyConsistTareTons); - if (!dto.forceAssign && weightCapWithOverage < grossWeightTons) { + const scheduleStops = await this.stopYardsForSchedule(schedule); + const perEdge = perEdgeConsistUsage(wagonPlan, scheduleStops); + const stopLabels = await this.yardLabelMap(scheduleStops); + // Each edge is its own consist — name EVERY leg that breaks the limit, + // not just the heaviest figure, so staff see where along A→…→E it fails. + const legName = (edge: number) => + scheduleStops.length > 2 + ? `${stopLabels.get(scheduleStops[edge]) ?? scheduleStops[edge]} → ${ + stopLabels.get(scheduleStops[edge + 1]) ?? scheduleStops[edge + 1] + }` + : 'the route'; + const overweightLegs = perEdge + .map((e) => ({ + edge: e.edge, + grossWeightTons: roundTons(e.grossWeightTons + emptyConsistTareTons), + })) + .filter((e) => e.grossWeightTons > weightCapWithOverage); + if (!dto.forceAssign && overweightLegs.length) { throw new BadRequestException( - `Train set locomotives cannot pull ${grossWeightTons}T gross on the heaviest leg (limit ${roundTons(weightCapWithOverage)}T incl. tolerance)`, + `Train set locomotives cannot pull the gross weight on ${overweightLegs + .map((e) => `leg ${legName(e.edge)} (${e.grossWeightTons}T)`) + .join(', ')} — limit ${roundTons(weightCapWithOverage)}T incl. tolerance`, ); } - const maxEdgeLengthMeters = roundTons(edgeUsage.lengthMeters); - if (!dto.forceAssign && lengthCapWithOverage < maxEdgeLengthMeters) { + const overlongLegs = perEdge + .map((e) => ({ edge: e.edge, lengthMeters: roundTons(e.lengthMeters) })) + .filter((e) => e.lengthMeters > lengthCapWithOverage); + if (!dto.forceAssign && overlongLegs.length) { throw new BadRequestException( - `Train set locomotives cannot support ${maxEdgeLengthMeters}m`, + `Train set locomotives cannot support the train length on ${overlongLegs + .map((e) => `leg ${legName(e.edge)} (${e.lengthMeters}m)`) + .join(', ')} — limit ${roundTons(lengthCapWithOverage)}m incl. tolerance`, ); } @@ -1844,6 +1937,11 @@ export class TrainSchedulingService { } const booking = await this.bookingsRepository.findById(bookingId); + if (booking?.isGovernment) { + throw new BadRequestException( + 'Government bookings cannot be removed from a train. They can only be switched onto another allocation.', + ); + } const bookingReference = booking?.reference ?? null; await this.dataSource.transaction(async (manager) => { @@ -1879,6 +1977,16 @@ export class TrainSchedulingService { manager, ); + // Unassign only runs pre-dispatch, so an IN_TRANSIT status here is stale + // (e.g. auto-loaded by an earlier dispatch that was rolled back). Left as + // is, the booking becomes invisible: the eligible pool only admits PAID, + // so it can never be re-added to any train. Revert it to PAID. + if (booking?.status === 'IN_TRANSIT' && !booking.arrivedAt) { + await manager + .getRepository(Booking) + .update(bookingId, { status: 'PAID', loadedAt: null } as never); + } + // Recompute the train-set composition from whatever survives this removal. // The removed booking's allocations were already deleted above, so any slot // left with zero allocations was ridden only by this booking — release it @@ -1923,6 +2031,11 @@ export class TrainSchedulingService { }); }); + // Freed wagons may un-full the train — re-derive the window status (this + // also revives a DONE window pre-departure so the freed space is bookable + // again for import/export). + await this.bookingBatchService?.refreshWindowStatus(scheduleId); + await this.trainCompositionRemovalLogRepository.create({ scheduleId, bookingId, @@ -1931,8 +2044,14 @@ export class TrainSchedulingService { removedAt: new Date(), }); - console.log( - `[NOTIFY] Booking ${bookingReference} removed from schedule ${scheduleId} by user ${userId ?? 'unknown'} — customer should be notified to reschedule or cancel.`, + // Ops decision, so the customer hears about it: SMS/email + inbox telling + // them to rebook or pick a new schedule (the removal log above is the record). + const removedBooking = await this.dataSource + .getRepository(Booking) + .findOne({ where: { id: bookingId }, relations: { company: true } }); + if (removedBooking) this.bookingNotifier.removedFromTrain(removedBooking); + this.logger.log( + `Booking ${bookingReference} removed from schedule ${scheduleId} by user ${userId ?? 'unknown'} — customer notified to reschedule or cancel.`, ); return this.getTrainScheduleById(scheduleId); @@ -2243,6 +2362,12 @@ export class TrainSchedulingService { if (!schedule) { throw new NotFoundException(`Train schedule ${scheduleId} not found`); } + // Schedules are born SCHEDULED now — finalize is a no-op for them so the + // allocate wizard and the window auto-finalize keep working. The DRAFT + // branch below only still runs for legacy rows. + if (schedule.status === TrainScheduleStatusEnum.Scheduled) { + return this.getTrainScheduleById(scheduleId); + } if (schedule.status !== TrainScheduleStatusEnum.Draft) { throw new BadRequestException('Only DRAFT schedules can be finalized'); } @@ -2801,6 +2926,32 @@ export class TrainSchedulingService { }; } + /** + * A container item's size in feet, for the marshalling document's 40ft/20ft + * tally. Two independent sources, since only one is populated depending on + * how the item was created: + * - `item.containerType` — the item's own container_type_id FK, set for + * manually-entered items (no booking-container line behind them). + * - `item.bookingContainer.containerType.sizeFt` / `.containerSize` — the + * booking-line fallback for items generated from an allocation. + * (`findByIdWithFullGraph` must load both relations or every item here + * silently resolves to null and the tally stays zero.) + */ + private resolveContainerItemSize(item: { + containerType?: { sizeFt?: number | null } | null; + bookingContainer?: { + containerSize?: string | null; + containerType?: { sizeFt?: number | null } | null; + } | null; + }): number | null { + const fromSizeFt = item.containerType?.sizeFt ?? item.bookingContainer?.containerType?.sizeFt; + if (fromSizeFt === 20 || fromSizeFt === 40) return fromSizeFt; + const label = item.bookingContainer?.containerSize; + if (label?.includes('40')) return 40; + if (label?.includes('20')) return 20; + return null; + } + private buildExportLoadListHtml(schedule: TrainSchedule): string { const esc = (value: unknown) => String(value ?? '-') @@ -2870,9 +3021,9 @@ export class TrainSchedulingService { wagons.forEach((wagon) => { (wagon.allocations ?? []).forEach((allocation) => { (allocation.containerItems ?? []).forEach((item) => { - const size = item.bookingContainer?.containerSize; - if (size?.includes('40')) count40ft++; - else if (size?.includes('20')) count20ft++; + const size = this.resolveContainerItemSize(item); + if (size === 40) count40ft++; + else if (size === 20) count20ft++; }); }); }); @@ -3036,9 +3187,9 @@ export class TrainSchedulingService { loadList.wagons.forEach((wagon) => { wagon.allocations.forEach((allocation) => { (allocation.containerItems ?? []).forEach((item) => { - const size = item.bookingContainer?.containerSize; - if (size?.includes('40')) count40ft++; - else if (size?.includes('20')) count20ft++; + const size = this.resolveContainerItemSize(item); + if (size === 40) count40ft++; + else if (size === 20) count20ft++; }); }); }); @@ -3582,6 +3733,30 @@ export class TrainSchedulingService { // unload each one by hand. The final station is covered by // arriveSchedule's bulk fallback above. await this.bookingJourneyService.autoUnloadAtYard(scheduleId, station.yardId); + // A pass is also a position fix: the locomotives, every wagon still + // aboard, and the built train are physically AT this yard now — not at + // the origin they departed from. Wagons released at earlier stops no + // longer carry this schedule id and stay where they alighted; the final + // arrival settle still writes the wagon-movement ledger rows. + await this.dataSource.transaction(async (manager) => { + const locoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id); + if (locoIds.length) { + await manager + .getRepository(Locomotive) + .update({ id: In(locoIds) }, { currentYardId: station.yardId }); + } + await manager + .getRepository(Wagon) + .update( + { currentTrainScheduleId: scheduleId }, + { currentYardId: station.yardId }, + ); + if (schedule.trainSet?.trainId) { + await manager + .getRepository(Train) + .update(schedule.trainSet.trainId, { currentYardId: station.yardId }); + } + }); } return this.getScheduleCheckpoints(scheduleId); @@ -4065,6 +4240,7 @@ export class TrainSchedulingService { fittingBookings, dto.originStationId, dto.destinationStationId, + stops, ); violations.push( @@ -4095,12 +4271,16 @@ export class TrainSchedulingService { wagonPlan.map((slot) => [slot.wagonTypeId, { lengthMeters: slot.lengthMeters }]), ).values(), ]; + const stopLabelMap = + stops.length > 2 ? await this.yardLabelMap(stops) : new Map(); + const stopLabels = stops.map((yardId) => stopLabelMap.get(yardId) ?? yardId); pushLimit( validateMixedTrainLimitsPerEdge( wagonPlan, plannedWagonTypes.length ? plannedWagonTypes : [{ lengthMeters: 14 }], trainLimits, stops, + stopLabels, ), ); if (requireContainerPlacements && resolvedMode !== 'BULK') { @@ -4111,6 +4291,8 @@ export class TrainSchedulingService { wagonPlan, containerPlacements, placementRules, + legByBookingId, + Math.max(1, stops.length - 1), ), ); violations.push( @@ -4128,11 +4310,17 @@ export class TrainSchedulingService { ); // Weight/length limits are enforced PER EDGE by validateMixedTrainLimitsPerEdge // above — the whole-route totals here are informational (summary) only. The - // locomotive checks below also compare the heaviest single edge: a train is - // never heavier than its heaviest leg, so disjoint legs must not be summed. - const edgeUsage = maxEdgeConsistUsage(wagonPlan, stops); - const maxEdgeGrossTons = roundTons(edgeUsage.grossWeightTons); - const maxEdgeLengthMeters = roundTons(edgeUsage.lengthMeters); + // locomotive checks below also compare per edge: a train is never heavier + // than its heaviest leg, so disjoint legs must not be summed. + const perEdgeUsage = perEdgeConsistUsage(wagonPlan, stops); + const maxEdgeGrossTons = roundTons( + Math.max(0, ...perEdgeUsage.map((e) => e.grossWeightTons)), + ); + const maxEdgeLengthMeters = roundTons( + Math.max(0, ...perEdgeUsage.map((e) => e.lengthMeters)), + ); + const legName = (edge: number) => + stops.length > 2 ? `${stopLabels[edge]} → ${stopLabels[edge + 1]}` : 'the route'; let assignedLocomotives: Locomotive[] = []; if (targetScheduleId) { @@ -4152,16 +4340,29 @@ export class TrainSchedulingService { `Locomotive ${offYard.code} is not at the schedule origin yard yet; it must arrive before dispatch`, ); } - if ( - setLimits && - (setLimits.maxPullWeightTons + (Number(setLimits.overageToleranceTons) || 0) < - maxEdgeGrossTons || - setLimits.maxTrainLengthMeters + (Number(setLimits.overageToleranceMeters) || 0) < - maxEdgeLengthMeters) - ) { - pushLimit([ - 'Assigned locomotives cannot support the total train weight and length', - ]); + if (setLimits) { + // Name every leg the set cannot pull — staff must see WHERE along the + // corridor the train is too heavy/long, not just that it is somewhere. + const weightCap = + setLimits.maxPullWeightTons + (Number(setLimits.overageToleranceTons) || 0); + const lengthCap = + setLimits.maxTrainLengthMeters + + (Number(setLimits.overageToleranceMeters) || 0); + const legIssues = perEdgeUsage.flatMap((e) => { + const issues: string[] = []; + if (roundTons(e.grossWeightTons) > weightCap) { + issues.push( + `Assigned locomotives cannot pull ${roundTons(e.grossWeightTons)}T gross on leg ${legName(e.edge)} (limit ${roundTons(weightCap)}T incl. tolerance)`, + ); + } + if (roundTons(e.lengthMeters) > lengthCap) { + issues.push( + `Assigned locomotives cannot support ${roundTons(e.lengthMeters)}m train length on leg ${legName(e.edge)} (limit ${roundTons(lengthCap)}m incl. tolerance)`, + ); + } + return issues; + }); + if (legIssues.length) pushLimit(legIssues); } } else { const inServiceLocomotives = await this.locomotivesRepository.findAll({ @@ -4238,6 +4439,7 @@ export class TrainSchedulingService { maxWagonsPerTrain?: number; }, locomotive?: LocomotiveLimits | null, + builtWagonCount?: number, ): Promise> { const row = await this.loadGlobalRulesRow(); const configured = this.configService?.get<{ @@ -4280,10 +4482,18 @@ export class TrainSchedulingService { return { maxWeightTons: derived.maxWeightTons, maxLengthMeters: derived.maxLengthMeters, + // A built train's own consist is the real capacity — the length-derived + // slot count is only an estimate for trains with no wagons coupled yet. + // Without this override, validation re-derives a DIFFERENT wagon cap + // than the one the train was actually built with (e.g. a 54-wagon + // consist rejected against a re-derived 53-slot cap that never matched + // what staff physically coupled). maxWagonsPerTrain: dto?.maxWagonsPerTrain != null ? Math.floor(this.positiveNumber(dto.maxWagonsPerTrain, derived.maxWagonSlots)) - : derived.maxWagonSlots, + : builtWagonCount && builtWagonCount > 0 + ? builtWagonCount + : derived.maxWagonSlots, max20ftContainerWeightTons: this.positiveNumber( undefined, Number(row?.max20ftContainerWeightTons) || @@ -4510,16 +4720,29 @@ export class TrainSchedulingService { * schedule. Used to guard consist trims — the Wagon entity itself carries no * schedule-occupancy state anymore. */ - private async wagonIdsPinnedToLiveSchedules(manager?: EntityManager): Promise> { + /** + * Physical wagons pinned to any live run's slot. `excludeTrainId` drops the + * pins of that BUILT TRAIN's own schedules (this run and its siblings — e.g. + * the paired return leg): a consist edit is an edit of the TRAIN, sibling + * runs ride whatever it is composed of and their pins are re-pointed by the + * edit itself. Only pins held by live schedules of OTHER trains block it. + */ + private async wagonIdsPinnedToLiveSchedules( + manager?: EntityManager, + excludeTrainId?: string, + ): Promise> { const runner = manager ?? this.dataSource; const rows: { physical_wagon_id: string }[] = await runner.query( `SELECT DISTINCT tsw.physical_wagon_id FROM freight.train_set_wagons tsw JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id + JOIN freight.train_sets tset ON tset.id = tsw.train_set_id WHERE ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED') AND ts.deleted_at IS NULL AND tsw.deleted_at IS NULL - AND tsw.physical_wagon_id IS NOT NULL`, + AND tsw.physical_wagon_id IS NOT NULL + AND ($1::uuid IS NULL OR tset.train_id IS NULL OR tset.train_id <> $1)`, + [excludeTrainId ?? null], ); return new Set(rows.map((row) => row.physical_wagon_id)); } @@ -4548,8 +4771,12 @@ export class TrainSchedulingService { wagonTypeCode: typeCodeById.get(slot.wagonTypeId) ?? slot.wagonTypeId, trainSetWagonId: slot.id, boardYardId: slot.boardYardId ?? null, + alightYardId: slot.alightYardId ?? null, })); + const pinSchedule = await this.trainSchedulesRepository.findById(scheduleId); + const stops = pinSchedule ? await this.stopYardsForSchedule(pinSchedule) : []; + const unpinnable = this.findUnpinnableWagonSlots( planSlots, wagons, @@ -4557,6 +4784,7 @@ export class TrainSchedulingService { originYardId, builtTrainId, pinnedToScheduleIds, + stops, ); if (unpinnable.length) { throw new BadRequestException({ @@ -4565,14 +4793,16 @@ export class TrainSchedulingService { }); } - const assignedPhysicalIds = new Set(); + const occupiedSpans = new Map>(); for (const slot of planSlots) { + const span = this.slotSpanOf(slot, stops); const physical = this.pickPhysicalWagonForSlot( slot, wagons, scheduleId, originYardId, - assignedPhysicalIds, + occupiedSpans, + span, builtTrainId, pinnedToScheduleIds, reverseWagonOrder, @@ -4586,7 +4816,9 @@ export class TrainSchedulingService { physicalWagonId: physical.id, status: 'RESERVED', }); - assignedPhysicalIds.add(physical.id); + const pinnedSpans = occupiedSpans.get(physical.id) ?? []; + pinnedSpans.push(span); + occupiedSpans.set(physical.id, pinnedSpans); } } @@ -4603,44 +4835,74 @@ export class TrainSchedulingService { this.builtTrainIdOfSchedule(targetScheduleId), this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId), ]); + const targetSchedule = targetScheduleId + ? await this.trainSchedulesRepository.findById(targetScheduleId) + : null; + const stops = targetSchedule + ? await this.stopYardsForSchedule(targetSchedule) + : []; return this.findUnpinnableWagonSlots( wagonPlan.map((slot) => ({ sequenceNo: slot.sequenceNo, wagonTypeId: slot.wagonTypeId, wagonTypeCode: slot.wagonTypeCode, boardYardId: slot.boardYardId ?? null, + alightYardId: slot.alightYardId ?? null, })), wagons, targetScheduleId, originYardId, builtTrainId, pinnedToScheduleIds, + stops, ); } + /** + * Stop-index span [board, alight) a slot occupies along the route. Slots with + * unknown/missing yards conservatively span the whole route (never share). + */ + private slotSpanOf( + slot: { boardYardId?: string | null; alightYardId?: string | null }, + stops: string[], + ): [number, number] { + const last = Math.max(1, stops.length - 1); + const from = slot.boardYardId ? stops.indexOf(slot.boardYardId) : 0; + const to = slot.alightYardId ? stops.indexOf(slot.alightYardId) : last; + if (from < 0 || to < 0 || from >= to) return [0, last]; + return [from, to]; + } + private findUnpinnableWagonSlots( slots: Array<{ sequenceNo: number; wagonTypeId: string; wagonTypeCode: string; boardYardId?: string | null; + alightYardId?: string | null; }>, wagons: Wagon[], scheduleId: string | undefined, originYardId: string, builtTrainId: string | null = null, pinnedToScheduleIds: Set = new Set(), + stops: string[] = [], ): string[] { const violations: string[] = []; - const assignedPhysicalIds = new Set(); + // One physical wagon may serve several slots whose leg spans don't overlap + // (freed at its alight yard, reloaded downstream) — track occupied spans + // per wagon, not a flat taken-set. + const occupiedSpans = new Map>(); for (const slot of [...slots].sort((a, b) => a.sequenceNo - b.sequenceNo)) { + const span = this.slotSpanOf(slot, stops); const physical = this.pickPhysicalWagonForSlot( slot, wagons, scheduleId, originYardId, - assignedPhysicalIds, + occupiedSpans, + span, builtTrainId, pinnedToScheduleIds, ); @@ -4650,7 +4912,9 @@ export class TrainSchedulingService { ); continue; } - assignedPhysicalIds.add(physical.id); + const spans = occupiedSpans.get(physical.id) ?? []; + spans.push(span); + occupiedSpans.set(physical.id, spans); } return violations; @@ -4666,14 +4930,21 @@ export class TrainSchedulingService { wagons: Wagon[], scheduleId: string | undefined, originYardId: string, - assignedPhysicalIds: Set, + occupiedSpans: Map>, + span: [number, number], builtTrainId: string | null = null, pinnedToScheduleIds: Set = new Set(), reverseWagonOrder = false, ): Wagon | undefined { + // Free for this slot = no already-assigned span on this wagon overlaps the + // slot's own leg. Disjoint legs (alight before board) share the wagon. + const spanFree = (wagonId: string): boolean => + (occupiedSpans.get(wagonId) ?? []).every( + ([from, to]) => to <= span[0] || span[1] <= from, + ); const usable = (wagon: Wagon): boolean => { if (wagon.wagonTypeId !== slot.wagonTypeId) return false; - if (assignedPhysicalIds.has(wagon.id)) return false; + if (!spanFree(wagon.id)) return false; // Loose pool never lends a wagon coupled to a built train's consist. if (wagon.trainId) return false; // Out on a dispatched train right now — physically gone. @@ -4699,7 +4970,7 @@ export class TrainSchedulingService { (w) => w.trainId === builtTrainId && w.wagonTypeId === slot.wagonTypeId && - !assignedPhysicalIds.has(w.id), + spanFree(w.id), ) .sort((a, b) => { if (a.sequenceNumber == null || b.sequenceNumber == null) { @@ -4889,6 +5160,7 @@ export class TrainSchedulingService { bookings: Booking[], scheduleOriginYardId: string, scheduleDestinationYardId: string, + stops: string[], ): void { const bookingById = new Map(bookings.map((b) => [b.id, b])); for (const slot of wagonPlan) { @@ -4904,13 +5176,33 @@ export class TrainSchedulingService { b.originYardId === first.originYardId && b.destinationYardId === first.destinationYardId, ); - if (!sameCorridor) continue; - slot.boardYardId = - first.originYardId === scheduleOriginYardId ? null : first.originYardId; - slot.alightYardId = - first.destinationYardId === scheduleDestinationYardId - ? null - : first.destinationYardId; + if (sameCorridor) { + slot.boardYardId = + first.originYardId === scheduleOriginYardId ? null : first.originYardId; + slot.alightYardId = + first.destinationYardId === scheduleDestinationYardId + ? null + : first.destinationYardId; + continue; + } + // Mixed corridors on one wagon (cross-leg TEU sharing): the wagon rides + // the UNION of its cargo legs. A yard missing from the stop list keeps + // the slot on the whole route so capacity is never under-occupied. + let from = Number.POSITIVE_INFINITY; + let to = Number.NEGATIVE_INFINITY; + for (const b of slotBookings) { + const f = stops.indexOf(b.originYardId); + const t = stops.indexOf(b.destinationYardId); + if (f < 0 || t <= f) { + from = Number.POSITIVE_INFINITY; + break; + } + from = Math.min(from, f); + to = Math.max(to, t); + } + if (!Number.isFinite(from) || to <= from) continue; + slot.boardYardId = from === 0 ? null : stops[from]; + slot.alightYardId = to === stops.length - 1 ? null : stops[to]; } } @@ -5238,6 +5530,7 @@ export class TrainSchedulingService { booking.destinationYard?.label ?? booking.destinationYard?.code ?? 'Unknown destination', preferredDepartureDate: booking.scheduledDate?.toISOString() ?? null, status: booking.status, + isGovernment: Boolean(booking.isGovernment), }; } @@ -5494,6 +5787,70 @@ export class TrainSchedulingService { }); } + /** + * Where consist work can physically happen right now. Before departure it is + * the built train's own yard. After dispatch it is the route stop the train + * is STANDING AT per its latest checkpoint — null while rolling between + * stops or when the last checkpoint is off-route, and consist work is closed + * there. Arrived/cancelled schedules always return null (history only). + */ + private async currentConsistYardId( + schedule: TrainSchedule, + ): Promise { + if ( + schedule.status === TrainScheduleStatusEnum.Draft || + schedule.status === TrainScheduleStatusEnum.Scheduled + ) { + return schedule.trainSet?.train?.currentYardId ?? schedule.originStationId; + } + if (schedule.status !== TrainScheduleStatusEnum.Dispatched) return null; + const rows: Array<{ yard_id: string | null }> = await this.dataSource.query( + `SELECT yard_id + FROM freight.train_checkpoint_events + WHERE train_schedule_id = $1 + ORDER BY occurred_at DESC, created_at DESC + LIMIT 1`, + [schedule.id], + ); + const yardId = rows[0]?.yard_id ?? null; + if (!yardId) return null; + return this.mapScheduleStops(schedule).some((s) => s.yardId === yardId) + ? yardId + : null; + } + + /** + * Physical wagons whose cargo still RIDES beyond the given stop: any + * allocation whose booking alights strictly after it. Cargo whose + * destination is this stop (or an earlier one) has been offloaded here and + * no longer blocks its wagon — that wagon may be trimmed or switched away. + * Before departure the stop is the origin, so every allocated wagon counts + * as aboard — one rule covers both phases. Unknown destinations and + * off-route stops stay conservative (aboard). + */ + // ponytail: trusts booking.destinationYardId, not a physical unload + // confirmation — if staff trim before actually unloading, the cargo strands. + // Wire the journey unload flag in if that ever bites. + private wagonIdsWithCargoBeyond( + schedule: TrainSchedule, + atYardId: string | null, + ): Set { + const stops = this.mapScheduleStops(schedule).map((s) => s.yardId); + const atIdx = atYardId ? stops.indexOf(atYardId) : -1; + const aboard = new Set(); + for (const slot of schedule.trainSet?.wagons ?? []) { + if (!slot.physicalWagonId || !(slot.allocations?.length ?? 0)) continue; + const ridesOn = (slot.allocations ?? []).some((allocation) => { + const destination = allocation.booking?.destinationYardId; + const destIdx = destination ? stops.indexOf(destination) : -1; + if (destIdx < 0 || atIdx < 0) return true; + return destIdx > atIdx; + }); + if (ridesOn) aboard.add(slot.physicalWagonId); + } + return aboard; + } + /** * Consist snapshot for the adjust-consist UI: the built train's wagons with * loaded/removable flags, gross weight (cargo + FULL consist tare) and length @@ -5510,32 +5867,41 @@ export class TrainSchedulingService { ); } + // Where the train stands right now — the origin yard before departure, the + // checkpoint stop after it. Null = rolling; the consist is view-only then. + const currentYardId = await this.currentConsistYardId(schedule); + const wagons = await this.dataSource.getRepository(Wagon).find({ where: { trainId: builtTrain.id }, relations: { wagonType: true }, order: { sequenceNumber: 'ASC' }, }); - const addableWagons = await this.dataSource.getRepository(Wagon).find({ - where: { - trainId: IsNull(), - status: WagonStatus.Available, - currentYardId: builtTrain.currentYardId ?? undefined, - }, - relations: { wagonType: true }, - order: { wagonNumber: 'ASC' }, - }); + const addableWagons = currentYardId + ? await this.dataSource.getRepository(Wagon).find({ + where: { + trainId: IsNull(), + status: WagonStatus.Available, + currentYardId, + }, + relations: { wagonType: true }, + order: { wagonNumber: 'ASC' }, + }) + : []; const adjustments = await this.dataSource .getRepository(ScheduleWagonAdjustmentLog) .find({ where: { trainScheduleId: scheduleId }, order: { occurredAt: 'DESC' }, take: 30 }); - // Slots with cargo aboard — their physical wagons are "loaded" and can - // never be trimmed. - const loadedWagonIds = new Set( - (schedule.trainSet?.wagons ?? []) - .filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0) - .map((slot) => slot.physicalWagonId as string), + // Slots whose cargo still rides beyond the current stop — those wagons + // cannot be trimmed, only switched. Cargo offloaded at this stop (or + // earlier) has released its wagon. + const loadedWagonIds = this.wagonIdsWithCargoBeyond(schedule, currentYardId); + // Only OTHER trains' pins block edits here — this train's own schedules + // (incl. the paired return run) have their pins managed by the edit itself + // (removal clears, switch re-points). + const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules( + undefined, + builtTrain.id, ); - const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules(); const limits = combinedLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet)); const maxPullWeightTons = roundTons(Number(limits?.maxPullWeightTons ?? 0)); @@ -5600,12 +5966,23 @@ export class TrainSchedulingService { bookingWindowStatus: schedule.bookingWindowStatus ?? null, } : null, - wagons: wagons.map((wagon) => ({ - ...mapWagon(wagon), - loaded: loadedWagonIds.has(wagon.id), - // Free = not pinned to any live run's slot; only free wagons can be trimmed. - removable: !pinnedToLiveIds.has(wagon.id) && !loadedWagonIds.has(wagon.id), - })), + wagons: wagons.map((wagon) => { + const loaded = loadedWagonIds.has(wagon.id); + const pinnedElsewhere = pinnedToLiveIds.has(wagon.id); + return { + ...mapWagon(wagon), + loaded, + removable: !pinnedElsewhere && !loaded, + // A loaded wagon can't leave, but its SLOT can change wagon: switch + // moves the cargo allocations onto a same-type replacement. + switchable: !pinnedElsewhere, + blockReason: pinnedElsewhere + ? 'Pinned by another live schedule' + : loaded + ? 'Cargo aboard rides beyond this stop — switch it instead' + : null, + }; + }), addableWagons: addableWagons.map(mapWagon), adjustments: adjustments.map((log) => ({ id: log.id, @@ -5613,9 +5990,26 @@ export class TrainSchedulingService { wagonId: log.wagonId, wagonNumber: log.wagonNumber, adjustedByUserId: log.adjustedByUserId, + yardId: log.yardId ?? null, occurredAt: log.occurredAt, })), - editable: ['DRAFT', 'SCHEDULED'].includes(schedule.status), + // Editable before departure, and after it whenever the train is standing + // at a route stop (mid-route wagon work at station B); frozen while + // rolling and once arrived/cancelled. + editable: + ['DRAFT', 'SCHEDULED'].includes(schedule.status) || + (schedule.status === TrainScheduleStatusEnum.Dispatched && + currentYardId != null), + currentStop: currentYardId + ? { + yardId: currentYardId, + label: + this.mapScheduleStops(schedule).find( + (s) => s.yardId === currentYardId, + )?.label ?? currentYardId, + isMidRoute: schedule.status === TrainScheduleStatusEnum.Dispatched, + } + : null, }; } @@ -5634,19 +6028,38 @@ export class TrainSchedulingService { ) { const addWagonIds = [...new Set(dto.addWagonIds ?? [])]; const removeWagonIds = [...new Set(dto.removeWagonIds ?? [])]; - if (!addWagonIds.length && !removeWagonIds.length) { - throw new BadRequestException('Nothing to adjust — pass wagons to add and/or remove'); + const switches = dto.switches ?? []; + if (!addWagonIds.length && !removeWagonIds.length && !switches.length) { + throw new BadRequestException( + 'Nothing to adjust — pass wagons to add, remove and/or switch', + ); } - const overlap = addWagonIds.filter((id) => removeWagonIds.includes(id)); - if (overlap.length) { - throw new BadRequestException('A wagon cannot be added and removed in the same adjustment'); + const switchFromIds = switches.map((s) => s.fromWagonId); + const switchToIds = switches.map((s) => s.toWagonId); + const touched = new Map(); + for (const id of [...addWagonIds, ...removeWagonIds, ...switchFromIds, ...switchToIds]) { + touched.set(id, (touched.get(id) ?? 0) + 1); + } + if ([...touched.values()].some((count) => count > 1)) { + throw new BadRequestException( + 'Each wagon may appear once per adjustment — not in two lists or two switches', + ); } const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`); - if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) { + // Consist edits are open before departure, and after it whenever the train + // is STANDING AT a route stop (checkpointed): that is exactly the "switch + // wagons at station B" window. Rolling between stops → frozen. + const currentYardId = await this.currentConsistYardId(schedule); + const editableStatus = + ['DRAFT', 'SCHEDULED'].includes(schedule.status) || + schedule.status === TrainScheduleStatusEnum.Dispatched; + if (!editableStatus || !currentYardId) { throw new BadRequestException( - 'The consist is frozen once the train is dispatched — adjust before departure', + schedule.status === TrainScheduleStatusEnum.Dispatched + ? 'The train is rolling — consist changes are only possible while it stands at a route stop (latest checkpoint)' + : 'The consist can no longer be adjusted — the run is over', ); } const builtTrainRef = schedule.trainSet?.train; @@ -5655,11 +6068,9 @@ export class TrainSchedulingService { 'This schedule was not created from a built train — its consist cannot be adjusted here', ); } - const loadedWagonIds = new Set( - (schedule.trainSet?.wagons ?? []) - .filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0) - .map((slot) => slot.physicalWagonId as string), - ); + // Wagons whose cargo still rides beyond the current stop: never removable, + // but switchable — the replacement inherits the slot, cargo included. + const loadedWagonIds = this.wagonIdsWithCargoBeyond(schedule, currentYardId); const limits = combinedLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet)); const pullCapTons = roundTons( Number(limits?.maxPullWeightTons ?? 0) + (Number(limits?.overageToleranceTons) || 0), @@ -5682,25 +6093,41 @@ export class TrainSchedulingService { }); const consistById = new Map(consist.map((w) => [w.id, w])); - // --- validate removals: must be coupled and free (no cargo, no pin) --- - const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules(manager); + // --- validate removals: coupled, cargo offloaded, no foreign pin --- + const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules( + manager, + train.id, + ); + // Every live train set of THIS built train (this run + siblings, e.g. + // the paired return leg) — their pins follow the consist edit. + const ownSetIds = ( + await manager.getRepository(TrainSet).find({ + where: { trainId: train.id }, + select: { id: true }, + }) + ).map((set) => set.id); const removed: Wagon[] = []; for (const wagonId of removeWagonIds) { const wagon = consistById.get(wagonId); if (!wagon) { throw new NotFoundException(`Wagon ${wagonId} is not coupled to train ${train.code}`); } - if (loadedWagonIds.has(wagon.id) || pinnedToLiveIds.has(wagon.id)) { + if (loadedWagonIds.has(wagon.id)) { throw new ConflictException( - `Wagon ${wagon.wagonNumber} is loaded/pinned on a schedule and cannot be trimmed`, + `Wagon ${wagon.wagonNumber} carries cargo riding beyond this stop — it cannot be trimmed, only switched`, + ); + } + if (pinnedToLiveIds.has(wagon.id)) { + throw new ConflictException( + `Wagon ${wagon.wagonNumber} is pinned by another live schedule and cannot be trimmed`, ); } removed.push(wagon); } - // --- validate additions: AVAILABLE, loose, standing in the train's yard --- - const added: Wagon[] = []; - for (const wagonId of addWagonIds) { + // Shared gate for every incoming wagon (couple or switch replacement): + // AVAILABLE, loose, and standing where the train stands right now. + const lockIncomingWagon = async (wagonId: string): Promise => { // No `relations` on this query: Postgres refuses FOR UPDATE through the // nullable side of the wagonType LEFT JOIN ("FOR UPDATE cannot be // applied to the nullable side of an outer join"). Lock the row alone, @@ -5718,21 +6145,57 @@ export class TrainSchedulingService { `Wagon ${wagon.wagonNumber} is not available (${wagon.status})`, ); } - if (wagon.currentYardId !== train.currentYardId) { + if (wagon.currentYardId !== currentYardId) { throw new BadRequestException( - `Wagon ${wagon.wagonNumber} is not in the train's yard — only wagons in the same yard can be coupled`, + `Wagon ${wagon.wagonNumber} is not at the train's current stop — only wagons standing there can be coupled`, ); } wagon.wagonType = (await manager .getRepository(WagonType) .findOne({ where: { id: wagon.wagonTypeId } })) ?? undefined; - added.push(wagon); + return wagon; + }; + + const added: Wagon[] = []; + for (const wagonId of addWagonIds) { + added.push(await lockIncomingWagon(wagonId)); } - // --- headroom check (only additions can push the train over a cap) --- + // --- validate switches: outgoing coupled + not foreign-pinned; the + // replacement passes the incoming gate AND matches the wagon type, so + // the slot's cargo (weight, TEU geometry) rides it unchanged --- + const switchPairs: Array<{ from: Wagon; to: Wagon }> = []; + for (const { fromWagonId, toWagonId } of switches) { + const from = consistById.get(fromWagonId); + if (!from) { + throw new NotFoundException( + `Wagon ${fromWagonId} is not coupled to train ${train.code}`, + ); + } + if (pinnedToLiveIds.has(from.id)) { + throw new ConflictException( + `Wagon ${from.wagonNumber} is pinned by another live schedule and cannot be switched`, + ); + } + const to = await lockIncomingWagon(toWagonId); + if (to.wagonTypeId !== from.wagonTypeId) { + throw new BadRequestException( + `Wagon ${to.wagonNumber} (${to.wagonType?.code ?? 'unknown type'}) is not the same type as ${from.wagonNumber} (${from.wagonType?.code ?? 'unknown type'}) — a switch must not change what the slot can carry`, + ); + } + switchPairs.push({ from, to }); + } + + // --- headroom check (only additions can push the train over a cap; + // switches are same-type and cancel out, but are computed honestly) --- const removedIds = new Set(removed.map((w) => w.id)); - const finalConsist = [...consist.filter((w) => !removedIds.has(w.id)), ...added]; + const switchedFromIds = new Set(switchPairs.map((p) => p.from.id)); + const finalConsist = [ + ...consist.filter((w) => !removedIds.has(w.id) && !switchedFromIds.has(w.id)), + ...added, + ...switchPairs.map((p) => p.to), + ]; const tareOf = (w: Wagon) => Number(w.wagonType?.tareWeightTons ?? 0); const lengthOf = (w: Wagon) => Number(w.wagonType?.lengthMeters ?? 0); const finalTareTons = roundTons(finalConsist.reduce((s, w) => s + tareOf(w), 0)); @@ -5750,21 +6213,72 @@ export class TrainSchedulingService { ); } - // --- apply: detach trims, couple additions, compact the sequence --- + // --- apply: detach trims, couple additions, swap switches, compact --- + // A wagon leaving the train stands wherever the train stands — stamping + // the stop yard is what makes it findable (and re-couplable) at B. + const detachPatch = { + trainId: null, + sequenceNumber: null, + status: WagonStatus.Available, + trainSetWagonId: null, + currentTrainScheduleId: null, + currentYardId, + }; for (const wagon of removed) { - await manager.getRepository(Wagon).update(wagon.id, { - trainId: null, - sequenceNumber: null, - status: WagonStatus.Available, - }); + await manager.getRepository(Wagon).update(wagon.id, detachPatch); } - const remaining = consist.filter((w) => !removedIds.has(w.id)); - for (let i = 0; i < remaining.length; i++) { - if (remaining[i].sequenceNumber !== i + 1) { - await manager.getRepository(Wagon).update(remaining[i].id, { sequenceNumber: i + 1 }); + if (removed.length && ownSetIds.length) { + // This train's own pins (all its runs) on trimmed wagons are stale — + // clear them so the freed wagon isn't still claimed by slots it left. + await manager + .getRepository(TrainSetWagon) + .update( + { trainSetId: In(ownSetIds), physicalWagonId: In(removed.map((w) => w.id)) }, + { physicalWagonId: null }, + ); + } + + // Switches: the replacement takes the outgoing wagon's position AND its + // slot pins, so every cargo allocation now rides the new wagon. The + // outgoing wagon is left standing at the stop. + for (const { from, to } of switchPairs) { + const slots = ownSetIds.length + ? await manager.getRepository(TrainSetWagon).find({ + where: { trainSetId: In(ownSetIds), physicalWagonId: from.id }, + }) + : []; + for (const slot of slots) { + await manager + .getRepository(TrainSetWagon) + .update(slot.id, { physicalWagonId: to.id }); + } + const ownSlot = + slots.find((slot) => slot.trainSetId === schedule.trainSetId) ?? slots[0]; + await manager.getRepository(Wagon).update(to.id, { + trainId: train.id, + sequenceNumber: from.sequenceNumber, + status: WagonStatus.Assigned, + trainSetWagonId: ownSlot?.id ?? null, + currentTrainScheduleId: from.currentTrainScheduleId ?? null, + }); + // Mirror on the in-memory row — the compaction below sorts by it. + to.sequenceNumber = from.sequenceNumber; + await manager.getRepository(Wagon).update(from.id, detachPatch); + } + + const remaining = consist.filter( + (w) => !removedIds.has(w.id) && !switchedFromIds.has(w.id), + ); + const switchedIn = switchPairs.map((p) => p.to); + const compacted = [...remaining, ...switchedIn].sort( + (a, b) => (a.sequenceNumber ?? 0) - (b.sequenceNumber ?? 0), + ); + for (let i = 0; i < compacted.length; i++) { + if (compacted[i].sequenceNumber !== i + 1) { + await manager.getRepository(Wagon).update(compacted[i].id, { sequenceNumber: i + 1 }); } } - let sequence = remaining.length; + let sequence = compacted.length; for (const wagon of added) { sequence += 1; await manager.getRepository(Wagon).update(wagon.id, { @@ -5783,16 +6297,31 @@ export class TrainSchedulingService { const now = new Date(); await logRepo.save( [ - ...removed.map((wagon) => ({ action: 'REMOVE' as const, wagon })), - ...added.map((wagon) => ({ action: 'ADD' as const, wagon })), - ].map(({ action, wagon }) => + ...removed.map((wagon) => ({ + action: 'REMOVE' as const, + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + })), + ...added.map((wagon) => ({ + action: 'ADD' as const, + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + })), + ...switchPairs.map(({ from, to }) => ({ + action: 'SWITCH' as const, + wagonId: to.id, + // varchar(50) — two long wagon numbers could overflow the column. + wagonNumber: `${from.wagonNumber} → ${to.wagonNumber}`.slice(0, 50), + })), + ].map((entry) => logRepo.create({ trainScheduleId: scheduleId, trainId: train.id, - action, - wagonId: wagon.id, - wagonNumber: wagon.wagonNumber, + action: entry.action, + wagonId: entry.wagonId, + wagonNumber: entry.wagonNumber, adjustedByUserId: userId ?? null, + yardId: currentYardId, occurredAt: now, }), ), @@ -5832,6 +6361,125 @@ export class TrainSchedulingService { return { ...(await this.getScheduleConsist(scheduleId)), warnings }; } + /** + * Unified change history for the schedule detail "History" tab: wagon + * consist adjustments (ADD / REMOVE / SWITCH, with the stop they happened + * at) merged with booking composition removals, newest first. Actor resolves + * through iam.users; rows survive wagon/train deletion (log tables carry + * plain columns, no FKs). + */ + async getScheduleHistory(scheduleId: string) { + type HistoryRow = { + id: string; + kind: 'WAGON' | 'BOOKING'; + action: string; + subject: string | null; + yardLabel: string | null; + actor: string | null; + note: string | null; + occurredAt: Date; + }; + const wagonRows: HistoryRow[] = ( + await this.dataSource.query( + `SELECT l.id, + l.action, + l.wagon_number AS "subject", + COALESCE(y.label, y.code) AS "yardLabel", + COALESCE(u.username, u.email) AS "actor", + l.occurred_at AS "occurredAt" + FROM freight.schedule_wagon_adjustment_logs l + LEFT JOIN freight.yards y ON y.id = l.yard_id + LEFT JOIN iam.users u ON u.id = l.adjusted_by_user_id + WHERE l.train_schedule_id = $1 + AND l.deleted_at IS NULL + ORDER BY l.occurred_at DESC + LIMIT 200`, + [scheduleId], + ) + ).map((r: Omit) => ({ + ...r, + kind: 'WAGON' as const, + note: null, + })); + const bookingRows: HistoryRow[] = ( + await this.dataSource.query( + `SELECT r.id, + r.booking_reference AS "subject", + r.notes AS "note", + COALESCE(u.username, u.email) AS "actor", + r.removed_at AS "occurredAt" + FROM freight.train_composition_removal_logs r + LEFT JOIN iam.users u ON u.id = r.removed_by_user_id + WHERE r.schedule_id = $1 + AND r.deleted_at IS NULL + ORDER BY r.removed_at DESC + LIMIT 200`, + [scheduleId], + ) + ).map((r: Omit) => ({ + ...r, + kind: 'BOOKING' as const, + action: 'BOOKING_REMOVED', + yardLabel: null, + })); + // Per-booking journey events (load at boarding yard / unload at alighting + // yard) — sourced from the booking's own loaded_at/arrived_at stamps, so a + // multi-stop train's disjoint legs (a→b loads then unloads at b while a→c + // rides through) each show as their own row. Append-only: these columns are + // only ever set once per booking, never cleared, so rows never disappear. + const journeyRows: HistoryRow[] = ( + await this.dataSource.query( + `SELECT b.id, + b.reference AS "subject", + COALESCE(oy.label, oy.code) AS "yardLabel", + COALESCE(u.username, u.email) AS "actor", + b.loaded_at AS "occurredAt" + FROM freight.bookings b + JOIN freight.train_schedule_bookings tsb + ON tsb.booking_id = b.id AND tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL + LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id + LEFT JOIN iam.users u ON u.id = b.loaded_by_user_id + WHERE b.loaded_at IS NOT NULL + AND b.deleted_at IS NULL + ORDER BY b.loaded_at DESC + LIMIT 200`, + [scheduleId], + ) + ).map((r: Omit) => ({ + ...r, + kind: 'BOOKING' as const, + action: 'BOOKING_LOADED', + note: null, + })); + const unloadRows: HistoryRow[] = ( + await this.dataSource.query( + `SELECT b.id, + b.reference AS "subject", + COALESCE(dy.label, dy.code) AS "yardLabel", + COALESCE(u.username, u.email) AS "actor", + b.arrived_at AS "occurredAt" + FROM freight.bookings b + JOIN freight.train_schedule_bookings tsb + ON tsb.booking_id = b.id AND tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL + LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id + LEFT JOIN iam.users u ON u.id = b.arrived_by_user_id + WHERE b.arrived_at IS NOT NULL + AND b.deleted_at IS NULL + ORDER BY b.arrived_at DESC + LIMIT 200`, + [scheduleId], + ) + ).map((r: Omit) => ({ + ...r, + kind: 'BOOKING' as const, + action: 'BOOKING_UNLOADED', + note: null, + })); + return [...wagonRows, ...bookingRows, ...journeyRows, ...unloadRows].sort( + (a, b) => new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime(), + ); + } + /** * Re-derive a built train's lifecycle status from its schedules after one of * them changes: any DISPATCHED schedule → IN_SERVICE; any DRAFT/SCHEDULED → @@ -6122,7 +6770,15 @@ export class TrainSchedulingService { route: { milestones: true }, originStation: true, destinationStation: true, - scheduleBookings: { booking: true }, + // Cargo relations feed effectiveWagonsRequired for legacy links whose + // stored wagonsRequired is NULL — without them such a booking counts + // as 1 wagon and per-leg occupancy under-reports. + scheduleBookings: { + booking: { + bookingContainers: { containerType: true }, + cargoType: { wagonTypes: true }, + }, + }, }, order: { scheduledDepartureDate: 'ASC' }, }); @@ -6180,12 +6836,54 @@ export class TrainSchedulingService { }); } + /** + * Wagon slots still free for a leg of the schedule's corridor, per edge: + * capacity minus every linked booking ON ITS OWN LEG — wagon sharing means a + * booking alighting at a mid-stop frees its slots for the edges past it, so a + * train full Mojo→Dire can still sell Dire→DCT. Works for any corridor length + * (a→b→…→h). No leg given → the most open edge (can anything board at all?). + */ + private remainingWagonsForLeg( + schedule: TrainSchedule, + originYardId?: string, + destinationYardId?: string, + ): number { + const stops = this.mapScheduleStops(schedule).map((s) => s.yardId); + const budget = new CorridorBudget(stops, { + wagons: Number(schedule.maxWagons ?? 0), + weightTons: Number.POSITIVE_INFINITY, + lengthMeters: Number.POSITIVE_INFINITY, + }); + for (const sb of schedule.scheduleBookings ?? []) { + if (!sb.booking) continue; + budget.subtract( + { + wagons: this.effectiveWagonsRequired(sb.booking), + weightTons: 0, + lengthMeters: 0, + }, + budget.legForYards(sb.booking.originYardId, sb.booking.destinationYardId), + ); + } + const leg = + originYardId && destinationYardId + ? budget.legOf(originYardId, destinationYardId) + : null; + const remaining = leg ? budget.remainingFor(leg) : budget.maxRemaining(); + return Math.max(0, remaining.wagons); + } + async getBookableSchedules(originYardId?: string, destinationYardId?: string) { const schedules = await this.getBookableScheduleEntities( originYardId, destinationYardId, ); - return schedules.map((s) => this.mapScheduleListItem(s)); + return schedules.map((s) => ({ + ...this.mapScheduleListItem(s), + // Leg-aware: the list item's own remainingWagons is consist-based + // (maxWagons − coupled wagons) and reads 0 on any fully-consisted train. + remainingWagons: this.remainingWagonsForLeg(s, originYardId, destinationYardId), + })); } /** @@ -6231,8 +6929,16 @@ export class TrainSchedulingService { ); if (schedules.length === 0) return { days: [] }; + // Leg-aware: a train full on Mojo→Dire still sells Dire→DCT — gate on the + // REQUESTED leg's free slots, not on how many wagons are coupled to the + // consist (a fully-consisted train read 0 remaining and hid its days). const withCapacity = schedules.filter( - (s) => Math.max(0, (s.maxWagons ?? 0) - (s.trainSet?.wagonCount ?? 0)) > 0, + (s) => + this.remainingWagonsForLeg( + s, + input.originYardId, + input.destinationYardId, + ) > 0, ); const compatible = await this.filterCargoCompatibleSchedules(withCapacity, input); @@ -6663,11 +7369,21 @@ export class TrainSchedulingService { (snapshot?.slots ?? []).map((slot) => [slot.trainSetWagonId, slot]), ); + // Booking has no ORM relation to Contract (FK only) — fetched separately + // by id so the "on this train" cards can show the contract reference. + const contractIds = [ + ...new Set( + (schedule.scheduleBookings ?? []) + .map((sb) => sb.booking?.contractId) + .filter((id): id is string => Boolean(id)), + ), + ]; + // All independent lookups fired at once — they used to run one after // another, stacking round-trips onto every detail request. // tareDims: booking weights are reported GROSS (cargo + wagon tare) — the // number the locomotive actually hauls against its pull limit. - const [tareDims, importOp, windowCfg, containerItems, bulkLoads, rawConsistWagons] = + const [tareDims, importOp, windowCfg, containerItems, bulkLoads, rawConsistWagons, contracts] = await Promise.all([ this.loadWagonTareDims(), requiresLoadingConfirmation @@ -6697,7 +7413,13 @@ export class TrainSchedulingService { order: { sequenceNumber: schedule.reverseWagonOrder ? 'DESC' : 'ASC' }, }) : [], + contractIds.length + ? this.dataSource + .getRepository(Contract) + .find({ where: { id: In(contractIds) }, select: { id: true, reference: true } }) + : [], ]); + const contractReferenceById = new Map(contracts.map((c) => [c.id, c.reference])); const loadingConfirmed = requiresLoadingConfirmation ? Boolean(importOp?.loadedOnTrainAt) : true; @@ -6754,6 +7476,9 @@ export class TrainSchedulingService { ? roundTons(Number(wagon.wagonType.tareWeightTons)) : null, status: 'EMPTY', + // Coupled wagons ride the whole corridor — they count on every leg. + boardYardId: null, + alightYardId: null, physicalWagonId: wagon.id, physicalWagonNumber: wagon.wagonNumber ?? null, wagonType: wagon.wagonType @@ -6779,6 +7504,42 @@ export class TrainSchedulingService { reverseWagonOrder: schedule.reverseWagonOrder, }); + // Heaviest-edge consist usage. Cross-leg slot sharing means plain sums + // over-report a multi-stop train — a wagon reused Gelan→Adama and + // Adama→Doraleh is two slots but ONE physical wagon, and the train is never + // heavier/longer than its heaviest single leg. Same math as the pull-limit + // enforcement; coupled-but-empty consist wagons ride every edge. + const heaviestLeg = schedule.trainSet + ? (() => { + const usage = maxEdgeConsistUsage( + [ + ...(schedule.trainSet.wagons ?? []).map((w) => ({ + lengthMeters: Number(w.lengthMeters), + tareWeightTons: w.wagonType + ? Number(w.wagonType.tareWeightTons) + : 0, + assignedWeightTons: Number(w.assignedWeightTons), + boardYardId: w.boardYardId ?? null, + alightYardId: w.alightYardId ?? null, + allocations: w.allocations ?? [], + })), + ...emptyConsistWagons.map((w) => ({ + lengthMeters: w.lengthMeters, + tareWeightTons: Number(w.tareWeightTons ?? 0), + assignedWeightTons: 0, + allocations: [], + })), + ], + this.mapScheduleStops(schedule).map((s) => s.yardId), + ); + return { + grossWeightTons: roundTons(usage.grossWeightTons), + lengthMeters: roundTons(usage.lengthMeters), + loadedWagonCount: usage.loadedWagonCount, + }; + })() + : null; + return { id: schedule.id, reference: schedule.reference ?? null, @@ -6819,7 +7580,14 @@ export class TrainSchedulingService { importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null, exportBookingLeadHours: schedule.ruleExportBookingLeadHours ?? null, docReviewMinutes: windowCfg.docReviewMinutes, - paymentWindowMinutes: windowCfg.paymentWindowMinutes, + // Editor prefill: this schedule's own override when staff set one, + // else the live global for the schedule's direction (import/export + // pay windows are tuned separately). + paymentWindowMinutes: + schedule.rulePaymentWindowMinutes ?? + (schedule.direction === 'EXPORT' + ? windowCfg.exportPaymentWindowMinutes + : windowCfg.paymentWindowMinutes), }, route: schedule.route ? { id: schedule.route.id, name: formatRouteLabel(schedule.route) } @@ -6844,6 +7612,9 @@ export class TrainSchedulingService { wagonCount: schedule.trainSet.wagonCount, totalWeightTons: roundTons(Number(schedule.trainSet.totalWeightTons)), totalLengthMeters: roundTons(Number(schedule.trainSet.totalLengthMeters)), + // What the locomotives actually haul: usage on the corridor's + // heaviest edge, not the sum of every leg's slots. + heaviestLeg, locomotive: schedule.trainSet.locomotive ? { id: schedule.trainSet.locomotive.id, @@ -6900,6 +7671,10 @@ export class TrainSchedulingService { ? roundTons(Number(wagon.wagonType.tareWeightTons)) : null, status: wagon.status, + // Corridor span this slot rides (null = schedule endpoint) — + // lets the UI compute per-leg utilization from real slots. + boardYardId: wagon.boardYardId ?? null, + alightYardId: wagon.alightYardId ?? null, physicalWagonId: frozenSlot ? frozenSlot.physicalWagonId : wagon.physicalWagonId ?? null, @@ -6971,6 +7746,9 @@ export class TrainSchedulingService { weightTons: sb.booking ? this.grossBookingWeightTons(sb.booking, tareDims) : 0, + // Cargo only (VGM / bulk tons) — what the customer actually booked, + // without the wagons' tare. The legs tab shows this per booking. + cargoWeightTons: sb.booking ? bookingCargoTons(sb.booking) : 0, status: sb.booking?.status ?? null, schedulingStatus: sb.booking?.schedulingStatus ?? null, freightType: sb.booking?.freightType ?? null, @@ -6986,10 +7764,11 @@ export class TrainSchedulingService { sb.booking?.destinationYard?.label ?? sb.booking?.destinationYard?.code ?? null, - wagonsRequired: - sb.booking?.wagonsRequired != null - ? Number(sb.booking.wagonsRequired) - : null, + wagonsRequired: sb.booking ? this.effectiveWagonsRequired(sb.booking) : null, + contractReference: + (sb.booking?.contractId + ? contractReferenceById.get(sb.booking.contractId) + : null) ?? null, loadedAt: sb.booking?.loadedAt?.toISOString() ?? null, arrivedAt: sb.booking?.arrivedAt?.toISOString() ?? null, // Loaded/unloaded is tracked on the schedule↔booking link, not the @@ -6997,6 +7776,7 @@ export class TrainSchedulingService { // dispatch. Defaults UNLOADED for links written before the column. loadingStatus: sb.loadingStatus ?? LoadingStatus.Unloaded, wagonAssigned: allocatedBookingIds.has(sb.booking?.id ?? sb.bookingId), + isGovernment: Boolean(sb.booking?.isGovernment), })) ?? [], // Ordered corridor stops (route milestones; falls back to the two // endpoints) — lets the UI draw per-segment occupancy and label legs. @@ -7013,6 +7793,15 @@ export class TrainSchedulingService { ) : null; })(), + // Length ceiling per leg, same shape as maxGrossWeightTons: the set's + // most restrictive locomotive length plus its overage tolerance. + maxLengthMeters: (() => { + const setLimits = trainSetLocomotiveLimits(schedule.trainSet); + const cap = + Number(setLimits?.maxTrainLengthMeters) + + (Number(setLimits?.overageToleranceMeters) || 0); + return setLimits && Number.isFinite(cap) ? roundTons(cap) : null; + })(), // True when the wagon plan above is served from the frozen snapshot (schedule // is dispatched/arrived/cancelled) rather than the live joins — the UI can badge // it "historical" and skip re-pin affordances. @@ -7021,6 +7810,38 @@ export class TrainSchedulingService { }; } + /** + * A booking's wagon footprint with a computed fallback: rows linked by paths + * that never stamped `wagonsRequired` (legacy allocate) read NULL, and every + * occupancy consumer then counted them as 1 wagon — a 23-wagon booking showed + * a near-empty leg. Falls back to the TEU/weight-derived count when the cargo + * relations are loaded; a bare booking still degrades to 1. + */ + private effectiveWagonsRequired(booking: Booking): number { + const stored = Number(booking.wagonsRequired); + if (stored > 0) return Math.ceil(stored); + const bulkCapacities = (booking.cargoType?.wagonTypes ?? []) + .map((wt) => Number(wt.capacityTons)) + .filter((c) => c > 0); + const bulkCapacity = + booking.freightType === 'BULK' && bulkCapacities.length + ? Math.max(...bulkCapacities) + : undefined; + return wagonsRequiredForBooking(booking, bulkCapacity); + } + + /** + * yardId → display label for error messages that name corridor legs. One + * query; unknown ids fall back to the raw id so a message never goes blank. + */ + private async yardLabelMap(yardIds: string[]): Promise> { + if (!yardIds.length) return new Map(); + const yards = await this.dataSource + .getRepository(Yard) + .find({ where: { id: In(yardIds) } }); + return new Map(yards.map((y) => [y.id, y.label || y.code || y.id])); + } + /** Ordered corridor stops with labels, from the loaded route graph (no extra query). */ private mapScheduleStops( schedule: TrainSchedule, @@ -7111,6 +7932,7 @@ export class TrainSchedulingService { const limits = await this.resolveTrainLimitConfig( undefined, trainSetLocomotiveLimits(schedule.trainSet), + schedule.maxWagons ?? undefined, ); const validation = await this.validateBookingsForScheduling( @@ -7169,6 +7991,143 @@ export class TrainSchedulingService { ); } + /** + * Government-priority switch: free wagons by unassigning the selected + * commercial bookings, then allocate the government booking in their place. + * The gov booking must need no more wagons than the switched-out bookings + * free (ops selects more bookings otherwise), and the post-switch + * composition is fully validated BEFORE anything is unassigned so a failing + * switch never leaves the train half-emptied. + */ + async switchGovernmentBooking( + scheduleId: string, + governmentBookingId: string, + removeBookingIds: string[], + userId?: string, + ) { + if (removeBookingIds.includes(governmentBookingId)) { + throw new BadRequestException('Government booking cannot be switched out by itself'); + } + + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) { + throw new BadRequestException( + `Cannot switch bookings on a schedule in status ${schedule.status}`, + ); + } + + const [govBooking] = await this.bookingsRepository.findByIdsForScheduling([ + governmentBookingId, + ]); + if (!govBooking) { + throw new NotFoundException(`Booking ${governmentBookingId} not found`); + } + if (!govBooking.isGovernment) { + throw new BadRequestException('Only government bookings can be switched onto a train'); + } + + const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId); + if (wagonAssignedIds.has(governmentBookingId)) { + throw new BadRequestException('Government booking is already allocated on this train'); + } + + const removed = await this.bookingsRepository.findByIdsForScheduling(removeBookingIds); + if (removed.length !== removeBookingIds.length) { + throw new NotFoundException('One or more bookings to switch out were not found'); + } + const notOnTrain = removed.filter((b) => !wagonAssignedIds.has(b.id)); + if (notOnTrain.length) { + throw new BadRequestException( + `Not allocated on this train: ${notOnTrain.map((b) => b.reference).join(', ')}`, + ); + } + const govRemoved = removed.filter((b) => b.isGovernment); + if (govRemoved.length) { + throw new BadRequestException( + `Government bookings cannot be switched out: ${govRemoved.map((b) => b.reference).join(', ')}`, + ); + } + + // Dry-run the post-switch composition: survivors + the gov booking. + const survivorIds = [...wagonAssignedIds].filter((id) => !removeBookingIds.includes(id)); + const previewDto = { + bookingIds: [...survivorIds, governmentBookingId], + scheduleDate: schedule.scheduledDepartureDate.toISOString(), + originStationId: schedule.originStationId, + destinationStationId: schedule.destinationStationId, + }; + const limits = await this.resolveTrainLimitConfig( + undefined, + trainSetLocomotiveLimits(schedule.trainSet), + schedule.maxWagons ?? undefined, + ); + const validation = await this.validateBookingsForScheduling( + previewDto, + null, + false, + [], + false, + limits, + scheduleId, + ); + const freedWagons = removed.reduce((sum, b) => sum + Number(b.wagonsRequired ?? 0), 0); + if (!validation.valid) { + throw new BadRequestException({ + message: `Switch validation failed: ${validation.violations.join('; ')}`, + violations: validation.violations, + warnings: validation.warnings, + }); + } + if (!validation.bookings.some((b) => b.id === governmentBookingId)) { + throw new BadRequestException( + `Switching out ${removed.map((b) => b.reference).join(', ')} frees ${freedWagons} wagon(s) — not enough for this government booking. Select more bookings to switch out.`, + ); + } + const govWagons = sumWagonsRequired(govBooking, validation.wagonPlan); + if (govWagons > freedWagons) { + throw new BadRequestException( + `Government booking needs ${govWagons} wagon(s) but the selected bookings free only ${freedWagons}. Select more bookings to switch out.`, + ); + } + + // Same container-number gate as single-booking assignment, applied to the + // incoming gov booking only. + const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER'); + const units: ContainerUnitForPlacement[] = expandBookingContainerUnits(containerBookings); + const slots = getContainerSlotSequenceNos(validation.wagonPlan); + const placements = autoFillPlacements(units, slots); + const missingForGov = findMissingContainerNumberIssues(units, placements).find( + (m) => m.bookingId === governmentBookingId, + ); + if (missingForGov) { + throw new BadRequestException({ + message: missingForGov.issue, + violations: [missingForGov.issue], + }); + } + + // ponytail: unassign + assign run as sequential own-transaction steps, not + // one atomic unit — the dry-run above means the assign step can only fail + // on a concurrent edit; staff re-add from the eligible pool if it does. + for (const booking of removed) { + await this.unassignBooking(scheduleId, booking.id, userId); + } + + const assignableSet = new Set(validation.bookings.map((b) => b.id)); + const assignPlacements = placementsForBookings(placements, assignableSet, units); + return this.assignBookingsToSchedule( + scheduleId, + { + bookingIds: validation.bookings.map((b) => b.id), + containerPlacements: containerBookings.length > 0 ? assignPlacements : undefined, + }, + undefined, + ); + } + /** Preview wagon allocation issues per linked booking without mutating the schedule. */ async previewAllocationForSchedule( scheduleId: string, @@ -7238,6 +8197,7 @@ export class TrainSchedulingService { const limits = await this.resolveTrainLimitConfig( undefined, trainSetLocomotiveLimits(schedule.trainSet), + schedule.maxWagons ?? undefined, ); let validation: Awaited>; @@ -7826,6 +8786,7 @@ export class TrainSchedulingService { const limits = await this.resolveTrainLimitConfig( undefined, trainSetLocomotiveLimits(schedule.trainSet), + schedule.maxWagons ?? undefined, ); let validation: Awaited>; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts index 778dc70dd..6bafcc730 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts @@ -162,25 +162,36 @@ describe('applyWagonOrderReversal', () => { expect(applyWagonOrderReversal(plan, null)).toBe(plan); }); - it('flips the order and renumbers sequenceNo 1..N when the flag is true', () => { + it('flips the position numbers when the flag is true', () => { const reversed = applyWagonOrderReversal(plan, true); - // Physically-last wagon (was seq 3, wt-c) is now position 1. - expect(reversed.map((s) => s.wagonTypeId)).toEqual(['wt-c', 'wt-b', 'wt-a']); - expect(reversed.map((s) => s.sequenceNo)).toEqual([1, 2, 3]); + // Physically-last wagon (wt-c) is now position 1. + expect(reversed.map((s) => s.sequenceNo)).toEqual([3, 2, 1]); }); it('keeps each booking with its own wagon — only the position changes', () => { const reversed = applyWagonOrderReversal(plan, true); // The booking that was in the last wagon now sits at sequenceNo 1. - expect(reversed[0].sequenceNo).toBe(1); + const atPosition1 = reversed.find((s) => s.sequenceNo === 1); expect( - (reversed[0].allocations as { bookingId: string }[])[0].bookingId, + (atPosition1?.allocations as { bookingId: string }[])[0].bookingId, ).toBe('BKG-C'); + const atPosition3 = reversed.find((s) => s.sequenceNo === 3); expect( - (reversed[2].allocations as { bookingId: string }[])[0].bookingId, + (atPosition3?.allocations as { bookingId: string }[])[0].bookingId, ).toBe('BKG-A'); }); + // The regression that emptied every reversed train's container items: the + // placement generators pair unit k (booking order) with slot k of this array, + // and persistAllocationsAndLoads matches that sequenceNo against the + // allocation's booking. Array order must stay packing order. + it('keeps array order aligned with booking order so placements still match', () => { + const reversed = applyWagonOrderReversal(plan, true); + expect( + reversed.map((s) => (s.allocations as { bookingId: string }[])[0].bookingId), + ).toEqual(['BKG-A', 'BKG-B', 'BKG-C']); + }); + it('does not mutate the input plan', () => { applyWagonOrderReversal(plan, true); expect(plan.map((s) => s.sequenceNo)).toEqual([1, 2, 3]); @@ -198,7 +209,9 @@ describe('planWagonsWithStock — leg-aware stock (intercity ride-along)', () => new Map(entries); it('lets an intercity booking ride the empty leg of a train that is full on the other leg', () => { - // 1 wagon in stock. Export rides edge 1 only; intercity rides edge 0 only. + // 1 wagon in stock. Export rides edge 1 only; intercity rides edge 0 only: + // the intercity 20ft alights where the export 20ft boards, so both share + // the single physical wagon (cross-leg TEU sharing). const result = planWagonsWithStock({ bookings: [ containerBooking('EXPORT-1', 1, 1), @@ -222,16 +235,19 @@ describe('planWagonsWithStock — leg-aware stock (intercity ride-along)', () => 'EXPORT-1', 'INTERCITY-1', ]); - // Two slots planned, but both drawn from the single physical wagon. - expect(result.plan).toHaveLength(2); + expect(result.plan).toHaveLength(1); }); - it('still defers when the legs overlap and stock is exhausted', () => { + it('still defers when the wagon has no per-edge TEU room and stock is exhausted', () => { + // Export is a 40ft (2 TEU) riding the whole corridor — no edge has room + // for the intercity 20ft, and there is no second wagon to open. + const fortyFooter = containerBooking('EXPORT-1', 1, 1); + fortyFooter.bookingContainers![0]!.containerType = { + code: '40GP', + sizeFt: 40, + } as never; const result = planWagonsWithStock({ - bookings: [ - containerBooking('EXPORT-1', 1, 1), - containerBooking('INTERCITY-1', 1, 1), - ], + bookings: [fortyFooter, containerBooking('INTERCITY-1', 1, 1)], allowed, stock: { mode: 'TRAIN', @@ -239,7 +255,6 @@ describe('planWagonsWithStock — leg-aware stock (intercity ride-along)', () => codesByTypeId: new Map([[nw6.id, nw6.code]]), }, legs: legs([ - // Both ride edge 0 — they compete for the one wagon. ['EXPORT-1', { from: 0, to: 2 }], ['INTERCITY-1', { from: 0, to: 1 }], ]), @@ -252,9 +267,9 @@ describe('planWagonsWithStock — leg-aware stock (intercity ride-along)', () => expect(result.deferred[0]!.reason).toContain('Train has no free NW6 wagon left'); }); - it('never packs bookings with different legs into the same wagon slot', () => { - // Two 20ft units with room to share one wagon by TEU — but disjoint legs - // must open separate slots (each with its own leg), not one mixed slot. + it('packs disjoint-leg 20fts onto one wagon instead of appending a second', () => { + // Two 20ft units, two wagons in stock — cross-leg TEU sharing still fills + // the open wagon (span grows to the union) rather than opening wagon #2. const result = planWagonsWithStock({ bookings: [ containerBooking('EXPORT-1', 1, 1), @@ -273,11 +288,12 @@ describe('planWagonsWithStock — leg-aware stock (intercity ride-along)', () => edgeCount: 2, }); - expect(result.plan).toHaveLength(2); - const bookingsPerSlot = result.plan.map((s) => - [...new Set(s.allocations.map((a) => a.bookingId))].sort(), - ); - expect(bookingsPerSlot).toEqual([['EXPORT-1'], ['INTERCITY-1']]); + expect(result.deferred).toHaveLength(0); + expect(result.plan).toHaveLength(1); + const bookingsInSlot = [ + ...new Set(result.plan[0]!.allocations.map((a) => a.bookingId)), + ].sort(); + expect(bookingsInSlot).toEqual(['EXPORT-1', 'INTERCITY-1']); }); it('behaves exactly like the whole-route planner when no legs are given', () => { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts index 699d7a432..0cf56ded5 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts @@ -53,18 +53,25 @@ export type FlexPlanResult = { type OpenSlot = { slot: WagonPlanSlot; - teuUsed: number; + /** + * TEU occupied PER CORRIDOR EDGE. Containers on different legs share the + * same physical wagon as long as no single edge exceeds the wagon's TEU + * geometry — an intercity 20ft alighting at Adama frees its slot for a 20ft + * boarding there, and two overlapping-leg 20fts coexist while both ride. + */ + teuPerEdge: number[]; kind: SlotLoadType; /** Kind purity: a bulk wagon carries ONE cargo type at a time. */ cargoTypeId: string | null; freeCapacityTons: number; /** - * Corridor leg this slot rides (`"from-to"` stop indexes). Bookings only - * share a slot when their legs are identical — mixing corridors in one slot - * would degrade it to a whole-route slot (see stampSlotLegs) and silently - * re-occupy edges the cargo never rides. + * Leg of the FIRST booking placed (`"from-to"` stop indexes). Containers + * prefer a same-leg slot but may extend onto a different-leg one (span + * grows to the union); bulk still shares only on an identical leg. */ legKey: string; + /** Contiguous stop-index span this wagon physically rides (union of its cargo legs). */ + covered: { from: number; to: number }; }; /** Stop-index range a booking occupies: edges `from..to-1` of the corridor. */ @@ -227,16 +234,54 @@ export function planWagonsWithStock(params: { for (let e = leg.from; e < leg.to; e += 1) row[e] = (row[e] ?? 0) + 1; const open: OpenSlot = { slot: slotFromWagonType(chosen, kind), - teuUsed: 0, + teuPerEdge: new Array(edgeCount).fill(0), kind, cargoTypeId, freeCapacityTons: Number(chosen.capacityTons), legKey: legKeyOf(leg), + covered: { ...leg }, }; openSlots.push(open); return open; }; + /** TEU room on every edge of the unit's leg. */ + const teuFits = (open: OpenSlot, leg: BookingLeg, teu: number): boolean => { + for (let e = leg.from; e < leg.to; e += 1) { + if ((open.teuPerEdge[e] ?? 0) + teu > MAX_TEU_SLOTS_PER_WAGON) return false; + } + return true; + }; + + /** + * Whether the slot's ridden span can grow to include this leg: every NEW + * edge (outside the current span) must still have a physical wagon of the + * slot's type spare — extending the span puts this wagon on those edges. + */ + const canExtendSpan = (open: OpenSlot, leg: BookingLeg): boolean => { + const total = stock.remainingByTypeId.get(open.slot.wagonTypeId) ?? 0; + const row = usedPerEdge.get(open.slot.wagonTypeId); + const from = Math.min(open.covered.from, leg.from); + const to = Math.max(open.covered.to, leg.to); + for (let e = from; e < to; e += 1) { + if (e >= open.covered.from && e < open.covered.to) continue; + if (total - (row?.[e] ?? 0) <= 0) return false; + } + return true; + }; + + /** Grow the slot's span onto the leg's new edges, consuming stock there. */ + const extendSpan = (open: OpenSlot, leg: BookingLeg): void => { + const row = usedRow(open.slot.wagonTypeId); + const from = Math.min(open.covered.from, leg.from); + const to = Math.max(open.covered.to, leg.to); + for (let e = from; e < to; e += 1) { + if (e >= open.covered.from && e < open.covered.to) continue; + row[e] = (row[e] ?? 0) + 1; + } + open.covered = { from, to }; + }; + const tryPlaceBooking = (booking: Booking): PlacementProblem | null => { const leg = legFor(booking); const legKey = legKeyOf(leg); @@ -260,17 +305,23 @@ export function planWagonsWithStock(params: { } const allowedIds = new Set(candidates.map((wt) => wt.id)); const teu = unit.teuSlots ?? teuSlotsForSizeFt(unit.sizeFt ?? 20); - let target = openSlots.find( - (open) => - open.kind === 'CONTAINER' && - open.legKey === legKey && - allowedIds.has(open.slot.wagonTypeId) && - open.teuUsed + teu <= MAX_TEU_SLOTS_PER_WAGON, - ); + const fitsSlot = (open: OpenSlot): boolean => + open.kind === 'CONTAINER' && + allowedIds.has(open.slot.wagonTypeId) && + teuFits(open, leg, teu) && + canExtendSpan(open, leg); + // Same-leg slots first (keeps legacy packing byte-identical), then any + // open wagon with per-edge TEU room — an intercity 20ft rides an + // export wagon's spare slot instead of appending a new wagon. + let target = + openSlots.find((open) => open.legKey === legKey && fitsSlot(open)) ?? + openSlots.find(fitsSlot); if (!target) { const openedSlot = openSlot(candidates, 'CONTAINER', null, leg); if ('message' in openedSlot) return openedSlot; target = openedSlot; + } else { + extendSpan(target, leg); } addAllocation( target.slot, @@ -279,7 +330,9 @@ export function planWagonsWithStock(params: { unit.grossWeightTons, AllocationLoadType.Container, ); - target.teuUsed += teu; + for (let e = leg.from; e < leg.to; e += 1) { + target.teuPerEdge[e] = (target.teuPerEdge[e] ?? 0) + teu; + } } return null; } @@ -343,7 +396,8 @@ export function planWagonsWithStock(params: { ); const slotCountSnapshot = openSlots.length; const slotStateSnapshot = openSlots.map((open) => ({ - teuUsed: open.teuUsed, + teuPerEdge: [...open.teuPerEdge], + covered: { ...open.covered }, freeCapacityTons: open.freeCapacityTons, assignedWeightTons: open.slot.assignedWeightTons, allocationCount: open.slot.allocations.length, @@ -363,7 +417,8 @@ export function planWagonsWithStock(params: { openSlots.forEach((open, index) => { const snap = slotStateSnapshot[index]; if (!snap) return; - open.teuUsed = snap.teuUsed; + open.teuPerEdge = [...snap.teuPerEdge]; + open.covered = { ...snap.covered }; open.freeCapacityTons = snap.freeCapacityTons; open.slot.assignedWeightTons = snap.assignedWeightTons; open.slot.allocations.length = snap.allocationCount; @@ -415,15 +470,22 @@ export function planWagonsWithStock(params: { * sequenceNos, the snapshot re-sorts by them, and the board/allocation views all * read them — so the stored train order and the schedule order stay identical, * just reversed. A false/absent flag returns the plan unchanged. + * + * Only the NUMBERS flip — the array itself stays in packing order. Container + * placements are generated by walking the container units in booking order + * against getContainerSlotSequenceNos(plan) in array order, then matched back to + * their allocation by `sequenceNo:bookingId`. Reordering the array here broke + * that pairing on every reversed schedule: unit 1 was handed the number of the + * slot holding the LAST booking, the match missed, and persistAllocationsAndLoads + * silently dropped every container item — which is why a reversed export train + * printed a marshalling doc with no container numbers and 0/0 container counts. */ export function applyWagonOrderReversal( plan: WagonPlanSlot[], reverse: boolean | null | undefined, ): WagonPlanSlot[] { if (!reverse) return plan; - return [...plan] - .reverse() - .map((slot, index) => ({ ...slot, sequenceNo: index + 1 })); + return plan.map((slot, index) => ({ ...slot, sequenceNo: plan.length - index })); } /** Unbounded stock — used to compute pure demand for availability reporting. */ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts index a7d430b91..43c9d0f31 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts @@ -308,6 +308,7 @@ describe('maxEdgeConsistUsage — the binding edge, not the whole-route sum', () expect(maxEdgeConsistUsage(plan, stops)).toEqual({ grossWeightTons: 89, lengthMeters: 14, + loadedWagonCount: 1, }); }); @@ -328,6 +329,7 @@ describe('maxEdgeConsistUsage — the binding edge, not the whole-route sum', () expect(maxEdgeConsistUsage(plan, ['a', 'b'])).toEqual({ grossWeightTons: 178, lengthMeters: 28, + loadedWagonCount: 2, }); }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts index 84a2dc1f5..619ebbde6 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts @@ -3,7 +3,7 @@ import { AllocationLoadType } from '@edr/types'; import { Booking } from '../bookings/entities/booking.entity'; import { containersPerWagonForSize, wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; -import { consistViolations } from './train-capacity.util'; +import { bookingCargoTons, bulkItemWagonsRequired, consistViolations } from './train-capacity.util'; export const MAX_TRAIN_WEIGHT_TONS = 3500; export const MAX_TRAIN_LENGTH_METERS = 760; @@ -171,11 +171,21 @@ export function buildBulkWagonPlan( bookings: Booking[], wagonType: WagonType, ): WagonPlanSlot[] { - const totalWeight = roundTons( - bookings.reduce((sum, b) => sum + Number(b.cargoTotalWeightVgm ?? 0), 0), - ); const capacity = Number(wagonType.capacityTons); - const slots = Math.max(1, Math.ceil(totalWeight / capacity)); + // Break-bulk (PER_ITEM) bookings size by indivisible items per booking — + // their tonnage must NOT pool with PER_TON cargo (an item can't split + // across wagons the way loose tonnage can). + const itemSlotsByBooking = bookings.map((b) => bulkItemWagonsRequired(b, capacity)); + const itemSlots = itemSlotsByBooking.reduce((sum, n) => sum + n, 0); + const totalWeight = roundTons( + bookings.reduce( + (sum, b, i) => + itemSlotsByBooking[i] > 0 ? sum : sum + Number(b.cargoTotalWeightVgm ?? 0), + 0, + ), + ); + const tonSlots = totalWeight > 0 ? Math.ceil(totalWeight / capacity) : 0; + const slots = Math.max(1, tonSlots + itemSlots); const basePlan: WagonPlanSlot[] = Array.from({ length: slots }, (_, index) => ({ sequenceNo: index + 1, @@ -293,7 +303,9 @@ function allocateBookingsToSlots( const remaining = bookings.map((booking) => ({ bookingId: booking.id, bookingReference: booking.reference, - remainingWeightTons: roundTons(Number(booking.cargoTotalWeightVgm ?? 0)), + // bookingCargoTons, not the raw VGM column: for break-bulk (PER_ITEM) + // bookings that column is an item COUNT, not tons. + remainingWeightTons: roundTons(bookingCargoTons(booking)), })); let bookingIndex = 0; @@ -537,9 +549,12 @@ export function validateMixedTrainLimitsPerEdge( wagonTypes: Array>, limits: TrainLimitConfig | undefined, stops: string[], + /** Display names parallel to `stops` — violations then name the leg they hit. */ + stopLabels?: string[], ): string[] { if (stops.length <= 2) return validateMixedTrainLimits(wagonPlan, wagonTypes, limits); const spans = slotSpans(wagonPlan, stops); + const label = (i: number) => stopLabels?.[i] ?? stops[i]; const violations = new Set(); for (let edge = 0; edge < stops.length - 1; edge += 1) { const active = wagonPlan.filter( @@ -547,15 +562,28 @@ export function validateMixedTrainLimitsPerEdge( ); if (!active.length) continue; for (const violation of validateMixedTrainLimits(active, wagonTypes, limits)) { - violations.add(violation); + violations.add(`Leg ${label(edge)} → ${label(edge + 1)}: ${violation}`); } } return [...violations]; } +/** + * The slot fields per-edge usage math actually reads — lets callers feed + * persisted TrainSetWagon rows (or any structural subset), not only plan slots. + */ +export type EdgeUsageSlot = Pick< + WagonPlanSlot, + 'lengthMeters' | 'tareWeightTons' | 'assignedWeightTons' +> & { + boardYardId?: string | null; + alightYardId?: string | null; + allocations?: unknown[]; +}; + /** Per-slot stop-index spans; a yard missing from the stop list keeps the slot on the whole route. */ function slotSpans( - wagonPlan: WagonPlanSlot[], + wagonPlan: EdgeUsageSlot[], stops: string[], ): Array<{ from: number; to: number }> { const lastIdx = stops.length - 1; @@ -575,28 +603,57 @@ function slotSpans( * Two stops or fewer degrade to the whole-train totals. */ export function maxEdgeConsistUsage( - wagonPlan: WagonPlanSlot[], + wagonPlan: EdgeUsageSlot[], stops: string[], -): { grossWeightTons: number; lengthMeters: number } { - const totals = (slots: WagonPlanSlot[]) => ({ +): { grossWeightTons: number; lengthMeters: number; loadedWagonCount: number } { + return perEdgeConsistUsage(wagonPlan, stops).reduce( + (max, e) => ({ + grossWeightTons: Math.max(max.grossWeightTons, e.grossWeightTons), + lengthMeters: Math.max(max.lengthMeters, e.lengthMeters), + loadedWagonCount: Math.max(max.loadedWagonCount, e.loadedWagonCount), + }), + { grossWeightTons: 0, lengthMeters: 0, loadedWagonCount: 0 }, + ); +} + +/** Usage of one corridor edge (between stops[edge] and stops[edge + 1]). */ +export type EdgeConsistUsage = { + edge: number; + grossWeightTons: number; + lengthMeters: number; + loadedWagonCount: number; + wagonCount: number; +}; + +/** + * Per-edge breakdown behind {@link maxEdgeConsistUsage}: every edge's own + * consist totals, so callers can name WHICH leg breaks a limit instead of + * only reporting the heaviest figure. Two stops or fewer collapse to a + * single whole-route edge. + */ +export function perEdgeConsistUsage( + wagonPlan: EdgeUsageSlot[], + stops: string[], +): EdgeConsistUsage[] { + const totals = (edge: number, slots: EdgeUsageSlot[]): EdgeConsistUsage => ({ + edge, grossWeightTons: slots.reduce( (sum, w) => sum + Number(w.tareWeightTons ?? 0) + Number(w.assignedWeightTons ?? 0), 0, ), lengthMeters: slots.reduce((sum, w) => sum + Number(w.lengthMeters ?? 0), 0), + loadedWagonCount: slots.filter((w) => (w.allocations?.length ?? 1) > 0).length, + wagonCount: slots.length, }); - if (stops.length <= 2) return totals(wagonPlan); + if (stops.length <= 2) return [totals(0, wagonPlan)]; const spans = slotSpans(wagonPlan, stops); - const usage = { grossWeightTons: 0, lengthMeters: 0 }; - for (let edge = 0; edge < stops.length - 1; edge += 1) { - const active = totals( + return Array.from({ length: stops.length - 1 }, (_, edge) => + totals( + edge, wagonPlan.filter((_, i) => spans[i].from <= edge && edge < spans[i].to), - ); - usage.grossWeightTons = Math.max(usage.grossWeightTons, active.grossWeightTons); - usage.lengthMeters = Math.max(usage.lengthMeters, active.lengthMeters); - } - return usage; + ), + ); } export function validate20ftContainerRules( @@ -653,6 +710,14 @@ export function validateContainerPlacements( wagonPlan: WagonPlanSlot[], placements: ContainerPlacementInput[], rules?: ContainerPlacementRules, + /** + * Leg-aware occupancy (cross-leg TEU sharing): booking id → stop-index leg. + * With legs, a wagon's TEU/weight caps hold PER CORRIDOR EDGE — an intercity + * 20ft and an export 20ft coexist on one wagon when their edges allow it. + * Omitted → one edge, byte-identical to the whole-route check. + */ + legs?: Map, + edgeCount?: number, ): string[] { const violations: string[] = []; const units = expandBookingContainerUnits(containerBookings); @@ -709,8 +774,18 @@ export function validateContainerPlacements( } } - const slotTeuUsed = new Map(); - const slotWeightUsed = new Map(); + // TEU and weight are tracked PER EDGE of a unit's leg; without legs there is + // a single edge and this is exactly the old whole-route accounting. + const edges = Math.max(1, edgeCount ?? 1); + const legOf = (bookingId: string): { from: number; to: number } => { + const leg = legs?.get(bookingId); + if (!leg || leg.from < 0 || leg.to > edges || leg.from >= leg.to) { + return { from: 0, to: edges }; + } + return leg; + }; + const slotTeuUsed = new Map(); + const slotWeightUsed = new Map(); const slotBySeq = new Map(wagonPlan.map((s) => [s.sequenceNo, s])); for (const placement of placements) { @@ -722,22 +797,38 @@ export function validateContainerPlacements( if (!unit) continue; const teu = unit.teuSlots ?? teuSlotsForSizeFt(unit.sizeFt ?? 20); - const usedTeu = slotTeuUsed.get(placement.sequenceNo) ?? 0; - if (usedTeu + teu > MAX_TEU_SLOTS_PER_WAGON) { + const leg = legOf(unit.bookingId); + const teuRow = + slotTeuUsed.get(placement.sequenceNo) ?? new Array(edges).fill(0); + let teuFits = true; + for (let e = leg.from; e < leg.to; e += 1) { + if ((teuRow[e] ?? 0) + teu > MAX_TEU_SLOTS_PER_WAGON) { + teuFits = false; + break; + } + } + if (!teuFits) { violations.push( `Wagon #${placement.sequenceNo} cannot fit another ${unit.containerTypeCode} (max 1×40ft or 2×20ft per wagon)`, ); } else { - slotTeuUsed.set(placement.sequenceNo, usedTeu + teu); + for (let e = leg.from; e < leg.to; e += 1) teuRow[e] = (teuRow[e] ?? 0) + teu; + slotTeuUsed.set(placement.sequenceNo, teuRow); } const slot = slotBySeq.get(placement.sequenceNo); if (slot) { - const weight = roundTons(slotWeightUsed.get(placement.sequenceNo) ?? 0) + unit.grossWeightTons; - slotWeightUsed.set(placement.sequenceNo, weight); - if (weight > slot.capacityTons) { + const weightRow = + slotWeightUsed.get(placement.sequenceNo) ?? new Array(edges).fill(0); + let heaviestEdge = 0; + for (let e = leg.from; e < leg.to; e += 1) { + weightRow[e] = roundTons((weightRow[e] ?? 0) + unit.grossWeightTons); + heaviestEdge = Math.max(heaviestEdge, weightRow[e]); + } + slotWeightUsed.set(placement.sequenceNo, weightRow); + if (heaviestEdge > slot.capacityTons) { violations.push( - `Wagon #${placement.sequenceNo} total container weight ${weight}T exceeds capacity ${slot.capacityTons}T`, + `Wagon #${placement.sequenceNo} total container weight ${heaviestEdge}T exceeds capacity ${slot.capacityTons}T`, ); } } diff --git a/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts index 54322c789..0c9415e42 100644 --- a/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts +++ b/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts @@ -2,6 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ArrayMinSize, IsArray, + IsNotEmpty, IsOptional, IsString, IsUUID, @@ -50,11 +51,11 @@ export class BuildTrainDto { @IsUUID('all', { each: true }) wagonIds?: string[]; - @ApiPropertyOptional({ maxLength: 100 }) - @IsOptional() + @ApiProperty({ maxLength: 100, description: 'Vogue number' }) @IsString() + @IsNotEmpty({ message: 'Vogue number is required' }) @MaxLength(100) - trainName?: string; + trainName!: string; @ApiPropertyOptional() @IsOptional() diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts index 2b74c0253..a5cd1ff5c 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts @@ -13,8 +13,11 @@ import { Query, } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '@edr/api-common'; import { FleetManage, FleetView } from '../../common/booking-guards'; +import type { AuthUserPayload } from '../../common/resolve-auth-user-id'; +import { resolveAuthUserId } from '../../common/resolve-auth-user-id'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto'; import { BuildTrainDto } from './dto/build-train.dto'; @@ -94,8 +97,12 @@ export class TrainBuilderController { @Post(':id/wagons') @FleetManage(FREIGHT_PERMS.trains.assignWagons) @ApiOperation({ summary: "Append AVAILABLE wagons from the train's yard to the consist" }) - assignWagons(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignTrainWagonsDto) { - return this.trainBuilderService.assignWagons(id, dto); + assignWagons( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: AssignTrainWagonsDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.trainBuilderService.assignWagons(id, dto, resolveAuthUserId(user)); } @Delete(':id/wagons/:wagonId') @@ -104,8 +111,9 @@ export class TrainBuilderController { removeWagon( @Param('id', ParseUUIDPipe) id: string, @Param('wagonId', ParseUUIDPipe) wagonId: string, + @CurrentUser() user: AuthUserPayload, ) { - return this.trainBuilderService.removeWagon(id, wagonId); + return this.trainBuilderService.removeWagon(id, wagonId, resolveAuthUserId(user)); } @Post(':id/wagons/:wagonId/maintenance') @@ -114,8 +122,9 @@ export class TrainBuilderController { sendWagonToMaintenance( @Param('id', ParseUUIDPipe) id: string, @Param('wagonId', ParseUUIDPipe) wagonId: string, + @CurrentUser() user: AuthUserPayload, ) { - return this.trainBuilderService.sendWagonToMaintenance(id, wagonId); + return this.trainBuilderService.sendWagonToMaintenance(id, wagonId, resolveAuthUserId(user)); } @Post(':id/reorder-wagons') diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.reorder.spec.ts b/apps/edr-freight-api/src/modules/trains/train-builder.reorder.spec.ts new file mode 100644 index 000000000..dffa8caed --- /dev/null +++ b/apps/edr-freight-api/src/modules/trains/train-builder.reorder.spec.ts @@ -0,0 +1,44 @@ +import { orderSlotsByWagonSequence } from './train-builder.service'; + +describe('orderSlotsByWagonSequence', () => { + const slot = (sequenceNo: number, physicalWagonId: string | null) => ({ + sequenceNo, + physicalWagonId, + }); + + it('reorders pinned slots to the wagons’ new positions, unpinned trail in old order', () => { + // Built train reordered to w3, w1, w2. Slots 1..5: three pinned + two empty. + const newSeq = new Map([ + ['w3', 1], + ['w1', 2], + ['w2', 3], + ]); + const slots = [ + slot(1, 'w1'), + slot(2, 'w2'), + slot(3, 'w3'), + slot(4, null), + slot(5, null), + ]; + expect(orderSlotsByWagonSequence(slots, newSeq).map((s) => s.physicalWagonId)).toEqual([ + 'w3', + 'w1', + 'w2', + null, + null, + ]); + // Unpinned keep their old relative order (4 before 5). + expect(orderSlotsByWagonSequence(slots, newSeq).map((s) => s.sequenceNo)).toEqual([ + 3, 1, 2, 4, 5, + ]); + }); + + it('slots pinned to wagons outside the reorder trail like unpinned ones', () => { + const newSeq = new Map([['w2', 1]]); + const slots = [slot(1, 'w-foreign'), slot(2, 'w2')]; + expect(orderSlotsByWagonSequence(slots, newSeq).map((s) => s.physicalWagonId)).toEqual([ + 'w2', + 'w-foreign', + ]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts index 6aed88f07..fbb5301fb 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts @@ -11,7 +11,12 @@ import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity import { Locomotive } from '../locomotives/entities/locomotive.entity'; import { Yard } from '../rule-engine/entities/yard.entity'; +import { BookingBatchService } from '../train-scheduling/booking-batch.service'; import { combinedLocomotiveLimits } from '../train-scheduling/train-capacity.util'; +import { ScheduleWagonAdjustmentLog } from '../train-schedules/entities/schedule-wagon-adjustment-log.entity'; +import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; +import { TrainSet } from '../train-sets/entities/train-set.entity'; +import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { WagonMovement } from '../wagons/entities/wagon-movement.entity'; import { Wagon } from '../wagons/entities/wagon.entity'; @@ -58,7 +63,10 @@ export interface ActiveScheduleRef { export class TrainBuilderService { private readonly logger = new Logger(TrainBuilderService.name); - constructor(private readonly dataSource: DataSource) {} + constructor( + private readonly dataSource: DataSource, + private readonly bookingBatchService: BookingBatchService, + ) {} async buildTrain(dto: BuildTrainDto) { const locomotiveIds = [...new Set(dto.locomotiveIds)]; @@ -467,19 +475,26 @@ export class TrainBuilderService { } /** Append AVAILABLE wagons from the train's own yard to the consist. */ - async assignWagons(id: string, dto: AssignTrainWagonsDto) { + async assignWagons(id: string, dto: AssignTrainWagonsDto, userId?: string | null) { await this.dataSource.transaction(async (manager) => { const train = await this.getEditableTrain(manager, id); const currentCount = await manager .getRepository(Wagon) .count({ where: { trainId: train.id } }); - await this.attachWagons(manager, train, dto.wagonIds, currentCount); + const attached = await this.attachWagons(manager, train, dto.wagonIds, currentCount); + await this.syncLiveScheduleAfterConsistChange( + manager, + train.id, + attached.map((w) => ({ action: 'ADD' as const, wagonId: w.id, wagonNumber: w.wagonNumber })), + userId ?? null, + train.currentYardId ?? null, + ); }); return this.getComposition(id); } /** Detach one wagon and close the sequence gap it leaves. */ - async removeWagon(id: string, wagonId: string) { + async removeWagon(id: string, wagonId: string, userId?: string | null) { await this.dataSource.transaction(async (manager) => { const train = await this.getEditableTrain(manager, id); const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } }); @@ -497,6 +512,13 @@ export class TrainBuilderService { status: WagonStatus.Available, }); await this.resequenceWagons(manager, train.id); + await this.syncLiveScheduleAfterConsistChange( + manager, + train.id, + [{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }], + userId ?? null, + wagon.currentYardId ?? train.currentYardId ?? null, + ); }); return this.getComposition(id); } @@ -506,7 +528,7 @@ export class TrainBuilderService { * moves to MAINTENANCE status (not AVAILABLE), so it is not re-coupled until * it clears maintenance. The freed sequence gap is closed. */ - async sendWagonToMaintenance(id: string, wagonId: string) { + async sendWagonToMaintenance(id: string, wagonId: string, userId?: string | null) { await this.dataSource.transaction(async (manager) => { const train = await this.getEditableTrain(manager, id); const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } }); @@ -546,6 +568,13 @@ export class TrainBuilderService { ); } await this.resequenceWagons(manager, train.id); + await this.syncLiveScheduleAfterConsistChange( + manager, + train.id, + [{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }], + userId ?? null, + yardId, + ); }); return this.getComposition(id); } @@ -573,26 +602,6 @@ export class TrainBuilderService { return rows.length > 0; } - /** Batched form of {@link isWagonPinnedToLiveSchedule} for a whole consist. */ - private async isAnyWagonPinnedToLiveSchedule( - manager: EntityManager, - wagonIds: string[], - ): Promise { - if (!wagonIds.length) return false; - const rows: { exists: boolean }[] = await manager.query( - `SELECT TRUE AS exists - FROM freight.train_set_wagons tsw - JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id - WHERE tsw.physical_wagon_id = ANY($1::uuid[]) - AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED') - AND ts.deleted_at IS NULL - AND tsw.deleted_at IS NULL - LIMIT 1`, - [wagonIds], - ); - return rows.length > 0; - } - /** Persist a drag-reorder: `wagonIds` is the full consist in its new order. */ async reorderWagons(id: string, dto: ReorderTrainWagonsDto) { await this.dataSource.transaction(async (manager) => { @@ -605,20 +614,63 @@ export class TrainBuilderService { if (current.size !== incoming.size || [...current].some((wid) => !incoming.has(wid))) { throw new BadRequestException('Reorder must include every wagon of the train exactly once'); } - // A live schedule (DRAFT/SCHEDULED/DISPATCHED) reads each wagon's slot at - // its OWN frozen sequenceNo, never the wagon's live sequenceNumber — so - // renumbering here would silently desync that schedule's drawn consist - // from the built train's real order (loaded slots keep the old order, - // empty ones show the new one). Same guard as remove/maintenance. - if (await this.isAnyWagonPinnedToLiveSchedule(manager, [...current])) { + // Only a rolling train is frozen. Pre-dispatch (DRAFT/SCHEDULED) reorder + // is allowed — the pinned schedules' consists are resequenced below so + // they can never desync from the built train's real order. + const dispatched: { exists: boolean }[] = await manager.query( + `SELECT TRUE AS exists + FROM freight.train_set_wagons tsw + JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id + WHERE tsw.physical_wagon_id = ANY($1::uuid[]) + AND ts.status = 'DISPATCHED' + AND ts.deleted_at IS NULL + AND tsw.deleted_at IS NULL + LIMIT 1`, + [[...current]], + ); + if (dispatched.length > 0) { throw new ConflictException( - 'This train has wagons pinned to an active schedule and cannot be reordered — ' + - "it would desync the schedule's consist view from the built train's real order.", + 'This train is dispatched — wagons cannot be reordered while it is rolling.', ); } for (let i = 0; i < dto.wagonIds.length; i++) { await manager.getRepository(Wagon).update(dto.wagonIds[i], { sequenceNumber: i + 1 }); } + + // Propagate the new order to every live (DRAFT/SCHEDULED) schedule of + // this train: slots pinned to a reordered wagon adopt the wagon's new + // position, unpinned slots trail in their old relative order. Allocations + // ride the slot row (by id), so cargo stays with its physical wagon. + const newSeq = new Map(dto.wagonIds.map((wid, i) => [wid, i + 1])); + const sets: { train_set_id: string }[] = await manager.query( + `SELECT DISTINCT ts.train_set_id + FROM freight.train_schedules ts + JOIN freight.train_sets tset ON tset.id = ts.train_set_id + WHERE tset.train_id = $1 + AND ts.deleted_at IS NULL + AND ts.status IN ('DRAFT', 'SCHEDULED')`, + [id], + ); + for (const { train_set_id: trainSetId } of sets) { + const slots = await manager.getRepository(TrainSetWagon).find({ + where: { trainSetId }, + order: { sequenceNo: 'ASC' }, + }); + const sorted = orderSlotsByWagonSequence(slots, newSeq); + // (train_set_id, sequence_no) is unique — shift to a temp range first + // so the final renumbering can't collide mid-loop. + await manager.query( + `UPDATE freight.train_set_wagons + SET sequence_no = sequence_no + 100000 + WHERE train_set_id = $1 AND deleted_at IS NULL`, + [trainSetId], + ); + for (let i = 0; i < sorted.length; i++) { + await manager + .getRepository(TrainSetWagon) + .update(sorted[i].id, { sequenceNo: i + 1 }); + } + } }); return this.getComposition(id); } @@ -781,6 +833,81 @@ export class TrainBuilderService { }; } + /** + * Train Builder edits a train's physical consist directly on `Wagon.trainId` + * — it never touches `TrainSchedule.maxWagons` / `TrainSet.wagonCount`, so a + * wagon added/removed here (while the train already has a live DRAFT/ + * SCHEDULED schedule) used to leave the schedule's capacity, history, and + * booking-window status silently stale. This mirrors what + * TrainSchedulingService.adjustScheduleConsist does when the SAME edit is + * made from the schedule's own consist editor, so both entry points agree. + */ + private async syncLiveScheduleAfterConsistChange( + manager: EntityManager, + trainId: string, + changes: Array<{ action: 'ADD' | 'REMOVE'; wagonId: string; wagonNumber: string }>, + userId: string | null, + yardId: string | null, + ): Promise { + if (!changes.length) return; + const trainSet = await manager + .getRepository(TrainSet) + .findOne({ where: { trainId }, order: { createdAt: 'DESC' } }); + const schedule = trainSet + ? await manager.getRepository(TrainSchedule).findOne({ + where: { trainSetId: trainSet.id, status: In(['DRAFT', 'SCHEDULED']) }, + }) + : null; + + const consist = await manager.getRepository(Wagon).find({ + where: { trainId }, + relations: { wagonType: true }, + }); + const wagonCount = consist.length; + const totalWeightTons = round( + consist.reduce((sum, w) => sum + (w.wagonType?.tareWeightTons ? Number(w.wagonType.tareWeightTons) : 0), 0), + ); + const totalLengthMeters = round( + consist.reduce((sum, w) => sum + (w.wagonType?.lengthMeters ? Number(w.wagonType.lengthMeters) : 0), 0), + ); + if (trainSet) { + await manager + .getRepository(TrainSet) + .update(trainSet.id, { wagonCount, totalWeightTons, totalLengthMeters }); + } + if (!schedule) return; + + await manager.getRepository(TrainSchedule).update(schedule.id, { maxWagons: wagonCount }); + + const now = new Date(); + await manager.getRepository(ScheduleWagonAdjustmentLog).save( + changes.map((c) => + manager.getRepository(ScheduleWagonAdjustmentLog).create({ + trainScheduleId: schedule.id, + trainId, + action: c.action, + wagonId: c.wagonId, + wagonNumber: c.wagonNumber, + adjustedByUserId: userId, + yardId, + occurredAt: now, + }), + ), + ); + + // Same full/reopen rule as adjustScheduleConsist: freeing a slot on a FULL + // schedule reopens its booking window; filling the last one closes it. + const wasFull = schedule.bookingWindowStatus === 'FULL'; + const usage = await this.bookingBatchService.scheduleWagonUsage(schedule.id); + if (!usage) return; + const nowFull = usage.remainingSlots <= 0; + if (wasFull && !nowFull) { + await this.bookingBatchService.refreshWindowStatus(schedule.id); + } else if (!wasFull && nowFull) { + await this.bookingBatchService.setWindow(schedule.id, 'FULL'); + } + } + /** Load + freeze the train row for edit; block edits while it is out on a run. */ private async getEditableTrain(manager: EntityManager, id: string): Promise { const train = await manager.getRepository(Train).findOne({ @@ -856,7 +983,7 @@ export class TrainBuilderService { train: Train, wagonIds: string[], startCount: number, - ): Promise { + ): Promise { const uniqueIds = [...new Set(wagonIds)]; const wagonRepo = manager.getRepository(Wagon); @@ -883,7 +1010,7 @@ export class TrainBuilderService { } toAttach.push(wagon); } - if (!toAttach.length) return; + if (!toAttach.length) return []; await this.assertConsistLengthWithinLimit(manager, train, toAttach); @@ -896,6 +1023,7 @@ export class TrainBuilderService { status: WagonStatus.Assigned, }); } + return toAttach; } /** @@ -963,3 +1091,20 @@ export class TrainBuilderService { } } } + +/** + * New consist order for a schedule's slots after a built-train reorder: slots + * pinned to a reordered wagon adopt the wagon's new position; unpinned slots + * trail behind in their previous relative order. + */ +export function orderSlotsByWagonSequence< + T extends Pick, +>(slots: T[], newSeq: Map): T[] { + const key = (s: T): number => + (s.physicalWagonId ? newSeq.get(s.physicalWagonId) : undefined) ?? Infinity; + return [...slots].sort((a, b) => { + const sa = key(a); + const sb = key(b); + return sa !== sb ? sa - sb : a.sequenceNo - b.sequenceNo; + }); +} diff --git a/apps/edr-freight-api/src/modules/trains/trains.module.ts b/apps/edr-freight-api/src/modules/trains/trains.module.ts index 0c2ce8ded..6009ebef7 100644 --- a/apps/edr-freight-api/src/modules/trains/trains.module.ts +++ b/apps/edr-freight-api/src/modules/trains/trains.module.ts @@ -1,6 +1,7 @@ // apps/edr-freight-api/src/modules/trains/trains.module.ts import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; import { TrainLocomotive } from './entities/train-locomotive.entity'; import { Train } from './entities/train.entity'; import { TrainBuilderController } from './train-builder.controller'; @@ -9,7 +10,7 @@ import { TrainsController } from './trains.controller'; import { TrainsService } from './trains.service'; @Module({ - imports: [TypeOrmModule.forFeature([Train, TrainLocomotive])], + imports: [TypeOrmModule.forFeature([Train, TrainLocomotive]), TrainSchedulingModule], controllers: [TrainsController, TrainBuilderController], providers: [TrainsService, TrainBuilderService], exports: [TrainsService, TrainBuilderService], diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.dto.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.dto.ts index 1885b11e9..f9ffe04cb 100644 --- a/apps/edr-freight-api/src/modules/verifayda/verifayda.dto.ts +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.dto.ts @@ -13,14 +13,14 @@ export class StartVerificationDto { purpose?: 'LOGIN' | 'VERIFY'; @ApiPropertyOptional({ - enum: ['WEB', 'MOBILE'], + enum: ['WEB', 'MOBILE', 'PORTAL'], default: 'WEB', description: - 'Client platform. Selects which OAuth redirect_uri is sent to eSignet: WEB uses FAYDA_WEB_REDIRECT_URI, MOBILE uses FAYDA_REDIRECT_URI. Both land on the same /complete endpoint with identical handling.', + 'Client platform. Selects which OAuth redirect_uri is sent to eSignet: WEB (backoffice) uses FAYDA_WEB_REDIRECT_URI, PORTAL uses FAYDA_PORTAL_REDIRECT_URI, MOBILE uses FAYDA_REDIRECT_URI. All land on the same /complete handling.', }) @IsOptional() - @IsIn(['WEB', 'MOBILE']) - platform?: 'WEB' | 'MOBILE'; + @IsIn(['WEB', 'MOBILE', 'PORTAL']) + platform?: 'WEB' | 'MOBILE' | 'PORTAL'; @ApiPropertyOptional({ type: Boolean, @@ -57,6 +57,12 @@ export class CompleteVerificationResultDto { agentId?: string; }; + @ApiPropertyOptional({ + description: + 'Fayda OIDC subject — the stable key a verified identity is stored under (VERIFY flow). Pairwise pseudonymous.', + }) + sub?: string; + @ApiPropertyOptional({ description: 'Verified full name from Fayda (VERIFY flow).' }) fullName?: string; @@ -74,6 +80,11 @@ export class CompleteVerificationResultDto { @ApiPropertyOptional({ description: 'Verified gender from Fayda (VERIFY flow).' }) gender?: string; + @ApiPropertyOptional({ + description: 'Verified address from Fayda, English rendering (VERIFY flow).', + }) + address?: string; + @ApiPropertyOptional({ description: 'Whether the verified identity was saved to IAM. False if the IAM write failed.' }) userDataSaved?: boolean; diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts index 6e3e2e095..16441bd0d 100644 --- a/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts @@ -56,11 +56,15 @@ export interface CompleteVerificationResult { promptPasswordSetup?: boolean; iamUserId?: string; user?: FaydaUserSummary; + /** Fayda OIDC subject — the stable key a verified identity is stored under. */ + sub?: string; fullName?: string; email?: string; phoneNumber?: string; birthdate?: string; gender?: string; + /** Verified address, English rendering (falls back to Amharic). */ + address?: string; userDataSaved?: boolean; } @@ -125,11 +129,15 @@ export class VerifaydaService { }); } - /** WEB clients use `webRedirectUri`; MOBILE uses the base `redirectUri`. */ + /** + * Each client lands on its own registered redirect_uri: MOBILE on the base + * one, the customer portal on its own origin, everything else (backoffice) on + * the web one. All three must be registered with eSignet. + */ private redirectUriForPlatform(platform?: FaydaPlatform): string { - return platform === 'MOBILE' - ? this.faydaConfig.redirectUri - : this.faydaConfig.webRedirectUri; + if (platform === 'MOBILE') return this.faydaConfig.redirectUri; + if (platform === 'PORTAL') return this.faydaConfig.portalRedirectUri; + return this.faydaConfig.webRedirectUri; } async completeVerification( @@ -210,11 +218,13 @@ export class VerifaydaService { result = { purpose: 'VERIFY', verified: true, + sub: normalized.sub, fullName: normalized.fullName, email: normalized.email, phoneNumber: normalized.phoneNumber, birthdate: normalized.birthdate, gender: normalized.gender, + address: normalized.addressEn ?? normalized.addressAm, userDataSaved, iamUserId: iamUserId ?? undefined, token: sessionToken?.token, diff --git a/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts index 7bfb59e1d..28789b8e5 100644 --- a/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts +++ b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts @@ -16,6 +16,7 @@ export const WAGON_STATUSES = [ WagonStatus.ExportReady, WagonStatus.Maintenance, WagonStatus.Detained, + WagonStatus.OutOfService, ] as const; export type WagonStatusType = (typeof WAGON_STATUSES)[number]; diff --git a/apps/edr-freight-api/src/modules/warehouses/booking-unloaded-at-yard.spec.ts b/apps/edr-freight-api/src/modules/warehouses/booking-unloaded-at-yard.spec.ts new file mode 100644 index 000000000..836dd38b7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/booking-unloaded-at-yard.spec.ts @@ -0,0 +1,79 @@ +import { WarehouseInventoryService } from './warehouse-inventory.service'; + +/** + * A mid-corridor booking (import destined at an intermediate yard, or any + * DOMESTIC/intercity ride-along) used to have its booking.status flipped by + * the checkpoint-driven unload but never got a warehouse_inventory row — the + * Arrival Queue's unload count never moved and the booking was effectively + * stranded. handleBookingUnloadedAtYard reacts to the 'booking.unloadedAtYard' + * event BookingJourneyService.unloadBooking() emits and creates that row. + */ +function makeService(opts: { + existingInventory?: unknown; + booking?: Record | null; +}) { + const created: Record[] = []; + + const inventoryRepository = { + findAll: jest.fn().mockResolvedValue(opts.existingInventory ? [opts.existingInventory] : []), + create: jest.fn((row: Record) => { + created.push(row); + return Promise.resolve({ id: 'new-inv', ...row }); + }), + }; + + const bookingRow = + opts.booking === undefined + ? [{ weight: '10', freightType: 'CONTAINER', cargoTypeCode: 'GEN', customer: 'Acme' }] + : opts.booking + ? [opts.booking] + : []; + + const service = Object.create(WarehouseInventoryService.prototype) as Record; + service.inventoryRepository = inventoryRepository; + service.dataSource = { query: jest.fn().mockResolvedValue(bookingRow), manager: {} }; + service.allocation = { resolveLocation: jest.fn().mockResolvedValue(null) }; + service.pickDefaultLocation = jest.fn().mockResolvedValue({ warehouseId: 'w1', yardId: 'y1', zoneId: 'z1' }); + service.applyCapacityDelta = jest.fn().mockResolvedValue(undefined); + service.activityLog = { record: jest.fn().mockResolvedValue(undefined) }; + service.logger = { warn: jest.fn() }; + + return { service: service as unknown as WarehouseInventoryService, created }; +} + +describe('handleBookingUnloadedAtYard', () => { + it('creates an UNLOADED row with an IMPORT GRN for a fresh import booking', async () => { + const { service, created } = makeService({}); + await (service as unknown as { handleBookingUnloadedAtYard: (p: unknown) => Promise }) + .handleBookingUnloadedAtYard({ bookingId: 'b1', tradeDirection: 'IMPORT' }); + + expect(created).toHaveLength(1); + expect(created[0]).toMatchObject({ bookingId: 'b1', status: 'UNLOADED' }); + expect(created[0].grnNumber).toMatch(/^GRN-IMPORT-/); + }); + + it('creates one for a DOMESTIC/intercity ride-along too', async () => { + const { service, created } = makeService({}); + await (service as unknown as { handleBookingUnloadedAtYard: (p: unknown) => Promise }) + .handleBookingUnloadedAtYard({ bookingId: 'b2', tradeDirection: 'DOMESTIC' }); + + expect(created).toHaveLength(1); + expect(created[0].grnNumber).toMatch(/^GRN-DOMESTIC-/); + }); + + it('skips EXPORT — its warehouse record already exists from the origin receive', async () => { + const { service, created } = makeService({}); + await (service as unknown as { handleBookingUnloadedAtYard: (p: unknown) => Promise }) + .handleBookingUnloadedAtYard({ bookingId: 'b3', tradeDirection: 'EXPORT' }); + + expect(created).toHaveLength(0); + }); + + it('is idempotent — a booking that already has an inventory row is left alone', async () => { + const { service, created } = makeService({ existingInventory: { id: 'existing' } }); + await (service as unknown as { handleBookingUnloadedAtYard: (p: unknown) => Promise }) + .handleBookingUnloadedAtYard({ bookingId: 'b4', tradeDirection: 'IMPORT' }); + + expect(created).toHaveLength(0); + }); +}); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts index b5894c21b..be1c68bbd 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts @@ -95,11 +95,9 @@ export class WarehouseInspectionService { b.company_id AS "companyId", b.trade_direction AS "tradeDirection", b.last_mile_delivery_address AS "lastMileDeliveryAddress", - b.customer_truck_assigned_at AS "customerTruckAssignedAt", - COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile" + b.customer_truck_assigned_at AS "customerTruckAssignedAt" FROM freight.warehouse_inventory inv LEFT JOIN freight.bookings b ON b.id = inv.booking_id - LEFT JOIN freight.service_types st ON st.id = b.service_type_id WHERE inv.id = $1 AND inv.deleted_at IS NULL LIMIT 1`, [inventoryId], @@ -111,8 +109,11 @@ export class WarehouseInspectionService { readyForPickupAt: new Date(), }); - const hasLastMile = - Boolean(row.lastMileDeliveryAddress?.trim?.()) || Boolean(row.serviceIncludesLastMile); + // service_types.includes_last_mile is NOT read here — every service type + // ships with it true, which made this always true regardless of the + // customer's actual self-haul/EDR-haul choice and permanently dead-coded + // the self-haul nudge below. The delivery address is the real signal. + const hasLastMile = Boolean(row.lastMileDeliveryAddress?.trim?.()); if (row.bookingReference && hasLastMile) { await this.lastMileService.acceptBooking(row.bookingReference); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 7cd47f203..1159c1151 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -1,5 +1,5 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { EventEmitter2 } from '@nestjs/event-emitter'; +import { EventEmitter2, OnEvent } from '@nestjs/event-emitter'; import { Cron, CronExpression } from '@nestjs/schedule'; import { Between, @@ -1339,8 +1339,11 @@ export class WarehouseInventoryService { bcu.seal_numbers AS "sealNumbers", bc.container_quantity AS "containerQuantity", bc.container_packaging_type AS "containerPackagingType", - (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL - OR COALESCE(st.includes_last_mile, false)) AS "lastMileRequested", + -- service_types.includes_last_mile/first_mile are NOT read here: every + -- service type ships with both true, so OR-ing them in made this always + -- true regardless of the customer's actual self-haul/EDR-haul choice. + -- The address is the only per-booking record of that choice. + (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL) AS "lastMileRequested", oy.code AS "origin", dy.code AS "destination", oy.country AS "originCountry", @@ -1351,8 +1354,7 @@ export class WarehouseInventoryService { b.cargo_total_weight_vgm AS "weight", b.payment_status AS "paymentStatus", b.status AS "status", - (NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL - OR COALESCE(st.includes_first_mile, false)) AS "hasFirstMile", + (NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL) AS "hasFirstMile", fm.id AS "firstMileRequestId", fm.status AS "firstMileStatus", fm.vehicle_id AS "firstMileVehicleId", @@ -1386,7 +1388,6 @@ export class WarehouseInventoryService { LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id - LEFT JOIN freight.service_types st ON st.id = b.service_type_id LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL LEFT JOIN LATERAL ( SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers, @@ -1494,8 +1495,8 @@ export class WarehouseInventoryService { bc.container_packaging_type AS "containerPackagingType", COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargoDescription", oy.country AS "originCountry", dy.country AS "destinationCountry", - (NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL - OR COALESCE(st.includes_first_mile, false)) AS "hasFirstMile", + -- No service_types OR here either — see eligibleBookings above. + (NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL) AS "hasFirstMile", fm.id AS "firstMileRequestId", fm.status AS "firstMileStatus", v.plate_number AS "firstMileTruckPlateNumber", @@ -1519,14 +1520,12 @@ export class WarehouseInventoryService { b.customer_truck_container_number AS "customerTruckContainerNumber", b.customer_truck_assigned_at AS "customerTruckAssignedAt", b.company_id AS "companyId", - (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL - OR COALESCE(st.includes_last_mile, false)) AS "hasLastMile" + (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL) AS "hasLastMile" FROM freight.bookings b LEFT JOIN freight.companies company ON company.id = b.company_id ${primaryContactUserJoin('company')} LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id - LEFT JOIN freight.service_types st ON st.id = b.service_type_id LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id LEFT JOIN LATERAL ( SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers, @@ -1682,6 +1681,7 @@ export class WarehouseInventoryService { for (const pending of pendingNotifications) { void this.notifyOwnerInventoryReceived(pending.owner); void this.notifyTruckAssignmentNeeded(pending.booking, pending.bookingId); + if (dto.direction === 'EXPORT') void this.notifyCarriageAcceptanceReady(pending.bookingId); } return result; @@ -1979,11 +1979,10 @@ export class WarehouseInventoryService { COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber", ts.train_number AS "trainSchedule", inv.inspection_status AS "inspectionStatus", + -- No service_types OR here either — see eligibleBookings above. CASE WHEN NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL - OR COALESCE(st.includes_last_mile, false) THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption", - (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL - OR COALESCE(st.includes_last_mile, false)) AS "lastMileRequested", + (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL) AS "lastMileRequested", -- Multi-truck self-haul writes plates/drivers to -- customer_truck_assignments and leaves the booking columns null, -- so read the assignments first and keep the legacy column as the @@ -2018,7 +2017,6 @@ export class WarehouseInventoryService { LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id - LEFT JOIN freight.service_types st ON st.id = b.service_type_id LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id LEFT JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL @@ -2112,6 +2110,108 @@ export class WarehouseInventoryService { ); } + /** + * A booking alighted from a train at ITS OWN destination yard — emitted by + * BookingJourneyService.unloadBooking() for every direction, whether that + * yard is a mid-corridor stop (checkpoint auto-unload) or the train's final + * yard (manual per-booking unload). That per-booking flow only ever flips + * booking.status; it never creates a warehouse_inventory row, which used to + * strand mid-corridor IMPORT and DOMESTIC/intercity bookings — their status + * read ARRIVED/COMPLETED but the Arrival Queue's unload count never moved + * (nothing else was watching for a mid-corridor arrival). This creates that + * row the moment the cargo is physically off the train. + * + * EXPORT is deliberately skipped: its warehouse_inventory row (and GRN) is + * created at the ORIGIN warehouse receive, before the cargo ever boards — + * see BookingsService.carriageAcceptanceSheet and receive()/bulkReceive() + * above. Creating a second row here would duplicate that receipt. + * + * Idempotent — a booking already unloaded via this listener, a retried + * checkpoint, or the final-yard "Auto Unload" bulk action is left alone. + */ + @OnEvent('booking.unloadedAtYard') + async handleBookingUnloadedAtYard(payload: { + bookingId: string; + tradeDirection: string | null; + }): Promise { + if (payload.tradeDirection !== 'IMPORT' && payload.tradeDirection !== 'DOMESTIC') return; + try { + const existing = ( + await this.inventoryRepository.findAll({ where: { bookingId: payload.bookingId } }) + )[0]; + if (existing) return; + + const [booking]: Array<{ + weight: string | null; + freightType: string | null; + cargoTypeCode: string | null; + customer: string | null; + }> = await this.dataSource.query( + `SELECT COALESCE( + NULLIF(b.cargo_total_weight_vgm, 0), + (SELECT SUM(bcu.vgm_tons) + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc2 + ON bc2.id = bcu.booking_container_id AND bc2.deleted_at IS NULL + WHERE bc2.booking_id = b.id AND bcu.deleted_at IS NULL) + ) AS weight, + b.freight_type AS "freightType", + cgt.code AS "cargoTypeCode", + company.name AS customer + FROM freight.bookings b + LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id + LEFT JOIN freight.companies company ON company.id = b.company_id + WHERE b.id = $1 AND b.deleted_at IS NULL`, + [payload.bookingId], + ); + if (!booking) return; + + const allocated = await this.allocation.resolveLocation({ + freightType: booking.freightType, + tradeDirection: payload.tradeDirection, + cargoTypeCode: booking.cargoTypeCode, + }); + const location = allocated ?? (await this.pickDefaultLocation()); + if (!location) { + this.logger.warn( + `Checkpoint auto-unload for booking ${payload.bookingId}: no warehouse/yard/zone configured`, + ); + return; + } + + const now = new Date(); + const saved = await this.inventoryRepository.create({ + warehouseId: location.warehouseId, + yardId: location.yardId, + zoneId: location.zoneId, + bookingId: payload.bookingId, + quantity: 1, + weight: Number(booking.weight) || 0, + status: 'UNLOADED', + grnNumber: this.generateGrnNumber(payload.tradeDirection, payload.bookingId, now, booking.customer), + arrivedAt: now, + unloadedAt: now, + notes: + (allocated as { rule?: { name: string } | null } | null)?.rule + ? `Unloaded → ${(allocated as { path?: string | null }).path}` + : 'Unloaded from arrived train (checkpoint auto-unload)', + }); + await this.activityLog.record({ + activityType: 'INVENTORY_UNLOADED', + inventoryId: saved.id, + warehouseId: saved.warehouseId, + description: 'Unloaded from arrived train (checkpoint auto-unload)', + }); + if (Number(saved.weight) > 0) { + await this.applyCapacityDelta(this.dataSource.manager, location, Number(saved.weight), 0, 0); + } + } catch (err) { + this.logger.warn( + `Checkpoint auto-unload inventory create failed for ${payload.bookingId}: ${(err as Error).message}`, + ); + } + } + /** * Batch 8 — unload all eligible assigned bookings of an ARRIVED import train into UNLOADED state. * Reuses the allocation + inventory + activity-log plumbing. Does NOT store and does NOT inspect — @@ -2689,16 +2789,16 @@ export class WarehouseInventoryService { if (!bookingId) return; const [booking] = await this.dataSource.query( `SELECT reference, - last_mile_delivery_address AS "lastMileDeliveryAddress", - COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile" + last_mile_delivery_address AS "lastMileDeliveryAddress" FROM freight.bookings b - LEFT JOIN freight.service_types st ON st.id = b.service_type_id WHERE b.id = $1 AND b.deleted_at IS NULL LIMIT 1`, [bookingId], ); - const hasLastMile = - Boolean(booking?.lastMileDeliveryAddress?.trim?.()) || Boolean(booking?.serviceIncludesLastMile); + // service_types.includes_last_mile is NOT read here — every service type + // ships with it true, so it can't distinguish EDR last-mile from self-haul. + // The delivery address is the only per-booking record of that choice. + const hasLastMile = Boolean(booking?.lastMileDeliveryAddress?.trim?.()); if (!booking?.reference || !hasLastMile) return; await this.lastMileService.acceptBooking(booking.reference); } @@ -2809,6 +2909,10 @@ export class WarehouseInventoryService { return saved.id; }); + if (dto.bookingId && bookingDirection === 'EXPORT') { + void this.notifyCarriageAcceptanceReady(dto.bookingId); + } + return this.findById(id); } @@ -3057,14 +3161,15 @@ export class WarehouseInventoryService { */ private async isSelfHaulBooking(bookingId: string, manager?: EntityManager): Promise { const runner = manager ?? this.dataSource; + // service_types.includes_last_mile is NOT read here — every service type + // ships with it true, which made the second disjunct below unreachable and + // this method effectively return true only from an assigned truck. const [row]: Array<{ ok: number }> = await runner.query( `SELECT 1 AS ok FROM freight.bookings b - LEFT JOIN freight.service_types st ON st.id = b.service_type_id WHERE b.id = $1 AND b.deleted_at IS NULL AND (b.customer_truck_assigned_at IS NOT NULL - OR (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NULL - AND COALESCE(st.includes_last_mile, false) = false))`, + OR NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NULL)`, [bookingId], ); return Boolean(row); @@ -5978,6 +6083,36 @@ export class WarehouseInventoryService { return booking ?? {}; } + /** + * Tell the customer their export carriage acceptance sheet is ready to + * download from the portal. Export acceptance happens at the warehouse gate + * (see BookingsService.carriageAcceptanceSheet) — the sheet is generatable + * as soon as the cargo is received, no wagon allocation required, so this + * fires right after receive, not at marshalling. + */ + private async notifyCarriageAcceptanceReady(bookingId: string): Promise { + try { + const [b]: Array<{ companyId: string | null; reference: string }> = await this.dataSource.query( + `SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + if (!b?.companyId) return; + const body = `Your carriage acceptance sheet for booking ${b.reference} is ready to download from the portal.`; + await this.inbox.notify({ + recipients: { companyId: b.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.DOCUMENT_ACTION, + title: 'Carriage acceptance sheet ready', + body, + link: `/bookings/${bookingId}`, + data: { bookingId, reference: b.reference }, + }); + await sendCompanyChannels(this.dataSource, this.notifications, b.companyId, body); + } catch (err) { + this.logger.warn(`Carriage acceptance ready notify failed for ${bookingId}: ${(err as Error).message}`); + } + } + private async notifyOwnerInventoryReceived(params: { phone?: string | null; ownerName?: string | null; diff --git a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts index fa74f9043..7bcae753b 100644 --- a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts +++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts @@ -2,6 +2,7 @@ import { Injectable, Logger } from "@nestjs/common"; import { DataSource } from "typeorm"; import { FileUploadSetting } from "../modules/file-upload-settings/entities/file-upload-setting.entity"; +import { poaDelegationField } from "../modules/file-upload-settings/poa-delegation.constants"; interface OnboardingField { fileKey: string; @@ -17,27 +18,14 @@ interface OnboardingField { const DOC_EXTENSIONS = ["pdf", "jpg", "jpeg", "png"]; -/** fileKey of the delegation letter attached to the Power of Attorney step. */ -export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter"; - /** - * Seeded as optional: the delegation letter is only mandatory once a PoA has - * been entered, or when the company operates as a freight forwarder. That rule - * spans form fields as well as files, so it lives in the onboarding gate - * (companies.service.getOnboardingRequirements) rather than in `isRequired`. + * Listed in the sets below only so the reference defaults stay a complete + * picture of a company onboarding form. Unlike every other field here, the DARS + * delegation paper is not admin-managed: `FileUploadSettingsService.getByCode` + * injects it from poa-delegation.constants.ts whether or not a row exists. */ -const poaDelegationField = (displayOrder: number): OnboardingField => ({ - fileKey: POA_DELEGATION_FILE_KEY, - fileLabel: "PoA Delegation Letter", - helpText: - "Signed letter in which the General Manager delegates the representative named above.", - isRequired: false, - isMultiple: false, - maxFiles: 1, - allowedExtensions: DOC_EXTENSIONS, - maxSizeMb: 10, - displayOrder, -}); +const poaDelegationDefault = (displayOrder: number): OnboardingField => + poaDelegationField(displayOrder) as unknown as OnboardingField; /** Documents required from an Ethiopian company at onboarding. */ const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [ @@ -75,7 +63,7 @@ const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [ maxSizeMb: 10, displayOrder: 3, }, - poaDelegationField(4), + poaDelegationDefault(4), ]; /** Documents required from a Foreign company at onboarding. */ @@ -124,46 +112,47 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [ maxSizeMb: 10, displayOrder: 4, }, - poaDelegationField(5), + poaDelegationDefault(5), ]; -/** Legacy combined set, kept for the older per-company-type codes. */ -const LEGACY_ONBOARDING_FIELDS: OnboardingField[] = [ - { - fileKey: "business_license", - fileLabel: "Business License / Trade License", - helpText: - "Verified against the government trade system during registration.", - isRequired: true, - isMultiple: false, - maxFiles: 1, - allowedExtensions: DOC_EXTENSIONS, - maxSizeMb: 10, - displayOrder: 1, - }, - { - fileKey: "tin_certificate", - fileLabel: "TIN Certificate", - helpText: "Verified against the TIN registry during registration.", - isRequired: true, - isMultiple: false, - maxFiles: 1, - allowedExtensions: DOC_EXTENSIONS, - maxSizeMb: 10, - displayOrder: 2, - }, - { - fileKey: "national_id_passport", - fileLabel: "National ID / Passport", - helpText: "Verified against the National ID API during registration.", - isRequired: true, - isMultiple: false, - maxFiles: 1, - allowedExtensions: DOC_EXTENSIONS, - maxSizeMb: 10, - displayOrder: 3, - }, -]; +// Legacy combined set for the removed per-company-type codes +// (company_onboarding_documents_customer/forwarder/transporter/forwarder_dj). +// const LEGACY_ONBOARDING_FIELDS: OnboardingField[] = [ +// { +// fileKey: "business_license", +// fileLabel: "Business License / Trade License", +// helpText: +// "Verified against the government trade system during registration.", +// isRequired: true, +// isMultiple: false, +// maxFiles: 1, +// allowedExtensions: DOC_EXTENSIONS, +// maxSizeMb: 10, +// displayOrder: 1, +// }, +// { +// fileKey: "tin_certificate", +// fileLabel: "TIN Certificate", +// helpText: "Verified against the TIN registry during registration.", +// isRequired: true, +// isMultiple: false, +// maxFiles: 1, +// allowedExtensions: DOC_EXTENSIONS, +// maxSizeMb: 10, +// displayOrder: 2, +// }, +// { +// fileKey: "national_id_passport", +// fileLabel: "National ID / Passport", +// helpText: "Verified against the National ID API during registration.", +// isRequired: true, +// isMultiple: false, +// maxFiles: 1, +// allowedExtensions: DOC_EXTENSIONS, +// maxSizeMb: 10, +// displayOrder: 3, +// }, +// ]; interface OnboardingDocumentSetting { code: string; @@ -187,31 +176,32 @@ const COMPANY_ONBOARDING_DOCUMENTS: OnboardingDocumentSetting[] = [ entity: "customer", fields: FOREIGN_ONBOARDING_FIELDS, }, - // Legacy per-company-type codes (kept for back-compat; no longer used by the portal). - { - code: "company_onboarding_documents_customer", - label: "Customer onboarding documents", - entity: "customer", - fields: LEGACY_ONBOARDING_FIELDS, - }, - { - code: "company_onboarding_documents_forwarder", - label: "Forwarder onboarding documents", - entity: "other", - fields: LEGACY_ONBOARDING_FIELDS, - }, - { - code: "company_onboarding_documents_transporter", - label: "Transporter onboarding documents", - entity: "other", - fields: LEGACY_ONBOARDING_FIELDS, - }, - { - code: "company_onboarding_documents_forwarder_dj", - label: "Djibouti forwarder onboarding documents", - entity: "other", - fields: LEGACY_ONBOARDING_FIELDS, - }, + // Legacy per-company-type codes — removed, unused by any resolver or portal + // lookup (only `company_onboarding_documents_ethiopian`/`_foreign` are live). + // { + // code: "company_onboarding_documents_customer", + // label: "Customer onboarding documents", + // entity: "customer", + // fields: LEGACY_ONBOARDING_FIELDS, + // }, + // { + // code: "company_onboarding_documents_forwarder", + // label: "Forwarder onboarding documents", + // entity: "other", + // fields: LEGACY_ONBOARDING_FIELDS, + // }, + // { + // code: "company_onboarding_documents_transporter", + // label: "Transporter onboarding documents", + // entity: "other", + // fields: LEGACY_ONBOARDING_FIELDS, + // }, + // { + // code: "company_onboarding_documents_forwarder_dj", + // label: "Djibouti forwarder onboarding documents", + // entity: "other", + // fields: LEGACY_ONBOARDING_FIELDS, + // }, ]; const COMPANY_ONBOARDING_DESCRIPTION = @@ -224,7 +214,7 @@ const COMPANY_ONBOARDING_DESCRIPTION = // Two kinds of set per customs category: a CUSTOMER-INPUT set (the customer // uploads) and a GL-OUTPUT set (Global Logistics uploads the customs outputs). -const JPG_EXTENSIONS = ["jpg", "jpeg", "png", "pdf"]; +// const JPG_EXTENSIONS = ["jpg", "jpeg", "png", "pdf"]; // only used by the commented-out output field sets below const CLEARANCE_ENTITY = "booking_clearance"; /** Build a clearance field with sensible defaults; `critical` marks isRequired. */ @@ -293,24 +283,23 @@ const EXPORT_BULK_FIELDS: OnboardingField[] = [ clearanceField("port_invoice", "Port Invoice", 3), ]; -/** GL-uploaded customs output documents (import container). */ -const IMPORT_CONTAINER_OUTPUT_FIELDS: OnboardingField[] = [ - clearanceField("im4", "IM4 — Permanent Import Document", 1), - clearanceField("im5", "IM5 — Temporary Import Document", 2, { - required: false, - }), - clearanceField("transit_permitted", "Transit Permitted Screenshot", 3, { - extensions: JPG_EXTENSIONS, - }), -]; - -/** GL-uploaded customs output documents (export container). */ -const EXPORT_CONTAINER_OUTPUT_FIELDS: OnboardingField[] = [ - clearanceField("ex3", "EX3 — Permanent Export Document", 1), - clearanceField("ex8", "EX8 — Export Transit Document", 2), - clearanceField("export_release", "Export Release", 3), - clearanceField("t1", "T1 — Transport Document", 4), -]; +// GL-uploaded customs output documents — unused now that all +// clearance_output_*/contract_clearance_output_* settings are commented out. +// const IMPORT_CONTAINER_OUTPUT_FIELDS: OnboardingField[] = [ +// clearanceField("im4", "IM4 — Permanent Import Document", 1), +// clearanceField("im5", "IM5 — Temporary Import Document", 2, { +// required: false, +// }), +// clearanceField("transit_permitted", "Transit Permitted Screenshot", 3, { +// extensions: JPG_EXTENSIONS, +// }), +// ]; +// const EXPORT_CONTAINER_OUTPUT_FIELDS: OnboardingField[] = [ +// clearanceField("ex3", "EX3 — Permanent Export Document", 1), +// clearanceField("ex8", "EX8 — Export Transit Document", 2), +// clearanceField("export_release", "Export Release", 3), +// clearanceField("t1", "T1 — Transport Document", 4), +// ]; const CLEARANCE_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [ // ── Customer-input sets ── @@ -363,181 +352,194 @@ const CLEARANCE_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [ fields: EXPORT_BULK_FIELDS, }, // ── GL-output sets (customs only) ── - { - code: "clearance_output_import_container", - label: "Customs output documents (import container)", - entity: CLEARANCE_ENTITY, - fields: IMPORT_CONTAINER_OUTPUT_FIELDS, - }, - { - code: "clearance_output_export_container", - label: "Customs output documents (export container)", - entity: CLEARANCE_ENTITY, - fields: EXPORT_CONTAINER_OUTPUT_FIELDS, - }, + // Commented out per request. NOTE: these codes are still resolved and + // looked up at runtime by clearanceOutputSettingCode() in clearance.util.ts, + // called from booking-transition.service.ts (finalizeClearance, + // uploadClearanceOutputDocuments) and booking-clearance.service.ts — + // reachable from bookings.controller.ts's finalizeClearance/ + // uploadClearanceOutputDocuments endpoints. finalizeClearance does not + // catch getByCode()'s NotFoundException, so finalizing a customs-enabled + // booking will 500 once these rows are gone from the DB too. + // { + // code: "clearance_output_import_container", + // label: "Customs output documents (import container)", + // entity: CLEARANCE_ENTITY, + // fields: IMPORT_CONTAINER_OUTPUT_FIELDS, + // }, + // { + // code: "clearance_output_export_container", + // label: "Customs output documents (export container)", + // entity: CLEARANCE_ENTITY, + // fields: EXPORT_CONTAINER_OUTPUT_FIELDS, + // }, // Bulk output sets mirror the container output docs so customs+bulk bookings // can finalize (previously bulk had no output set and got stuck at finalize). - { - code: "clearance_output_import_bulk", - label: "Customs output documents (import bulk)", - entity: CLEARANCE_ENTITY, - fields: IMPORT_CONTAINER_OUTPUT_FIELDS, - }, - { - code: "clearance_output_export_bulk", - label: "Customs output documents (export bulk)", - entity: CLEARANCE_ENTITY, - fields: EXPORT_CONTAINER_OUTPUT_FIELDS, - }, + // { + // code: "clearance_output_import_bulk", + // label: "Customs output documents (import bulk)", + // entity: CLEARANCE_ENTITY, + // fields: IMPORT_CONTAINER_OUTPUT_FIELDS, + // }, + // { + // code: "clearance_output_export_bulk", + // label: "Customs output documents (export bulk)", + // entity: CLEARANCE_ENTITY, + // fields: EXPORT_CONTAINER_OUTPUT_FIELDS, + // }, ]; -// ── Contract pre-booking clearance settings (Path B) ──────────────────────── -// For customs-clearance contracts the customer uploads clearance documents on -// the CONTRACT (before any booking exists). Same customer doc sets as the legacy -// booking clearance, plus the GL output sets, keyed on the contract. Resolved by -// contract-clearance.util.ts (codes: contract_clearance_{op}_{freight} and -// contract_clearance_output_{op}_container). -const CONTRACT_CLEARANCE_ENTITY = "contract_clearance"; +// ── Contract pre-booking clearance settings ───────────────────────────────── +// Removed — clearance is now collected once, per booking, instead of also on +// the contract. See bookings/clearance.util.ts + CLEARANCE_DOCUMENT_SETTINGS. +// const CONTRACT_CLEARANCE_ENTITY = "contract_clearance"; -const CONTRACT_CLEARANCE_SETTINGS: OnboardingDocumentSetting[] = [ - { - code: "contract_clearance_import_container", - label: "Contract clearance documents (import container)", - entity: CONTRACT_CLEARANCE_ENTITY, - fields: IMPORT_CONTAINER_FIELDS, - }, - { - code: "contract_clearance_export_container", - label: "Contract clearance documents (export container)", - entity: CONTRACT_CLEARANCE_ENTITY, - fields: EXPORT_CONTAINER_FIELDS, - }, - { - code: "contract_clearance_import_bulk", - label: "Contract clearance documents (import bulk)", - entity: CONTRACT_CLEARANCE_ENTITY, - fields: IMPORT_BULK_FIELDS, - }, - { - code: "contract_clearance_export_bulk", - label: "Contract clearance documents (export bulk)", - entity: CONTRACT_CLEARANCE_ENTITY, - fields: EXPORT_BULK_FIELDS, - }, - // GL ET output sets uploaded during pre-booking clearance. - { - code: "contract_clearance_output_import_container", - label: "Contract customs output documents (import container)", - entity: CONTRACT_CLEARANCE_ENTITY, - fields: IMPORT_CONTAINER_OUTPUT_FIELDS, - }, - { - code: "contract_clearance_output_export_container", - label: "Contract customs output documents (export container)", - entity: CONTRACT_CLEARANCE_ENTITY, - fields: EXPORT_CONTAINER_OUTPUT_FIELDS, - }, - // Bulk output sets mirror the container output docs so customs+bulk contracts - // can finalize (previously bulk had no output set and got stuck at finalize). - { - code: "contract_clearance_output_import_bulk", - label: "Contract customs output documents (import bulk)", - entity: CONTRACT_CLEARANCE_ENTITY, - fields: IMPORT_CONTAINER_OUTPUT_FIELDS, - }, - { - code: "contract_clearance_output_export_bulk", - label: "Contract customs output documents (export bulk)", - entity: CONTRACT_CLEARANCE_ENTITY, - fields: EXPORT_CONTAINER_OUTPUT_FIELDS, - }, -]; +// Contract-level IMPORT/EXPORT clearance input codes — removed. Clearance is +// now collected once, per booking, via bookings/clearance.util.ts's 4+4 +// clearance_{op}_{freight}_{with|without}_customs codes. Kept here as +// reference in case a contract-level step is reinstated. +// const CONTRACT_CLEARANCE_SETTINGS: OnboardingDocumentSetting[] = [ +// { +// code: "contract_clearance_import_container_with_customs", +// label: "Contract clearance documents (import container, with customs)", +// entity: CONTRACT_CLEARANCE_ENTITY, +// fields: IMPORT_CONTAINER_FIELDS, +// }, +// { +// code: "contract_clearance_export_container_with_customs", +// label: "Contract clearance documents (export container, with customs)", +// entity: CONTRACT_CLEARANCE_ENTITY, +// fields: EXPORT_CONTAINER_FIELDS, +// }, +// { +// code: "contract_clearance_import_bulk_with_customs", +// label: "Contract clearance documents (import bulk, with customs)", +// entity: CONTRACT_CLEARANCE_ENTITY, +// fields: IMPORT_BULK_FIELDS, +// }, +// { +// code: "contract_clearance_export_bulk_with_customs", +// label: "Contract clearance documents (export bulk, with customs)", +// entity: CONTRACT_CLEARANCE_ENTITY, +// fields: EXPORT_BULK_FIELDS, +// }, +// ]; -// ── Path A self-clearance settings (no EDR customs service) ────────────────── -// When the contract does NOT bundle customs clearance, the customer clears the -// cargo himself and uploads his OWN clearance proof on the contract. Operations -// (not GL) reviews this smaller set before the customer may create the booking. -// Resolved by contract-clearance.util.ts as contract_clearance_selfclear_{op}_{freight}. -const SELF_CLEARANCE_IMPORT_FIELDS: OnboardingField[] = [ - clearanceField("customs_declaration", "Customs Declaration (IM4/IM5)", 1), - clearanceField("import_release", "Import Release Permit", 2), - clearanceField("duty_tax_receipt", "Duty & Tax Payment Receipt", 3, { - required: false, - }), - clearanceField("delivery_order", "Delivery Order", 4, { required: false }), - clearanceField("supporting_document", "Other Clearance Document", 5, { - required: false, - }), -]; +// GL ET output sets uploaded during pre-booking clearance — commented out +// per request. NOTE: still resolved/looked up at runtime by +// contractClearanceOutputSettingCode() in contract-clearance.util.ts, +// called from contract-clearance.service.ts (finalize, uploadOutputDocuments) +// — reachable from contracts.controller.ts. `finalize` does not catch +// getByCode()'s NotFoundException, so finalizing a customs-enabled contract +// will 500 once these rows are gone from the DB too. +// const CONTRACT_CLEARANCE_OUTPUT_SETTINGS: OnboardingDocumentSetting[] = [ +// { +// code: "contract_clearance_output_import_container", +// label: "Contract customs output documents (import container)", +// entity: CONTRACT_CLEARANCE_ENTITY, +// fields: IMPORT_CONTAINER_OUTPUT_FIELDS, +// }, +// { +// code: "contract_clearance_output_export_container", +// label: "Contract customs output documents (export container)", +// entity: CONTRACT_CLEARANCE_ENTITY, +// fields: EXPORT_CONTAINER_OUTPUT_FIELDS, +// }, +// { +// code: "contract_clearance_output_import_bulk", +// label: "Contract customs output documents (import bulk)", +// entity: CONTRACT_CLEARANCE_ENTITY, +// fields: IMPORT_CONTAINER_OUTPUT_FIELDS, +// }, +// { +// code: "contract_clearance_output_export_bulk", +// label: "Contract customs output documents (export bulk)", +// entity: CONTRACT_CLEARANCE_ENTITY, +// fields: EXPORT_CONTAINER_OUTPUT_FIELDS, +// }, +// ]; -const SELF_CLEARANCE_EXPORT_FIELDS: OnboardingField[] = [ - clearanceField("customs_declaration", "Customs Declaration (EX3/EX8)", 1), - clearanceField("export_release", "Export Release", 2), - clearanceField("transit_document", "Transit Document (T1)", 3, { - required: false, - }), - clearanceField("supporting_document", "Other Clearance Document", 4, { - required: false, - }), -]; - -const SELF_CLEARANCE_SETTINGS: OnboardingDocumentSetting[] = [ - { - code: "contract_clearance_selfclear_import_container", - label: "Self-clearance documents (import container)", - entity: CONTRACT_CLEARANCE_ENTITY, - fields: SELF_CLEARANCE_IMPORT_FIELDS, - }, - { - code: "contract_clearance_selfclear_export_container", - label: "Self-clearance documents (export container)", - entity: CONTRACT_CLEARANCE_ENTITY, - fields: SELF_CLEARANCE_EXPORT_FIELDS, - }, - { - code: "contract_clearance_selfclear_import_bulk", - label: "Self-clearance documents (import bulk)", - entity: CONTRACT_CLEARANCE_ENTITY, - fields: SELF_CLEARANCE_IMPORT_FIELDS, - }, - { - code: "contract_clearance_selfclear_export_bulk", - label: "Self-clearance documents (export bulk)", - entity: CONTRACT_CLEARANCE_ENTITY, - fields: SELF_CLEARANCE_EXPORT_FIELDS, - }, -]; +// ── Path A self-clearance settings — removed (contract-level clearance input +// codes no longer exist; see CONTRACT_CLEARANCE_SETTINGS above). Kept as +// reference in case a contract-level step is reinstated. +// const SELF_CLEARANCE_IMPORT_FIELDS: OnboardingField[] = [ +// clearanceField("customs_declaration", "Customs Declaration (IM4/IM5)", 1), +// clearanceField("import_release", "Import Release Permit", 2), +// clearanceField("duty_tax_receipt", "Duty & Tax Payment Receipt", 3, { +// required: false, +// }), +// clearanceField("delivery_order", "Delivery Order", 4, { required: false }), +// clearanceField("supporting_document", "Other Clearance Document", 5, { +// required: false, +// }), +// ]; +// const SELF_CLEARANCE_EXPORT_FIELDS: OnboardingField[] = [ +// clearanceField("customs_declaration", "Customs Declaration (EX3/EX8)", 1), +// clearanceField("export_release", "Export Release", 2), +// clearanceField("transit_document", "Transit Document (T1)", 3, { +// required: false, +// }), +// clearanceField("supporting_document", "Other Clearance Document", 4, { +// required: false, +// }), +// ]; +// const SELF_CLEARANCE_SETTINGS: OnboardingDocumentSetting[] = [ +// { +// code: "contract_clearance_import_container_without_customs", +// label: "Contract clearance documents (import container, without customs)", +// entity: CONTRACT_CLEARANCE_ENTITY, +// fields: SELF_CLEARANCE_IMPORT_FIELDS, +// }, +// { +// code: "contract_clearance_export_container_without_customs", +// label: "Contract clearance documents (export container, without customs)", +// entity: CONTRACT_CLEARANCE_ENTITY, +// fields: SELF_CLEARANCE_EXPORT_FIELDS, +// }, +// { +// code: "contract_clearance_import_bulk_without_customs", +// label: "Contract clearance documents (import bulk, without customs)", +// entity: CONTRACT_CLEARANCE_ENTITY, +// fields: SELF_CLEARANCE_IMPORT_FIELDS, +// }, +// { +// code: "contract_clearance_export_bulk_without_customs", +// label: "Contract clearance documents (export bulk, without customs)", +// entity: CONTRACT_CLEARANCE_ENTITY, +// fields: SELF_CLEARANCE_EXPORT_FIELDS, +// }, +// ]; // ── Contract intake settings ──────────────────────────────────────────────── // Commercial/framework documents attached at contract submission (wizard step 5), // distinct from the post-sign clearance docs above. const CONTRACT_INTAKE_ENTITY = "contract_intake"; -const CONTRACT_INTAKE_FIELDS: OnboardingField[] = [ - clearanceField( - "commercial_framework", - "Commercial Framework / Agreement", - 1, - { - required: false, - }, - ), - clearanceField("onboarding_attachment", "Onboarding Attachment", 2, { - required: false, - }), - clearanceField("supporting_document", "Supporting Document", 3, { - required: false, - }), -]; - -const CONTRACT_INTAKE_SETTINGS: OnboardingDocumentSetting[] = [ - { - code: "contract_intake_documents", - label: "Contract intake documents", - entity: CONTRACT_INTAKE_ENTITY, - fields: CONTRACT_INTAKE_FIELDS, - }, -]; +// Removed — unused. Nothing resolves/looks up `contract_intake_documents` by +// code anywhere in the API or web apps. +// const CONTRACT_INTAKE_FIELDS: OnboardingField[] = [ +// clearanceField( +// "commercial_framework", +// "Commercial Framework / Agreement", +// 1, +// { +// required: false, +// }, +// ), +// clearanceField("onboarding_attachment", "Onboarding Attachment", 2, { +// required: false, +// }), +// clearanceField("supporting_document", "Supporting Document", 3, { +// required: false, +// }), +// ]; +// const CONTRACT_INTAKE_SETTINGS: OnboardingDocumentSetting[] = [ +// { +// code: "contract_intake_documents", +// label: "Contract intake documents", +// entity: CONTRACT_INTAKE_ENTITY, +// fields: CONTRACT_INTAKE_FIELDS, +// }, +// ]; const CLEARANCE_DESCRIPTION = "Operation/clearance documents collected after contract execution, by operation, freight type and customs."; @@ -626,21 +628,6 @@ export class FileUploadSettingsSeeder { ...s, description: CLEARANCE_DESCRIPTION, })), - ...CONTRACT_CLEARANCE_SETTINGS.map((s) => ({ - ...s, - description: - "Pre-booking clearance documents collected on the contract (Path B), by operation and freight type.", - })), - ...SELF_CLEARANCE_SETTINGS.map((s) => ({ - ...s, - description: - "Customer self-clearance documents (Path A, no EDR customs service), reviewed by Operations.", - })), - ...CONTRACT_INTAKE_SETTINGS.map((s) => ({ - ...s, - description: - "Commercial/framework documents attached at contract submission.", - })), ...DRIVER_DOCUMENT_SETTINGS.map((s) => ({ ...s, description: diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index 9f7fd27bf..568b3a47a 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -94,6 +94,7 @@ "react-intersection-observer": "^9.16.0", "react-pdf": "^10.4.1", "react-pdf-html": "^2.1.5", + "react-quill-new": "^3.8.3", "react-resizable-panels": "^3.0.6", "react-router-dom": "^6.27.0", "react-signature-canvas": "1.1.0-alpha.2", diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 77e67f210..d523d2e8a 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -118,9 +118,12 @@ import TrainDetailPage from "./pages/trains/TrainDetailPage"; import TrainBuilderDetailPage from "./pages/trainBuilder/TrainBuilderDetailPage"; import TrainBuilderListPage from "./pages/trainBuilder/TrainBuilderListPage"; import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage"; +import EDRLastMileReturnsPage from "./pages/warehouses/EDRLastMileReturnsPage"; +import ContainerReturnsPage from "./pages/warehouses/ContainerReturnsPage"; import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage"; import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage"; import ExportWarehouseFlowPage from "./pages/warehouses/ExportWarehouseFlowPage"; +import ImportTrucksPage from "./pages/warehouses/ImportTrucksPage"; import ImportWarehouseFlowPage from "./pages/warehouses/ImportWarehouseFlowPage"; import InterchangeDocumentsPage from "./pages/warehouses/InterchangeDocumentsPage"; import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage"; @@ -404,6 +407,24 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.warehouseInventory.view, }, + { + label: "Import Trucks", + href: "/dashboard/import-trucks", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "EDR Last Mile Returns", + href: "/dashboard/edr-last-mile-returns", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Container Returns", + href: "/dashboard/container-returns", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, { label: "Dispatch Queue", href: "/dashboard/dispatch-queue", @@ -1052,6 +1073,9 @@ const App = () => { } /> } /> } /> + } /> + } /> + } /> } /> } /> (null); const [notes, setNotes] = useState(""); // The customer states the billing currency on their shipment request — GL @@ -578,6 +580,45 @@ export default function GlCreateBookingForm() { enabled: cargoQuery !== null && !isIntercity, }); + // EXPORT completes pick the TRAIN, not just the day (portal parity). Only + // when completing an initiated instance — a fresh GL create goes through + // clearance and picks its train there. + const isExportPick = + contract?.tradeDirection === "EXPORT" && Boolean(completeBookingId); + const wagonsEstimate = useMemo(() => { + if (!isContainer) return undefined; + const ft20 = containerLines + .filter((l) => parseInt(l.containerSize, 10) === 20) + .reduce((s, l) => s + Number(l.quantity || 0), 0); + const ft40 = containerLines + .filter((l) => parseInt(l.containerSize, 10) === 40) + .reduce((s, l) => s + Number(l.quantity || 0), 0); + const wagons = Math.ceil(ft20 / 2) + ft40; + return wagons > 0 ? wagons : undefined; + }, [isContainer, containerLines]); + const exportTrainsQuery = useQuery({ + ...api.trainScheduling.exportTrains.queryOptions({ + input: { + bookingId: completeBookingId ?? "", + date: scheduledDate, + cargo: { + containerSizes: isContainer + ? containerLines + .filter((l) => Number(l.quantity || 0) >= 1) + .map((l) => l.containerSize) + : undefined, + cargoTypeCode: !isContainer + ? (contract?.pricingBreakdown?.lineItems?.find( + (li) => li.cargoTypeCode, + )?.cargoTypeCode ?? undefined) + : undefined, + wagons: wagonsEstimate, + }, + }, + }), + enabled: isExportPick && Boolean(scheduledDate), + }); + /** * Line handling totals are a roll-up of the per-container switches — the * count is however many containers ticked each service. Recomputed on every @@ -846,6 +887,8 @@ export default function GlCreateBookingForm() { ...(scheduledDate ? { scheduledDate: new Date(scheduledDate).toISOString() } : {}), + // EXPORT rail: lock the booking onto the picked train. + ...(trainScheduleId ? { trainScheduleId } : {}), ...(notes.trim() ? { notes: notes.trim() } : {}), // Equipment return: WITH_RETURN contracts derive it server-side from the // per-line return quantities; only legacy contracts (no value chosen at @@ -1667,7 +1710,11 @@ export default function GlCreateBookingForm() { availableDays={availableDays ?? []} isLoading={daysLoading} value={scheduledDate} - onChange={setScheduledDate} + onChange={(d) => { + setScheduledDate(d); + // A new day invalidates the old train pick. + setTrainScheduleId(""); + }} /> {showErrors && dateError && ( @@ -1675,6 +1722,14 @@ export default function GlCreateBookingForm() { {dateError} )} + {isExportPick && scheduledDate ? ( + + ) : null} )} diff --git a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx index ff99be149..253d8aea5 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx @@ -59,6 +59,13 @@ const FIELD_LABELS: Record = { woreda: "Woreda", kebele: "Kebele", houseNo: "House no.", + statusDescription: "eTrade status", + dateRegistered: "Date registered", + renewedFrom: "Renewed from", + renewalDate: "Renewal date", + renewedTo: "Renewed to", + etradePhone: "eTrade phone", + ownerPassportNumber: "Owner passport number", }; /** Best-effort current value on the live company for a proposed field key. */ @@ -85,6 +92,74 @@ function currentValue(company: Company, key: string): string { return v === null || v === undefined || v === "" ? "—" : String(v); } +/** Subject a staged `snapshot.faydaIdentity` blob belongs to, from which of its `*FaydaSub` keys is present. */ +function faydaIdentitySubject( + snapshot: Record, +): "owner" | "poa" | null { + if ("ownerFaydaSub" in snapshot) return "owner"; + if ("poaFaydaSub" in snapshot) return "poa"; + return null; +} + +/** + * `stageIdentityChange` writes a nested `snapshot.faydaIdentity` object + * (attrs-key names like `ownerEmail`, not top-level DTO keys), so the generic + * `DiffRow` loop below can't render it — it would just stringify to + * `[object Object]`. Render it as its own before/after block instead, using + * the company's current `identity.owner`/`identity.poa` as the "before" side. + */ +function FaydaIdentityDiff({ + company, + snapshot, +}: { + company: Company; + snapshot: Record; +}) { + const subject = faydaIdentitySubject(snapshot); + if (!subject) return null; + const current = + subject === "owner" ? company.identity?.owner : company.identity?.poa; + const read = (key: string) => snapshot[`${subject}${key}`] as string | undefined; + const verifiedAt = read("FaydaVerifiedAt"); + const fields: { label: string; from?: string | null; to?: string }[] = [ + { label: "Name", from: current?.name, to: read("Name") }, + { label: "Email", from: current?.email, to: read("Email") }, + { label: "Phone", from: current?.phone, to: read("Phone") }, + { label: "Address", from: current?.address, to: read("Address") }, + ].filter((f) => f.to !== undefined); + + return ( + + + + {subject === "owner" ? "Owner re-verification" : "PoA re-verification"} + + {verifiedAt && ( + + Verified {formatDate(verifiedAt)} + + )} + + {fields.length > 0 ? ( + + {fields.map((f) => ( + + ))} + + ) : ( + + Identity re-verified — no name/email/phone/address change. + + )} + + ); +} + function DiffRow({ label, from, @@ -153,8 +228,11 @@ export function ChangeRequestReview({ company }: { company: Company }) { if (!pending && history.length === 0) return null; const proposedKeys = pending - ? Object.keys(pending.snapshot ?? {}) + ? Object.keys(pending.snapshot ?? {}).filter((k) => k !== "faydaIdentity") : ([] as string[]); + const faydaIdentitySnapshot = pending?.snapshot?.faydaIdentity as + | Record + | undefined; const docCount = pending?.documentFileIds?.length ?? 0; const licenseChanges = pending?.licenseChanges ?? []; const documentChanges = pending?.documentChanges ?? []; @@ -209,10 +287,14 @@ export function ChangeRequestReview({ company }: { company: Company }) { /> ))} - ) : ( + ) : !faydaIdentitySnapshot ? ( No field changes — document uploads only. + ) : null} + + {faydaIdentitySnapshot && ( + )} {documentChanges.length > 0 && ( diff --git a/apps/edr-freight-web/backoffice/src/components/operations/WarehouseGateTimesModal.tsx b/apps/edr-freight-web/backoffice/src/components/operations/WarehouseGateTimesModal.tsx new file mode 100644 index 000000000..6ab317271 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/operations/WarehouseGateTimesModal.tsx @@ -0,0 +1,156 @@ +import { + Button, + Divider, + Group, + Modal, + Stack, + Table, + Text, +} from '@mantine/core'; +import { DateTimePicker } from '@mantine/dates'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; + +import { useToast } from '@/hooks/use-toast'; +import { lastMileService, type LastMileRecord } from '@/services/last-mile.service'; + +interface WarehouseGateTimesModalProps { + opened: boolean; + onClose: () => void; + record: LastMileRecord | null; +} + +const plateOf = (a: NonNullable[number]) => + [a.vehicle?.code, a.vehicle?.plateNumber].filter(Boolean).join(' · ') || a.vehicleId; + +export function WarehouseGateTimesModal({ opened, onClose, record }: WarehouseGateTimesModalProps) { + const { toast } = useToast(); + const qc = useQueryClient(); + const id = record?.id ?? null; + const assignments = record?.vehicleAssignments ?? []; + + interface TruckRow { + vehicleId: string; + label: string; + arrivedAt: Date | null; + departedAt: Date | null; + } + + const [rows, setRows] = useState>([]); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (assignments.length > 0) { + setRows( + assignments.map((a) => ({ + vehicleId: a.vehicleId, + label: plateOf(a), + arrivedAt: a.arrivedAt ? new Date(a.arrivedAt) : null, + departedAt: a.departedAt ? new Date(a.departedAt) : null, + })), + ); + } + }, [assignments, opened]); + + const updateMutation = useMutation({ + mutationFn: () => { + if (!id) return Promise.resolve(null); + return lastMileService.setWarehouseGateTimes(id, rows.map(r => ({ + vehicleId: r.vehicleId, + arrivedAt: r.arrivedAt?.toISOString() ?? null, + departedAt: r.departedAt?.toISOString() ?? null, + }))); + }, + onSuccess: () => { + toast({ + title: 'Warehouse gate times updated', + }); + qc.invalidateQueries({ queryKey: ['last-mile-record-import-trucks', id] }); + onClose(); + }, + onError: (error: any) => { + toast({ + variant: 'destructive', + title: 'Failed to update warehouse gate times', + description: error?.response?.data?.message || error?.message, + }); + }, + onSettled: () => { + setSaving(false); + }, + }); + + const handleSave = async () => { + setSaving(true); + await updateMutation.mutateAsync(); + }; + + return ( + + + + Set arrival (gate-in) and departure (gate-out) times for each truck. + + + {/* @ts-ignore - DateTimePicker type inference issue with row state */} + + + + Plate + Arrived At (Gate-In) + Departed At (Gate-Out) + + + + {rows.map((row: TruckRow, idx: number) => ( + + + + {row.label} + + + + { + const newRows = [...rows]; + newRows[idx] = { ...row, arrivedAt: date }; + setRows(newRows); + }} + clearable + size="sm" + /> + + + { + const newRows = [...rows]; + newRows[idx] = { ...row, departedAt: date }; + setRows(newRows); + }} + clearable + size="sm" + /> + + + ))} + +
+ + + + + + + +
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/profile/MySignatureCard.tsx b/apps/edr-freight-web/backoffice/src/components/profile/MySignatureCard.tsx index 5f4f4d7a7..ca895b70b 100644 --- a/apps/edr-freight-web/backoffice/src/components/profile/MySignatureCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/profile/MySignatureCard.tsx @@ -5,6 +5,7 @@ import toast from "react-hot-toast"; import { api } from "@/services/api"; import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad"; +import { StampUpload } from "@/components/contracts/StampUpload"; import { Card, CardContent, @@ -40,6 +41,7 @@ export function MySignatureCard() { const [open, setOpen] = useState(false); const [signerName, setSignerName] = useState(""); const [signatureData, setSignatureData] = useState(null); + const [stampData, setStampData] = useState(null); const defaultName = user?.name?.en || user?.username || user?.email || ""; @@ -47,6 +49,7 @@ export function MySignatureCard() { const openDialog = () => { setSignerName(saved?.signerDisplayName ?? defaultName); setSignatureData(null); + setStampData(saved?.stampImageUrl ?? null); setOpen(true); }; @@ -56,6 +59,10 @@ export function MySignatureCard() { { signerDisplayName: signerName.trim(), signatureImageBase64: signatureData, + // Only send the stamp when it changed — omitted keeps the saved one. + ...(stampData && stampData !== saved?.stampImageUrl + ? { stampImageBase64: stampData } + : {}), }, { onSuccess: () => { @@ -101,6 +108,18 @@ export function MySignatureCard() { You have not saved a signature yet.

)} + {saved?.stampImageUrl && ( +
+
+ My saved company stamp +
+

Company stamp

+
+ )} @@ -126,6 +145,11 @@ export function MySignatureCard() { /> + @@ -429,7 +524,89 @@ function LimitGauge({ ); } -function WagonRow({ +/** Coupled row: trim checkbox (reason-badged when blocked) + switch picker. */ +function CoupledWagonRow({ + wagon, + checked, + editable, + switchValue, + switchOptions, + onToggleRemove, + onSwitch, +}: { + wagon: ConsistWagon; + checked: boolean; + editable: boolean; + switchValue: string | null; + switchOptions: Array<{ value: string; label: string }>; + onToggleRemove: (id: string, checked: boolean) => void; + onSwitch: (fromId: string, toId: string | null) => void; +}) { + const badge = wagon.loaded ? "Loaded" : !wagon.removable ? "Pinned" : null; + const checkbox = ( + onToggleRemove(wagon.id, e.currentTarget.checked)} + aria-label={`Trim wagon ${wagon.wagonNumber}`} + /> + ); + return ( + + {wagon.blockReason ? ( + + {checkbox} + + ) : ( + checkbox + )} + + + {wagon.wagonNumber} + + + {wagon.wagonType + ? `${wagon.wagonType.code} · ${wagon.wagonType.tareWeightTons}T tare · ${wagon.wagonType.lengthMeters}m` + : "Unknown type"} + + + {badge ? ( + + {badge} + + ) : null} + {editable && wagon.switchable && switchOptions.length ? ( +