From eab36a2526779917546f1cfca0c5e065db6d0eeb Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Sun, 6 Sep 2026 16:35:14 +0300 Subject: [PATCH 01/11] feat: add toggle to DJF --- .../3890000000000-AddDjfPaymentsEnabled.ts | 26 +++++++++ .../modules/billing/billing.service.spec.ts | 22 ++++---- .../src/modules/billing/billing.service.ts | 11 +++- .../contracts/booking-request.service.ts | 13 +++++ .../djf-currency-switch.spec.ts | 56 +++++++++++++++++++ .../dto/update-manual-payment-setting.dto.ts | 8 +++ .../entities/manual-payment-setting.entity.ts | 11 ++++ .../manual-payment-settings.controller.ts | 15 ++++- .../manual-payment-settings.service.ts | 39 ++++++++++++- .../contracts/GlCreateBookingForm.tsx | 8 ++- .../components/layout/sidebar-sections.tsx | 2 +- .../backoffice/src/constants/URLS.ts | 1 + .../src/hooks/useManualPaymentSettings.ts | 21 ++++++- .../src/pages/invoices/UsdPaymentsPage.tsx | 6 +- .../settings/ManualPaymentSettingsCard.tsx | 46 +++++++++++++-- .../services/manualPaymentSettings.service.ts | 19 ++++++- .../portal/src/hooks/usePaymentCurrencies.ts | 31 ++++++++++ .../components/PaymentMethodModal.tsx | 19 +++++-- .../payment-currency-field.tsx | 13 +++-- .../src/pages/contracts/NewShipmentPage.tsx | 6 +- .../contracts/NewShipmentRequestPage.tsx | 5 +- 21 files changed, 337 insertions(+), 41 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3890000000000-AddDjfPaymentsEnabled.ts create mode 100644 apps/edr-freight-api/src/modules/payment-settings/djf-currency-switch.spec.ts create mode 100644 apps/edr-freight-web/portal/src/hooks/usePaymentCurrencies.ts diff --git a/apps/edr-freight-api/src/migrations/3890000000000-AddDjfPaymentsEnabled.ts b/apps/edr-freight-api/src/migrations/3890000000000-AddDjfPaymentsEnabled.ts new file mode 100644 index 000000000..c8eb09978 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3890000000000-AddDjfPaymentsEnabled.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Adds the currency-level DJF switch to `manual_payment_settings`. + * + * Distinct from `djf_enabled`, which governs only the MANUAL rail: this one + * says whether DJF may be used as a payment currency at all — offered on the + * booking forms and accepted for online payment. Defaults to `true`, the + * behaviour before the switch existed. + */ +export class AddDjfPaymentsEnabled3890000000000 implements MigrationInterface { + name = 'AddDjfPaymentsEnabled3890000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.manual_payment_settings + ADD COLUMN IF NOT EXISTS djf_payments_enabled boolean NOT NULL DEFAULT true; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.manual_payment_settings DROP COLUMN IF EXISTS djf_payments_enabled; + `); + } +} 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 9a34516aa..647d9717a 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 @@ -82,7 +82,7 @@ describe("BillingService.generateInvoice", () => { {} as never, // invoiceDocuments {} as never, // files { get: () => undefined } as never, // config - { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings { directSend: jest.fn() } as never, // notifications { notify: jest.fn() } as never, // inbox ); @@ -167,7 +167,7 @@ describe("BillingService.issueMemo", () => { {} as never, {} as never, { get: () => undefined } as never, - { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings { directSend: jest.fn() } as never, // notifications { notify: jest.fn() } as never, // inbox ); @@ -304,7 +304,7 @@ describe("BillingService.markInvoiceAsPaid", () => { {} as never, // invoiceDocuments {} as never, // files { get: () => undefined } as never, // config - { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings { directSend: jest.fn() } as never, // notifications { notify: jest.fn() } as never, // inbox ); @@ -362,7 +362,7 @@ describe("BillingService.markInvoiceAsPaid", () => { {} as never, // invoiceDocuments {} as never, // files { get: () => undefined } as never, // config - { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings { directSend: jest.fn() } as never, // notifications { notify: jest.fn() } as never, // inbox ); @@ -410,7 +410,7 @@ describe("BillingService.settleByPaymentId", () => { {} as never, // invoiceDocuments {} as never, // files { get: () => undefined } as never, // config - { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings { directSend: jest.fn() } as never, // notifications { notify: jest.fn() } as never, // inbox ); @@ -526,7 +526,7 @@ describe("BillingService.recordPayment", () => { {} as never, // invoiceDocuments {} as never, // files { get: () => undefined } as never, // config - { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings { directSend: jest.fn() } as never, // notifications { notify: jest.fn() } as never, // inbox ); @@ -646,7 +646,7 @@ describe("BillingService.expirePayable — locked write runs in a transaction", {} as never, {} as never, {} as never, // config - { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings { directSend: jest.fn() } as never, // notifications { notify: jest.fn() } as never, // inbox ); @@ -722,7 +722,7 @@ describe("BillingService.issuePayable", () => { {} as never, {} as never, {} as never, // config - { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings { directSend: jest.fn() } as never, // notifications { notify: jest.fn() } as never, // inbox ); @@ -816,7 +816,7 @@ describe("BillingService — CAC Bank (OTP debit)", () => { {} as never, {} as never, {} as never, // config - { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings { directSend: jest.fn() } as never, // notifications { notify: jest.fn() } as never, // inbox ); @@ -902,7 +902,7 @@ describe("BillingService — CBE bill amounts carry cents, never rounded", () => {} as never, {} as never, {} as never, // config - { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings { directSend: jest.fn() } as never, // notifications { notify: jest.fn() } as never, // inbox ); @@ -978,7 +978,7 @@ describe("BillingService.document", () => { ? { tin: "0053481357", invoice: { sellerVatNumber: "43256663343256663322" } } : undefined, } as never, // config - { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings { directSend: jest.fn() } as never, // notifications { notify: jest.fn() } as never, // inbox ); 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 ce8c95f40..42a5baf34 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -806,7 +806,7 @@ export class BillingService { // settle by hand in a currency whose channel is switched off. if (!(await this.manualPaymentSettings.isEnabled(invoice.currency))) { throw new BadRequestException( - `Manual payment is disabled for ${invoice.currency ?? "this"} invoices. Enable it in Configuration → Manual payments first.`, + `Manual payment is disabled for ${invoice.currency ?? "this"} invoices. Enable it in Configuration → Payments first.`, ); } if (!file) { @@ -2297,6 +2297,15 @@ export class BillingService { ); } + // DJF switched off as a payment currency: the online rails (Waafi / CAC + // Bank) stop taking it. The invoice itself is untouched — Finance can + // still settle it by hand while the DJF manual channel is on. + if (!(await this.manualPaymentSettings.isCurrencyOffered(invoice.currency))) { + throw new BadRequestException( + `${invoice.currency} payments are switched off. Enable them in Configuration → Payments first.`, + ); + } + // A booking's PREPAID invoice is only payable inside its pay window — // `dueAt` mirrors booking.paymentDeadline (issuePayable at reserve time). // Blocking INITIATION here is what makes the deadline real: a payment diff --git a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts index a2900174a..d0f9ee7a4 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts @@ -7,6 +7,7 @@ import { } from '@nestjs/common'; import type { Freight } from '@edr/types'; +import { ManualPaymentSettingsService } from '../payment-settings/manual-payment-settings.service'; import { YardScopeService } from '../rule-engine/services/yard-scope.service'; import { BookingRequestRepository } from './booking-request.repository'; import { ContractsService } from './contracts.service'; @@ -30,6 +31,7 @@ export class BookingRequestService { private readonly contractBookingService: ContractBookingService, private readonly notifier: ContractNotifierService, private readonly yardScope: YardScopeService, + private readonly paymentSettings: ManualPaymentSettingsService, ) {} /** @@ -101,6 +103,17 @@ export class BookingRequestService { } } } + // The currency picker hides a switched-off currency, but a stale tab must + // not be able to raise a shipment nobody can pay for. + if ( + dto.paymentCurrency && + !(await this.paymentSettings.isCurrencyOffered(dto.paymentCurrency)) + ) { + throw new BadRequestException( + `${dto.paymentCurrency.toUpperCase()} is not accepted as a billing currency right now. Pick another currency.`, + ); + } + await this.contractBookingService.assertRequestWithinCapacity(contract, { containers: dto.containers, bulk: dto.bulk, diff --git a/apps/edr-freight-api/src/modules/payment-settings/djf-currency-switch.spec.ts b/apps/edr-freight-api/src/modules/payment-settings/djf-currency-switch.spec.ts new file mode 100644 index 000000000..d1f1a9baa --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment-settings/djf-currency-switch.spec.ts @@ -0,0 +1,56 @@ +import { ManualPaymentSettingsService } from "./manual-payment-settings.service"; +import type { ManualPaymentSetting } from "./entities/manual-payment-setting.entity"; + +/** Single-row repository stub: enough for get/update, nothing more. */ +const repoWith = (row: Partial) => { + const stored = { id: "settings-1", ...row } as ManualPaymentSetting; + return { + findOne: async () => stored, + create: (v: Partial) => v as ManualPaymentSetting, + save: async (v: ManualPaymentSetting) => v, + update: async (_id: string, patch: Partial) => { + Object.assign(stored, patch); + }, + }; +}; + +const serviceWith = (row: Partial) => + new ManualPaymentSettingsService(repoWith(row) as never); + +describe("DJF currency switch", () => { + it("offers DJF, and accepts it, while the switch is on", async () => { + const service = serviceWith({ djfPaymentsEnabled: true }); + + await expect(service.offeredCurrencies()).resolves.toEqual([ + "ETB", + "USD", + "DJF", + ]); + await expect(service.isCurrencyOffered("DJF")).resolves.toBe(true); + }); + + it("drops DJF from the offered currencies once switched off", async () => { + const service = serviceWith({ djfPaymentsEnabled: false }); + + await expect(service.offeredCurrencies()).resolves.toEqual(["ETB", "USD"]); + await expect(service.isCurrencyOffered("djf")).resolves.toBe(false); + }); + + it("never switches off ETB or USD — only DJF has a currency-level switch", async () => { + const service = serviceWith({ djfPaymentsEnabled: false }); + + await expect(service.isCurrencyOffered("ETB")).resolves.toBe(true); + await expect(service.isCurrencyOffered("USD")).resolves.toBe(true); + }); + + it("leaves the manual rail alone when the currency switch flips", async () => { + const service = serviceWith({ djfEnabled: true, djfPaymentsEnabled: true }); + + const updated = await service.update({ djfPaymentsEnabled: false }, "user-1"); + + expect(updated.djfPaymentsEnabled).toBe(false); + // Existing DJF invoices stay hand-settleable, so nothing is stranded. + expect(updated.djfEnabled).toBe(true); + await expect(service.isEnabled("DJF")).resolves.toBe(true); + }); +}); diff --git a/apps/edr-freight-api/src/modules/payment-settings/dto/update-manual-payment-setting.dto.ts b/apps/edr-freight-api/src/modules/payment-settings/dto/update-manual-payment-setting.dto.ts index 39d4757e7..0672d573e 100644 --- a/apps/edr-freight-api/src/modules/payment-settings/dto/update-manual-payment-setting.dto.ts +++ b/apps/edr-freight-api/src/modules/payment-settings/dto/update-manual-payment-setting.dto.ts @@ -20,4 +20,12 @@ export class UpdateManualPaymentSettingDto { @IsOptional() @IsBoolean() djfEnabled?: boolean; + + @ApiPropertyOptional({ + description: + "Accept DJF as a payment currency at all — booking forms and online payment", + }) + @IsOptional() + @IsBoolean() + djfPaymentsEnabled?: boolean; } diff --git a/apps/edr-freight-api/src/modules/payment-settings/entities/manual-payment-setting.entity.ts b/apps/edr-freight-api/src/modules/payment-settings/entities/manual-payment-setting.entity.ts index 862456241..d201bb07e 100644 --- a/apps/edr-freight-api/src/modules/payment-settings/entities/manual-payment-setting.entity.ts +++ b/apps/edr-freight-api/src/modules/payment-settings/entities/manual-payment-setting.entity.ts @@ -24,6 +24,17 @@ export class ManualPaymentSetting extends BaseEntity { @Column({ name: "djf_enabled", type: "boolean", default: true }) djfEnabled!: boolean; + /** + * Whether DJF may be used as a payment currency AT ALL — offered on the + * booking/shipment forms and accepted for online payment (Waafi / CAC Bank). + * + * Wider than `djfEnabled`, which only governs the manual rail. Off leaves + * existing DJF invoices settleable by hand (while `djfEnabled` is on), so + * switching it off strands nothing — it only stops new DJF business. + */ + @Column({ name: "djf_payments_enabled", type: "boolean", default: true }) + djfPaymentsEnabled!: boolean; + /** IAM user id of the last operator to change either toggle. */ @Column({ name: "updated_by_id", type: "uuid", nullable: true }) updatedById?: string | null; diff --git a/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.controller.ts b/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.controller.ts index 786d1f7ca..6b29ee226 100644 --- a/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.controller.ts +++ b/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.controller.ts @@ -30,13 +30,26 @@ export class ManualPaymentSettingsController { return this.service.get(); } + /** + * Which currencies may be picked for new bookings and paid online. Read by + * the customer portal's booking forms, so it stays open like the other + * form-shaping settings reads (file-upload / dropdown settings) — it exposes + * nothing beyond what the currency picker already shows. + */ + @Get("currencies") + @ApiOperation({ summary: "Currencies customers may be billed and pay in" }) + currencies() { + return this.service.offeredCurrencies(); + } + @Patch() @BookingStaff([ FREIGHT_PERMS.settings.manualPayment.manage, FREIGHT_PERMS.admin, ]) @ApiOperation({ - summary: "Enable or disable manual invoice settlement for ETB and/or USD", + summary: + "Enable or disable manual invoice settlement per currency, and whether DJF is accepted at all", }) update( @Body() dto: UpdateManualPaymentSettingDto, diff --git a/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.service.ts b/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.service.ts index dc397cf79..50d05a7f9 100644 --- a/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.service.ts +++ b/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.service.ts @@ -37,7 +37,12 @@ export class ManualPaymentSettingsService { if (existing) return existing; return this.repository.save( - this.repository.create({ etbEnabled: false, usdEnabled: true, djfEnabled: true }), + this.repository.create({ + etbEnabled: false, + usdEnabled: true, + djfEnabled: true, + djfPaymentsEnabled: true, + }), ); } @@ -60,9 +65,34 @@ export class ManualPaymentSettingsService { return setting[field]; } + /** + * Currencies customers may be billed and pay in right now. ETB and USD are + * always offered; DJF only while its currency-level switch is on. Read by + * the booking forms (portal and backoffice) to decide which options to show. + */ + async offeredCurrencies(): Promise { + const setting = await this.get(); + return setting.djfPaymentsEnabled ? ["ETB", "USD", "DJF"] : ["ETB", "USD"]; + } + + /** + * Whether a currency may be used for NEW business and online payment — the + * currency-level switch, not the manual rail's {@link isEnabled}. Only DJF + * is switchable; ETB and USD have no off switch. + */ + async isCurrencyOffered(currency: string | null | undefined): Promise { + if (currency?.toUpperCase() !== "DJF") return true; + return (await this.get()).djfPaymentsEnabled; + } + /** Flip any toggle; an omitted field leaves that currency unchanged. */ async update( - patch: { etbEnabled?: boolean; usdEnabled?: boolean; djfEnabled?: boolean }, + patch: { + etbEnabled?: boolean; + usdEnabled?: boolean; + djfEnabled?: boolean; + djfPaymentsEnabled?: boolean; + }, updatedById?: string | null, ): Promise { const current = await this.get(); @@ -70,11 +100,14 @@ export class ManualPaymentSettingsService { ...(patch.etbEnabled === undefined ? {} : { etbEnabled: patch.etbEnabled }), ...(patch.usdEnabled === undefined ? {} : { usdEnabled: patch.usdEnabled }), ...(patch.djfEnabled === undefined ? {} : { djfEnabled: patch.djfEnabled }), + ...(patch.djfPaymentsEnabled === undefined + ? {} + : { djfPaymentsEnabled: patch.djfPaymentsEnabled }), updatedById: updatedById ?? null, }); const updated = await this.get(); this.logger.warn( - `Manual payment channels set to ETB=${updated.etbEnabled} USD=${updated.usdEnabled} DJF=${updated.djfEnabled} by ${updatedById ?? "unknown user"}`, + `Manual payment channels set to ETB=${updated.etbEnabled} USD=${updated.usdEnabled} DJF=${updated.djfEnabled} (DJF accepted as a currency: ${updated.djfPaymentsEnabled}) by ${updatedById ?? "unknown user"}`, ); return updated; } diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index 308867caa..419d73f42 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -65,6 +65,7 @@ import { type ConsolidationCandidate, } from "@/services/contracts.service"; import { bookingsService } from "@/services/bookings.service"; +import { usePaymentCurrenciesQuery } from "@/hooks/useManualPaymentSettings"; import { useContractCapacity, useContractDetail, @@ -346,6 +347,9 @@ export default function GlCreateBookingForm() { // IMPORT bookings pick ETB or USD — starts empty so the choice is // deliberate (required before pricing). Everything else is forced to ETB. const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB" | "DJF" | "">(""); + const { data: offeredCurrencies } = usePaymentCurrenciesQuery(); + // Undefined while the read is in flight — assume on, the switch is rarely off. + const djfOffered = offeredCurrencies ? offeredCurrencies.includes("DJF") : true; // What the containers carry — captured per booking (moved off the contract). const [cargoDescription, setCargoDescription] = useState(""); const [containerLines, setContainerLines] = useState([]); @@ -2359,7 +2363,9 @@ export default function GlCreateBookingForm() { onChange={setPaymentCurrency} disabled={!isImport || requestCurrencyLocked} allowUsd={isImport} - allowDjf={isImport} + // A currency switched off in Configuration → Payments is not + // offered, but one the customer already chose stays visible. + allowDjf={isImport && (djfOffered || paymentCurrency === "DJF")} error={currencyError} /> diff --git a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx index 4672efa1a..8bb62412e 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx @@ -612,7 +612,7 @@ export const buildSidebarSections = ( permission: FREIGHT_PERMS.settings.operationsStandards.view, }, { - label: "Manual payments", + label: "Payments", href: "/dashboard/configuration/manual-payments", permission: FREIGHT_PERMS.settings.manualPayment.view, }, diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index ae972022e..ad55e03b7 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -63,6 +63,7 @@ export const URL_CONSTANTS = { MANUAL_PAYMENT_SETTINGS: { BASE: "/payment-settings/manual", + CURRENCIES: "/payment-settings/manual/currencies", }, AUDIT_LOGS: { diff --git a/apps/edr-freight-web/backoffice/src/hooks/useManualPaymentSettings.ts b/apps/edr-freight-web/backoffice/src/hooks/useManualPaymentSettings.ts index cbb5f5996..fddb42240 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/useManualPaymentSettings.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/useManualPaymentSettings.ts @@ -9,6 +9,18 @@ import { import { useErrorHandler } from "@/shared/hooks/useErrorHandler"; export const MANUAL_PAYMENT_SETTINGS_KEY = ["manualPaymentSettings"]; +export const PAYMENT_CURRENCIES_KEY = ["paymentCurrencies"]; + +/** + * Currencies a booking may be billed in right now. Open read, so forms can + * hide a switched-off currency without needing the settings permission. + */ +export const usePaymentCurrenciesQuery = () => + useQuery({ + queryKey: PAYMENT_CURRENCIES_KEY, + queryFn: () => manualPaymentSettingsService.currencies(), + staleTime: 60_000, + }); export const useManualPaymentSettingsQuery = () => useQuery({ @@ -25,13 +37,18 @@ export const useUpdateManualPaymentSettings = () => { return useMutation({ mutationFn: ( patch: Partial< - Pick + Pick< + ManualPaymentSettings, + "etbEnabled" | "usdEnabled" | "djfEnabled" | "djfPaymentsEnabled" + > >, ) => manualPaymentSettingsService.update(patch), onSuccess: (data) => { queryClient.setQueryData(MANUAL_PAYMENT_SETTINGS_KEY, data); - // The Manual Payments worklist only lists enabled currencies. + // The Manual Payments worklist only lists enabled currencies, and the + // booking forms only offer currencies that are switched on. queryClient.invalidateQueries({ queryKey: ["invoices"] }); + queryClient.invalidateQueries({ queryKey: PAYMENT_CURRENCIES_KEY }); toast.success( t("manualPaymentSettings.updated", "Manual payment settings updated"), ); diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx index eeceae91b..92202c4c5 100644 --- a/apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx @@ -291,8 +291,8 @@ export default function UsdPaymentsPanel({ const { user } = useAuth(); const canConfirm = hasPermission(user, FREIGHT_PERMS.invoices.confirmOffline); - // Manual settlement is switched on per currency in Configuration → Manual - // payments. FinanceHubPage hides the tab for a disabled currency; this is + // Manual settlement is switched on per currency in Configuration → + // Payments. FinanceHubPage hides the tab for a disabled currency; this is // the fallback for a direct `?tab=` link, and the API refuses regardless. const { data: manualSettings } = useManualPaymentSettingsQuery(); const currencyEnabled = manualSettings @@ -549,7 +549,7 @@ export default function UsdPaymentsPanel({ onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)} emptyMessage={ !currencyEnabled - ? `Manual payment is switched off for ${currency} invoices. Enable it in Configuration → Manual payments.` + ? `Manual payment is switched off for ${currency} invoices. Enable it in Configuration → Payments.` : controls.activeCount > 0 ? "No invoices match these filters." : `No ${currency} invoices awaiting manual payment confirmation.` diff --git a/apps/edr-freight-web/backoffice/src/pages/settings/ManualPaymentSettingsCard.tsx b/apps/edr-freight-web/backoffice/src/pages/settings/ManualPaymentSettingsCard.tsx index d7386259a..0edea429a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/settings/ManualPaymentSettingsCard.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/settings/ManualPaymentSettingsCard.tsx @@ -8,7 +8,7 @@ import { import { Badge } from "@/shared/common/ui/badge"; import { Switch } from "@/shared/common/ui/switch"; import { Skeleton } from "@/shared/common/ui/skeleton"; -import { AlertTriangle, Banknote, Landmark } from "lucide-react"; +import { AlertTriangle, Banknote, Coins, Landmark } from "lucide-react"; import { useAuth } from "@/auth/useAuth"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; @@ -53,7 +53,8 @@ const CURRENCIES: { ]; /** - * Switches the manual (offline) payment channel on or off per currency. + * Two levels of switch: whether DJF is accepted as a payment currency at all, + * and whether the manual (offline) channel is open, per currency. * * Off means gone, not greyed out: the Manual Payments worklist lists only * enabled currencies, and the API refuses a confirmation in a disabled one — @@ -75,11 +76,12 @@ export default function ManualPaymentSettingsCard() { return ( - Manual payments + Payments - Whether Finance staff may mark invoices as paid by hand, from - Invoices → Manual Payments. Each currency is switched separately. - Confirming still requires the payment slip and the booking's pay + Which currencies customers may be billed in, and whether Finance + staff may mark invoices as paid by hand from Invoices → Manual + Payments. Each currency is switched separately. Confirming a manual + payment still requires the payment slip and the booking's pay window to be open. @@ -95,6 +97,38 @@ export default function ManualPaymentSettingsCard() { )} + {isLoading || !data ? ( + + ) : ( +
+
+
+ +

Accept Djibouti Franc (DJF)

+ + {data.djfPaymentsEnabled ? "Enabled" : "Disabled"} + +
+

+ Whether DJF is offered as a billing currency on new bookings and + shipment requests, and whether DJF invoices can be paid online + (Waafi / CAC Bank). Switching this off stops new DJF business — + invoices already in DJF stay settleable by hand below. +

+
+ + update.mutate({ djfPaymentsEnabled: checked }) + } + /> +
+ )} + +

Manual (offline) settlement

+ {isLoading || !data ? CURRENCIES.map((c) => ( diff --git a/apps/edr-freight-web/backoffice/src/services/manualPaymentSettings.service.ts b/apps/edr-freight-web/backoffice/src/services/manualPaymentSettings.service.ts index ffe5281ec..d231eac98 100644 --- a/apps/edr-freight-web/backoffice/src/services/manualPaymentSettings.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/manualPaymentSettings.service.ts @@ -14,6 +14,12 @@ export interface ManualPaymentSettings { etbEnabled: boolean; usdEnabled: boolean; djfEnabled: boolean; + /** + * Wider than `djfEnabled`: whether DJF is accepted as a payment currency at + * all — offered on the booking forms and payable online. Off leaves existing + * DJF invoices settleable by hand. + */ + djfPaymentsEnabled: boolean; updatedById: string | null; updatedAt?: string; } @@ -24,10 +30,21 @@ export const manualPaymentSettingsService = { return unwrap(response.data); }, + /** Currencies customers may be billed and pay in — open read, no permission. */ + currencies: async (): Promise => { + const response = await client.get>( + URL_CONSTANTS.MANUAL_PAYMENT_SETTINGS.CURRENCIES, + ); + return unwrap(response.data); + }, + /** Partial: an omitted currency keeps its current setting. */ update: async ( patch: Partial< - Pick + Pick< + ManualPaymentSettings, + "etbEnabled" | "usdEnabled" | "djfEnabled" | "djfPaymentsEnabled" + > >, ): Promise => { const response = await client.patch>( diff --git a/apps/edr-freight-web/portal/src/hooks/usePaymentCurrencies.ts b/apps/edr-freight-web/portal/src/hooks/usePaymentCurrencies.ts new file mode 100644 index 000000000..d09e7201a --- /dev/null +++ b/apps/edr-freight-web/portal/src/hooks/usePaymentCurrencies.ts @@ -0,0 +1,31 @@ +import { useQuery } from "@tanstack/react-query"; + +import { client } from "@/utils/api"; +import { unwrap } from "@/utils/endpoint"; +import type { ApiResponse } from "@/types/apiResponse"; + +/** + * Currencies a booking may be billed and paid in right now — DJF drops out + * when staff switch it off in Configuration → Payments. One open GET, so it + * is fetched here rather than through a service file of its own. + */ +export const PAYMENT_CURRENCIES_KEY = ["payment-currencies"] as const; + +export function usePaymentCurrencies() { + const { data } = useQuery({ + queryKey: PAYMENT_CURRENCIES_KEY, + queryFn: async () => { + const response = await client.get>( + "/api/payment-settings/manual/currencies", + ); + return unwrap(response.data); + }, + staleTime: 60_000, + }); + + return { + currencies: data, + /** In flight or failed → assume on; the switch is off only by exception. */ + djfEnabled: data ? data.includes("DJF") : true, + }; +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx index 24f817f14..8e8adf99a 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx @@ -13,6 +13,7 @@ import { Check, Landmark, ShieldCheck } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import type { InvoicePaymentFlow } from "@/hooks/useInvoicePayment"; +import { usePaymentCurrencies } from "@/hooks/usePaymentCurrencies"; import { PayerAccountNote } from "@/pages/bookings/payments/PayerAccountNote"; import type { PaymentMethod } from "@/services/payments.service"; @@ -207,13 +208,21 @@ export function PaymentMethodModal({ /** CBE bill-reference step, from `useInvoicePayment`. Omit to disable CBE_BILL. */ bill?: InvoicePaymentFlow["bill"]; }) { + // DJF switched off in Configuration → Payments closes its online rails; the + // API refuses the charge too, so offering them would only fail at the gate. + const { djfEnabled } = usePaymentCurrencies(); + const currencyOff = + !djfEnabled && currency?.trim().toUpperCase() === "DJF"; const providers = useMemo( () => - providersForCurrency(currency).filter( - (p) => - (otp || !isOtpMethod(p.method)) && (bill || !isBillMethod(p.method)), - ), - [currency, otp, bill], + currencyOff + ? [] + : providersForCurrency(currency).filter( + (p) => + (otp || !isOtpMethod(p.method)) && + (bill || !isBillMethod(p.method)), + ), + [currency, otp, bill, currencyOff], ); const [method, setMethod] = useState(providers[0]?.method ?? PROVIDERS[0].method); const [mobile, setMobile] = useState(""); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/payment-currency-field.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/payment-currency-field.tsx index 9cee483db..b7461f5ae 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/payment-currency-field.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/payment-currency-field.tsx @@ -7,6 +7,7 @@ import { type BookingFormValues, type PaymentCurrency, } from "./schema"; +import { usePaymentCurrencies } from "@/hooks/usePaymentCurrencies"; import { OptionFieldError, StepLabel } from "./shared"; const CURRENCY_ICONS: Record< @@ -29,10 +30,14 @@ export function PaymentCurrencyField({ */ allowUsd?: boolean; }) { - // DJF is offered wherever USD is — both are import-shipment-only currencies. - const options = allowUsd - ? PAYMENT_CURRENCY_OPTIONS - : PAYMENT_CURRENCY_OPTIONS.filter((o) => o.value === "ETB"); + // DJF is offered wherever USD is — both are import-shipment-only currencies + // — and only while staff keep it switched on in Configuration → Payments. + const { djfEnabled } = usePaymentCurrencies(); + const options = ( + allowUsd + ? PAYMENT_CURRENCY_OPTIONS + : PAYMENT_CURRENCY_OPTIONS.filter((o) => o.value === "ETB") + ).filter((o) => o.value !== "DJF" || djfEnabled); return ( Payment currency diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx index 3cc75e221..8019a76b6 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -56,6 +56,8 @@ import { ExportTrainPicker, OperationDatePicker, } from "@edr/ui-common"; + +import { usePaymentCurrencies } from "@/hooks/usePaymentCurrencies"; import { api } from "@/services/api"; import { contractsService, @@ -1318,6 +1320,7 @@ function ScheduleStep({ }) { const contractRouteId = form.watch("contractRouteId"); const route = routes.find((r) => r.id === contractRouteId) ?? routes[0]; + const { djfEnabled } = usePaymentCurrencies(); // Read the cargo entered in the previous step so the day list reflects what // can actually be shipped (matching wagons + open train capacity). @@ -1474,7 +1477,8 @@ function ScheduleStep({ onChange={(v) => field.onChange(v)} error={fieldState.error?.message} allowUsd={isImport} - allowDjf={isImport} + // Hidden once staff switch DJF off, unless it is already picked. + allowDjf={isImport && (djfEnabled || field.value === "DJF")} /> )} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx index 29e7406ef..3d3e6777d 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx @@ -15,6 +15,8 @@ import { Title, } from "@mantine/core"; import { CurrencySelector } from "@edr/ui-common"; + +import { usePaymentCurrencies } from "@/hooks/usePaymentCurrencies"; import { AlertCircle, ArrowLeft, CalendarDays, Send } from "lucide-react"; import toast from "react-hot-toast"; import type { Freight } from "@edr/types"; @@ -39,6 +41,7 @@ export default function NewShipmentRequestPage() { // Starts empty so the billing-currency choice is deliberate — required at // submit. Intercity/export are forced to ETB (server-enforced too). const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB" | "DJF" | "">(""); + const { djfEnabled } = usePaymentCurrencies(); const [currencyError, setCurrencyError] = useState(); const [notes, setNotes] = useState(""); @@ -267,7 +270,7 @@ export default function NewShipmentRequestPage() { }} disabled={isIntercity || isExport} allowUsd={!isIntercity && !isExport} - allowDjf={!isIntercity && !isExport} + allowDjf={!isIntercity && !isExport && djfEnabled} error={currencyError} /> From f65f541238e6989f13744d5fb9a1f8925676dd75 Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Sun, 6 Sep 2026 18:44:58 +0300 Subject: [PATCH 02/11] Update .gitignore --- .gitignore | 3 --- 1 file changed, 3 deletions(-) diff --git a/.gitignore b/.gitignore index cf36ca979..f3dd54fba 100644 --- a/.gitignore +++ b/.gitignore @@ -63,6 +63,3 @@ integration/.it-shards.yaml *.crt secrets/ certs/ -branch_structure.json -temp_auto_push.bat -temp_interactive_push.bat From 116a2a7b89136a6a1f999a9fe308af1a6d13884a Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Sun, 6 Sep 2026 22:15:06 +0300 Subject: [PATCH 03/11] fix --- .../src/components/bookings/OperationRescheduleModal.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/OperationRescheduleModal.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/OperationRescheduleModal.tsx index 8fa1d5c46..0e31ceff4 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/OperationRescheduleModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/OperationRescheduleModal.tsx @@ -22,6 +22,11 @@ import { useBookingDetail, useBookingMutations, } from "@/hooks/bookings/useBookings"; +import { + eatDay, + exportTrainOption, + isExportRailBooking, +} from "@/features/bookings/shipmentDay"; export interface OperationRescheduleModalProps { bookingId: string; From eeac88f3be6c798a04d8b08fc2a24b7c591f3df8 Mon Sep 17 00:00:00 2001 From: hager Date: Sun, 6 Sep 2026 19:37:02 +0000 Subject: [PATCH 04/11] fix(freight-backoffice): identify yards by name rather than code Operators know the yards by name, not by the internal code: the yard coded KALITY is universally called GMP / Gelan Multipurpose Port (Indode), so rendering the code alongside the name read as two different places. Show the name alone in route creation, the yard desks modal and the booking route card, falling back to the code only when a yard has no name. The code is unchanged in the data and remains searchable. Co-Authored-By: Claude Opus 5 --- .../components/bookings/detail/BookingRouteCard.tsx | 10 +++------- .../backoffice/src/pages/fleet/RoutesPage.tsx | 7 +++++-- .../backoffice/src/pages/ruleEngine/YardDesksModal.tsx | 2 +- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteCard.tsx index 05fa58100..76478bc4f 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteCard.tsx @@ -17,9 +17,8 @@ export function BookingRouteCard({ booking }: BookingRouteCardProps) { Origin - {booking.originYard?.label} - - {booking.originYard?.code} + + {booking.originYard?.label ?? booking.originYard?.code} @@ -57,10 +56,7 @@ export function BookingRouteCard({ booking }: BookingRouteCardProps) { Destination - {booking.destinationYard?.label} - - - {booking.destinationYard?.code} + {booking.destinationYard?.label ?? booking.destinationYard?.code} diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx index 6a70596aa..ec31e2f03 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx @@ -67,8 +67,11 @@ const emptyForm = (): RouteFormState => ({ /** Order-insensitive pair key — yard distances are symmetric. */ const pairKey = (a: string, b: string) => (a < b ? `${a}|${b}` : `${b}|${a}`); +// Yards are identified to operators by name, not by their internal code: the +// yard coded KALITY is universally called GMP / Gelan Multipurpose Port, and +// showing both read as two different places. The code stays in the data. const yardLabel = (yard?: YardRef | null) => - yard ? `${yard.label} (${yard.code})` : "—"; + yard ? (yard.label ?? yard.code) : "—"; const statusColor = (status: RouteStatus) => { switch (status) { @@ -232,7 +235,7 @@ export default function RoutesPage() { () => (yardsQuery.data ?? []).map((yard) => ({ value: yard.id, - label: `${yard.label} (${yard.code})`, + label: yard.label ?? yard.code, })), [yardsQuery.data], ); diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/YardDesksModal.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/YardDesksModal.tsx index 5a9b6ccf6..18aa450ff 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/YardDesksModal.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/YardDesksModal.tsx @@ -83,7 +83,7 @@ export function YardDesksModal({ From c2fb320709c40a7049385e617ad780459fa11a4a Mon Sep 17 00:00:00 2001 From: hager Date: Sun, 6 Sep 2026 19:42:59 +0000 Subject: [PATCH 05/11] feat(warehouses): receive an arrival from multiple trucks A customer's containers routinely arrive together on several trucks, but bulk receive accepted one truck per operation: a single truckEntrance and a flat containerNumbers list capped at two boxes. Receiving a six-container arrival meant six separate operations. Model the arrival as a list of trucks instead. Each entry carries its own truckEntrance, its own containers and, optionally, its own bookingIds, defaulting to the operation's. The receive loop iterates trucks, so every per-truck invariant is preserved rather than pooled: the physical capacity check (one 40ft or up to two 20ft), the container-to-plate assignment check, the GRN batch number and the capacity assertions all still apply per truck. Callers sending the existing truckEntrance and containerNumbers fields collapse to a one-element list and behave exactly as before. Reject a container listed on more than one truck up front. The per-booking check only catches this once a unit is marked received, so a duplicate would otherwise surface from whichever truck happened to be processed second and read as a duplicate gate entry rather than a data-entry slip. Verified: freight-api type-check passes; all 13 warehouse suites pass (73 tests). Co-Authored-By: Claude Opus 5 --- .../warehouses/dto/bulk-receive.dto.ts | 58 ++ .../warehouses/warehouse-inventory.service.ts | 791 ++++++++++-------- 2 files changed, 477 insertions(+), 372 deletions(-) diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts index 85417ea87..0a04b040b 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts @@ -176,6 +176,49 @@ export class TruckEntranceDto { warehouseManagerName?: string; } +/** + * One physical truck at the gate, with the containers it is carrying. + * + * A customer whose containers arrive together sends several trucks, and each + * carries its own load: the plate, driver and boxes belong to that truck, not + * to the receive operation as a whole. Each truck is validated and given its + * own GRN batch exactly as a single-truck receive always was. + */ +export class ReceiveTruckDto { + /** + * The physical containers delivered by this truck: either one 40ft box or up + * to two 20ft boxes, the same physical limit a single-truck receive enforces. + */ + @ApiPropertyOptional({ type: [String] }) + @IsOptional() + @IsArray() + @ArrayNotEmpty() + @ArrayMaxSize(2) + @ArrayUnique() + @Matches(/^[A-Z]{4}\d{7}$/, { + each: true, + message: 'each container number must match ISO container format, e.g. ABCD1234567', + }) + containerNumbers?: string[]; + + @ApiProperty({ type: TruckEntranceDto }) + @ValidateNested() + @Type(() => TruckEntranceDto) + truckEntrance!: TruckEntranceDto; + + /** + * The bookings this truck delivers against. Defaults to the operation's + * `bookingIds` when omitted; container freight still requires exactly one, + * so its containers and documents stay separate per truck. + */ + @ApiPropertyOptional({ type: [String], format: 'uuid' }) + @IsOptional() + @IsArray() + @ArrayNotEmpty() + @IsUUID('all', { each: true }) + bookingIds?: string[]; +} + /** Bulk-receive eligible PAID bookings into a warehouse location, by trade direction. */ export class BulkReceiveDto { @ApiProperty({ enum: ['IMPORT', 'EXPORT'] }) @@ -200,9 +243,24 @@ export class BulkReceiveDto { @IsUUID('all', { each: true }) bookingIds!: string[]; + /** + * Several trucks arriving together, each with its own plate, driver and + * containers. When present this supersedes the single-truck + * `containerNumbers` / `truckEntrance` pair below, which is kept so existing + * callers (and single-truck arrivals) keep working unchanged. + */ + @ApiPropertyOptional({ type: [ReceiveTruckDto] }) + @IsOptional() + @IsArray() + @ArrayNotEmpty() + @ValidateNested({ each: true }) + @Type(() => ReceiveTruckDto) + trucks?: ReceiveTruckDto[]; + /** * The physical containers delivered by this truck. Container exports are * received one truck at a time: either one 40ft box or up to two 20ft boxes. + * Ignored when `trucks` is given. */ @ApiPropertyOptional({ type: [String] }) @IsOptional() 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 ef68f3673..3925e923e 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 @@ -1580,335 +1580,412 @@ export class WarehouseInventoryService { bookingId: string; }> = []; + // A customer's containers often arrive on several trucks at once. Each + // truck carries its own load, so the operation is a list of trucks; the + // legacy single-truck fields collapse to a one-element list so existing + // callers behave exactly as before. + const trucks: Array<{ + truckEntrance?: BulkReceiveDto['truckEntrance']; + containerNumbers?: string[]; + bookingIds: string[]; + }> = dto.trucks?.length + ? dto.trucks.map((truck) => ({ + truckEntrance: truck.truckEntrance, + containerNumbers: truck.containerNumbers, + bookingIds: truck.bookingIds?.length ? truck.bookingIds : dto.bookingIds, + })) + : [ + { + truckEntrance: dto.truckEntrance, + containerNumbers: dto.containerNumbers, + bookingIds: dto.bookingIds, + }, + ]; + + // Two trucks cannot deliver the same box. The per-booking check below only + // catches this once a unit is marked received, which would let a duplicate + // through on the truck that happens to be processed first. + const seenContainers = new Set(); + for (const truck of trucks) { + for (const raw of truck.containerNumbers ?? []) { + const number = raw.trim().toUpperCase(); + if (seenContainers.has(number)) { + throw new BadRequestException( + `Container ${number} is listed on more than one truck`, + ); + } + seenContainers.add(number); + } + } + await this.dataSource.transaction(async (manager) => { const { warehouse, yard, zone } = await this.validateLocation(manager, { warehouseId: dto.warehouseId, yardId: dto.yardId, zoneId: dto.zoneId, }); - // The receive location is whatever the operator selected above — never a - // hand-typed string. Stamp it on the truck entrance for the GRN/notes. - if (dto.truckEntrance && !dto.truckEntrance.warehouseCodeLocation) { - dto.truckEntrance.warehouseCodeLocation = [warehouse.code, yard.code, zone.code] - .filter(Boolean) - .join(' / '); - } - - for (const bookingId of dto.bookingIds) { - const skip = (reason: string) => { - result.skippedCount += 1; - result.results.push({ bookingId, status: 'SKIPPED', reason }); - }; - - const [booking] = await manager.query( - `SELECT b.reference AS "reference", - b.payment_status AS "paymentStatus", - b.freight_type AS "freightType", - b.cargo_total_weight_vgm AS "weight", - company.name AS "customer", - company.tin AS "customerTin", - ${companyNotifyPhoneExpr('company')} AS "customerPhone", - bc.container_numbers AS "containerNumber", - bc.container_quantity AS "containerQuantity", - bc.container_packaging_type AS "containerPackagingType", - COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargoDescription", - oy.country AS "originCountry", dy.country AS "destinationCountry", - -- No service_types OR here either — see eligibleBookings above. - (NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL) AS "hasFirstMile", - fm.id AS "firstMileRequestId", - fm.status AS "firstMileStatus", - v.plate_number AS "firstMileTruckPlateNumber", - v.trailer_plate_no AS "firstMileTrailerPlateNumber", - COALESCE( - NULLIF(TRIM(CONCAT(COALESCE(driver.first_name, ''), ' ', COALESCE(driver.last_name, ''))), ''), - v.assigned_driver_name - ) AS "firstMileDriverName", - driver.phone_number AS "firstMileDriverPhone", - driver.license_number AS "firstMileDriverLicenseNumber", - v.vehicle_type AS "firstMileTruckType", - COALESCE((SELECT string_agg(DISTINCT cta.plate_number, ', ') - FROM freight.customer_truck_assignments cta - WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL), - b.customer_truck_plate_number) AS "customerTruckPlateNumber", - COALESCE((SELECT string_agg(DISTINCT cta.driver_name, ', ') - FROM freight.customer_truck_assignments cta - WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL), - b.customer_truck_driver_name) AS "customerTruckDriverName", - b.customer_truck_type AS "customerTruckType", - b.customer_truck_container_number AS "customerTruckContainerNumber", - b.customer_truck_assigned_at AS "customerTruckAssignedAt", - b.company_id AS "companyId", - (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL) AS "hasLastMile" - FROM freight.bookings b - LEFT JOIN freight.companies company ON company.id = b.company_id - ${primaryContactUserJoin('company')} - LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id - LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id - LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id - LEFT JOIN LATERAL ( - SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers, - SUM(booking_container.quantity)::int AS container_quantity, - CASE - WHEN COUNT(booking_container.id) = 0 THEN NULL - WHEN bool_or(container_type.is_reefer) THEN 'REEFER_CONTAINER' - WHEN bool_or(container_type.is_open_top) THEN 'OPEN_TOP_CONTAINER' - WHEN MIN(container_type.size_ft) = 20 THEN 'CONTAINER_20FT' - WHEN MIN(container_type.size_ft) = 40 THEN 'CONTAINER_40FT' - WHEN MIN(container_type.size_ft) = 45 THEN 'CONTAINER_45FT' - ELSE 'OTHER_CONTAINER' - END AS container_packaging_type - FROM freight.booking_container booking_container - LEFT JOIN freight.container_types container_type ON container_type.id = booking_container.container_type_id - WHERE booking_container.booking_id = b.id AND booking_container.deleted_at IS NULL - ) bc ON true - LEFT JOIN LATERAL ( - SELECT first_mile.id, first_mile.status, first_mile.vehicle_id - FROM freight.first_mile first_mile - WHERE first_mile.booking_id = b.id AND first_mile.deleted_at IS NULL - ORDER BY first_mile.created_at DESC - LIMIT 1 - ) fm ON true - LEFT JOIN freight.vehicles v ON v.id = fm.vehicle_id - LEFT JOIN freight.drivers driver ON driver.id = v.assigned_driver_id - WHERE b.id = $1 AND b.deleted_at IS NULL LIMIT 1`, - [bookingId], - ); - if (!booking) { skip('Booking not found'); continue; } - if (booking.paymentStatus !== 'PAID') { skip('Booking not PAID'); continue; } - // Direction is derived from the route (yard countries), not the stored field. - const bookingDirection = deriveTradeDirection( - { country: booking.originCountry }, - { country: booking.destinationCountry }, - ); - if (bookingDirection !== dto.direction) { - skip(`Booking route is ${bookingDirection}, not ${dto.direction}`); - continue; - } - if (dto.direction === 'EXPORT' && booking.hasFirstMile) { - if (!booking.firstMileRequestId) { - skip('First-mile request not created'); - continue; - } - if (booking.firstMileStatus !== 'RECEIVED_TO_PORT') { - skip('First-mile truck has not arrived'); - continue; - } + // Each arriving truck is its own unit of work: its own plate and driver, + // its own containers, its own physical-load check and its own GRN batch. + // A single-truck arrival is just the one-element case, so the per-truck + // body below is unchanged from when this only ever handled one truck. + for (const truck of trucks) { + const truckEntranceInput = truck.truckEntrance; + const truckContainerNumbers = truck.containerNumbers; + const truckBookingIds = truck.bookingIds; + // The receive location is whatever the operator selected above — never a + // hand-typed string. Stamp it on the truck entrance for the GRN/notes. + if (truckEntranceInput && !truckEntranceInput.warehouseCodeLocation) { + truckEntranceInput.warehouseCodeLocation = [warehouse.code, yard.code, zone.code] + .filter(Boolean) + .join(' / '); } - const containerQuantity = Number(booking.containerQuantity ?? 0); - if (booking.freightType === 'CONTAINER' && containerQuantity <= 0) { - skip('Container booking has no container quantity'); - continue; - } + for (const bookingId of truckBookingIds) { + const skip = (reason: string) => { + result.skippedCount += 1; + result.results.push({ bookingId, status: 'SKIPPED', reason }); + }; - const now = new Date(); - const truckEntrance = dto.truckEntrance - ? this.mergeSystemTruckEntrance(dto.truckEntrance, booking) - : undefined; - // Multi-truck self-haul is selected explicitly at the gate. The booking - // source contains comma-joined legacy summary fields, which must never - // replace the one physical truck the receiver selected. - if (truckEntrance && !booking.hasFirstMile && dto.truckEntrance) { - truckEntrance.truckPlateNumber = dto.truckEntrance.truckPlateNumber; - truckEntrance.driverName = dto.truckEntrance.driverName; - truckEntrance.driverPhone = dto.truckEntrance.driverPhone; - truckEntrance.truckType = dto.truckEntrance.truckType; - } - if (dto.direction === 'EXPORT') { - this.assertTruckEntrance(truckEntrance); - } - - type ReceiveContainerUnit = { - containerNumber: string; - containerSize: string | null; - weightTons: string | number; - sealNumber: string | null; - bookingContainerId: string; - containerTypeId: string | null; - received: boolean; - }; - let selectedUnits: ReceiveContainerUnit[] = []; - let grnNumber = this.generateGrnNumber(dto.direction, bookingId, now, booking.customer); - - if (booking.freightType === 'CONTAINER') { - if (dto.bookingIds.length !== 1) { - throw new BadRequestException( - 'Receive one container booking per arriving truck so its containers and documents stay separate', - ); - } - const selectedNumbers = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); - if (!selectedNumbers.length) { - throw new BadRequestException('Select the containers arriving on this truck'); - } - const allUnits: ReceiveContainerUnit[] = await manager.query( - `SELECT UPPER(bcu.container_number) AS "containerNumber", - bc.container_size AS "containerSize", - bcu.vgm_tons AS "weightTons", - bcu.seal_number AS "sealNumber", - bc.id AS "bookingContainerId", - bc.container_type_id AS "containerTypeId", - bcu.received_to_port AS received - FROM freight.booking_container_units bcu - JOIN freight.booking_container bc - ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL - WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL - FOR UPDATE OF bcu`, + const [booking] = await manager.query( + `SELECT b.reference AS "reference", + b.payment_status AS "paymentStatus", + b.freight_type AS "freightType", + b.cargo_total_weight_vgm AS "weight", + company.name AS "customer", + company.tin AS "customerTin", + ${companyNotifyPhoneExpr('company')} AS "customerPhone", + bc.container_numbers AS "containerNumber", + bc.container_quantity AS "containerQuantity", + bc.container_packaging_type AS "containerPackagingType", + COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargoDescription", + oy.country AS "originCountry", dy.country AS "destinationCountry", + -- No service_types OR here either — see eligibleBookings above. + (NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL) AS "hasFirstMile", + fm.id AS "firstMileRequestId", + fm.status AS "firstMileStatus", + v.plate_number AS "firstMileTruckPlateNumber", + v.trailer_plate_no AS "firstMileTrailerPlateNumber", + COALESCE( + NULLIF(TRIM(CONCAT(COALESCE(driver.first_name, ''), ' ', COALESCE(driver.last_name, ''))), ''), + v.assigned_driver_name + ) AS "firstMileDriverName", + driver.phone_number AS "firstMileDriverPhone", + driver.license_number AS "firstMileDriverLicenseNumber", + v.vehicle_type AS "firstMileTruckType", + COALESCE((SELECT string_agg(DISTINCT cta.plate_number, ', ') + FROM freight.customer_truck_assignments cta + WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL), + b.customer_truck_plate_number) AS "customerTruckPlateNumber", + COALESCE((SELECT string_agg(DISTINCT cta.driver_name, ', ') + FROM freight.customer_truck_assignments cta + WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL), + b.customer_truck_driver_name) AS "customerTruckDriverName", + b.customer_truck_type AS "customerTruckType", + b.customer_truck_container_number AS "customerTruckContainerNumber", + b.customer_truck_assigned_at AS "customerTruckAssignedAt", + b.company_id AS "companyId", + (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL) AS "hasLastMile" + FROM freight.bookings b + LEFT JOIN freight.companies company ON company.id = b.company_id + ${primaryContactUserJoin('company')} + LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id + LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id + LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id + LEFT JOIN LATERAL ( + SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers, + SUM(booking_container.quantity)::int AS container_quantity, + CASE + WHEN COUNT(booking_container.id) = 0 THEN NULL + WHEN bool_or(container_type.is_reefer) THEN 'REEFER_CONTAINER' + WHEN bool_or(container_type.is_open_top) THEN 'OPEN_TOP_CONTAINER' + WHEN MIN(container_type.size_ft) = 20 THEN 'CONTAINER_20FT' + WHEN MIN(container_type.size_ft) = 40 THEN 'CONTAINER_40FT' + WHEN MIN(container_type.size_ft) = 45 THEN 'CONTAINER_45FT' + ELSE 'OTHER_CONTAINER' + END AS container_packaging_type + FROM freight.booking_container booking_container + LEFT JOIN freight.container_types container_type ON container_type.id = booking_container.container_type_id + WHERE booking_container.booking_id = b.id AND booking_container.deleted_at IS NULL + ) bc ON true + LEFT JOIN LATERAL ( + SELECT first_mile.id, first_mile.status, first_mile.vehicle_id + FROM freight.first_mile first_mile + WHERE first_mile.booking_id = b.id AND first_mile.deleted_at IS NULL + ORDER BY first_mile.created_at DESC + LIMIT 1 + ) fm ON true + LEFT JOIN freight.vehicles v ON v.id = fm.vehicle_id + LEFT JOIN freight.drivers driver ON driver.id = v.assigned_driver_id + WHERE b.id = $1 AND b.deleted_at IS NULL LIMIT 1`, [bookingId], ); - assertTruckLoad({ - containers: selectedNumbers, - bookingContainers: allUnits.map((unit) => unit.containerNumber), - sizes: allUnits - .filter((unit) => selectedNumbers.includes(unit.containerNumber)) - .map((unit) => unit.containerSize ?? ''), - }); - selectedUnits = allUnits.filter((unit) => selectedNumbers.includes(unit.containerNumber)); - if (selectedUnits.some((unit) => unit.received)) { - const repeated = selectedUnits.filter((unit) => unit.received).map((unit) => unit.containerNumber); - throw new BadRequestException(`Container(s) already received: ${repeated.join(', ')}`); + if (!booking) { skip('Booking not found'); continue; } + if (booking.paymentStatus !== 'PAID') { skip('Booking not PAID'); continue; } + // Direction is derived from the route (yard countries), not the stored field. + const bookingDirection = deriveTradeDirection( + { country: booking.originCountry }, + { country: booking.destinationCountry }, + ); + if (bookingDirection !== dto.direction) { + skip(`Booking route is ${bookingDirection}, not ${dto.direction}`); + continue; } - - // If this is a customer-assigned truck, it may only deliver the boxes - // assigned to that plate. Manual/unassigned arrivals retain the same - // physical capacity validation but have no assignment list to check. - if (truckEntrance?.truckPlateNumber) { - const assigned: Array<{ containerNumber: string }> = await manager.query( - `SELECT UPPER(ctc.container_number) AS "containerNumber" - FROM freight.customer_truck_assignments cta - JOIN freight.customer_truck_containers ctc - ON ctc.assignment_id = cta.id AND ctc.deleted_at IS NULL - WHERE cta.booking_id = $1 - AND UPPER(cta.plate_number) = UPPER($2) - AND cta.deleted_at IS NULL`, - [bookingId, truckEntrance.truckPlateNumber], - ); - if ( - assigned.length > 0 && - selectedNumbers.some( - (number) => !assigned.some((container) => container.containerNumber === number), - ) - ) { - throw new BadRequestException( - `Selected containers are not assigned to truck ${truckEntrance.truckPlateNumber}`, - ); + if (dto.direction === 'EXPORT' && booking.hasFirstMile) { + if (!booking.firstMileRequestId) { + skip('First-mile request not created'); + continue; + } + if (booking.firstMileStatus !== 'RECEIVED_TO_PORT') { + skip('First-mile truck has not arrived'); + continue; } } - const [{ batches }]: Array<{ batches: string }> = await manager.query( - `SELECT COUNT(DISTINCT inv.grn_number) AS batches - FROM freight.warehouse_inventory inv - WHERE inv.booking_id = $1 - AND inv.grn_number IS NOT NULL - AND inv.deleted_at IS NULL`, - [bookingId], - ); - grnNumber = `${grnNumber}-${String(Number(batches ?? 0) + 1).padStart(2, '0')}`; - if (truckEntrance) { - truckEntrance.assignedEquipmentNumber = selectedNumbers.join(', '); - truckEntrance.unitCount = selectedNumbers.length; - truckEntrance.netWeightKg = selectedUnits.reduce( - (total, unit) => total + Number(unit.weightTons || 0), - 0, - ); - } - } else { - const existing = await manager - .getRepository(WarehouseInventory) - .findOne({ where: { bookingId } }); - if (existing) { - skip('Already received'); + const containerQuantity = Number(booking.containerQuantity ?? 0); + if (booking.freightType === 'CONTAINER' && containerQuantity <= 0) { + skip('Container booking has no container quantity'); continue; } - } - const receivedBefore = - booking.freightType === 'CONTAINER' - ? Number( - ( - await manager.query( - `SELECT COUNT(*) AS count - FROM freight.booking_container_units bcu - JOIN freight.booking_container bc - ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL - WHERE bc.booking_id = $1 - AND bcu.received_to_port = true - AND bcu.deleted_at IS NULL`, - [bookingId], - ) - )[0]?.count ?? 0, - ) - : 0; - const receivedAfter = receivedBefore + selectedUnits.length; - const remainingAfter = Math.max(0, containerQuantity - receivedAfter); - const receiveNote = this.buildReceiveNote({ - grnNumber, - direction: dto.direction, - notes: - booking.freightType === 'CONTAINER' - ? `${selectedUnits.length} container(s) arrived: ${selectedUnits - .map((unit) => unit.containerNumber) - .join(', ')}. ${remainingAfter} container(s) left.` - : `Bulk received (${dto.direction})`, - truckEntrance, - }); + const now = new Date(); + const truckEntrance = truckEntranceInput + ? this.mergeSystemTruckEntrance(truckEntranceInput, booking) + : undefined; + // Multi-truck self-haul is selected explicitly at the gate. The booking + // source contains comma-joined legacy summary fields, which must never + // replace the one physical truck the receiver selected. + if (truckEntrance && !booking.hasFirstMile && truckEntranceInput) { + truckEntrance.truckPlateNumber = truckEntranceInput.truckPlateNumber; + truckEntrance.driverName = truckEntranceInput.driverName; + truckEntrance.driverPhone = truckEntranceInput.driverPhone; + truckEntrance.truckType = truckEntranceInput.truckType; + } + if (dto.direction === 'EXPORT') { + this.assertTruckEntrance(truckEntrance); + } - // Validate capacity before saving - const weight = - booking.freightType === 'CONTAINER' - ? selectedUnits.reduce((total, unit) => total + Number(unit.weightTons || 0), 0) - : Number(booking.weight) || 0; - const containerCount = booking.freightType === 'CONTAINER' ? selectedUnits.length : 0; - this.assertCapacity('Warehouse', warehouse, weight, 0, containerCount); - this.assertCapacity('Yard', yard, weight, 0, containerCount); - this.assertCapacity('Zone', zone, weight, 0, containerCount); + type ReceiveContainerUnit = { + containerNumber: string; + containerSize: string | null; + weightTons: string | number; + sealNumber: string | null; + bookingContainerId: string; + containerTypeId: string | null; + received: boolean; + }; + let selectedUnits: ReceiveContainerUnit[] = []; + let grnNumber = this.generateGrnNumber(dto.direction, bookingId, now, booking.customer); - const inventoryIds: string[] = []; - if (booking.freightType === 'CONTAINER') { - const containers = manager.getRepository(Container); - for (const unit of selectedUnits) { - let container = await containers.findOne({ - where: { containerNumber: unit.containerNumber }, - withDeleted: true, + if (booking.freightType === 'CONTAINER') { + if (truckBookingIds.length !== 1) { + throw new BadRequestException( + 'Receive one container booking per arriving truck so its containers and documents stay separate', + ); + } + const selectedNumbers = (truckContainerNumbers ?? []).map((n) => n.trim().toUpperCase()); + if (!selectedNumbers.length) { + throw new BadRequestException('Select the containers arriving on this truck'); + } + const allUnits: ReceiveContainerUnit[] = await manager.query( + `SELECT UPPER(bcu.container_number) AS "containerNumber", + bc.container_size AS "containerSize", + bcu.vgm_tons AS "weightTons", + bcu.seal_number AS "sealNumber", + bc.id AS "bookingContainerId", + bc.container_type_id AS "containerTypeId", + bcu.received_to_port AS received + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL + FOR UPDATE OF bcu`, + [bookingId], + ); + assertTruckLoad({ + containers: selectedNumbers, + bookingContainers: allUnits.map((unit) => unit.containerNumber), + sizes: allUnits + .filter((unit) => selectedNumbers.includes(unit.containerNumber)) + .map((unit) => unit.containerSize ?? ''), }); - if (!container && !unit.containerTypeId) { - throw new BadRequestException( - `Container ${unit.containerNumber} has no container type and cannot be received`, + selectedUnits = allUnits.filter((unit) => selectedNumbers.includes(unit.containerNumber)); + if (selectedUnits.some((unit) => unit.received)) { + const repeated = selectedUnits.filter((unit) => unit.received).map((unit) => unit.containerNumber); + throw new BadRequestException(`Container(s) already received: ${repeated.join(', ')}`); + } + + // If this is a customer-assigned truck, it may only deliver the boxes + // assigned to that plate. Manual/unassigned arrivals retain the same + // physical capacity validation but have no assignment list to check. + if (truckEntrance?.truckPlateNumber) { + const assigned: Array<{ containerNumber: string }> = await manager.query( + `SELECT UPPER(ctc.container_number) AS "containerNumber" + FROM freight.customer_truck_assignments cta + JOIN freight.customer_truck_containers ctc + ON ctc.assignment_id = cta.id AND ctc.deleted_at IS NULL + WHERE cta.booking_id = $1 + AND UPPER(cta.plate_number) = UPPER($2) + AND cta.deleted_at IS NULL`, + [bookingId, truckEntrance.truckPlateNumber], + ); + if ( + assigned.length > 0 && + selectedNumbers.some( + (number) => !assigned.some((container) => container.containerNumber === number), + ) + ) { + throw new BadRequestException( + `Selected containers are not assigned to truck ${truckEntrance.truckPlateNumber}`, + ); + } + } + + const [{ batches }]: Array<{ batches: string }> = await manager.query( + `SELECT COUNT(DISTINCT inv.grn_number) AS batches + FROM freight.warehouse_inventory inv + WHERE inv.booking_id = $1 + AND inv.grn_number IS NOT NULL + AND inv.deleted_at IS NULL`, + [bookingId], + ); + grnNumber = `${grnNumber}-${String(Number(batches ?? 0) + 1).padStart(2, '0')}`; + if (truckEntrance) { + truckEntrance.assignedEquipmentNumber = selectedNumbers.join(', '); + truckEntrance.unitCount = selectedNumbers.length; + truckEntrance.netWeightKg = selectedUnits.reduce( + (total, unit) => total + Number(unit.weightTons || 0), + 0, ); } - if (!container) { - container = await containers.save( - containers.create({ - containerNumber: unit.containerNumber, - containerTypeId: unit.containerTypeId as string, - bookingContainerId: unit.bookingContainerId, + } else { + const existing = await manager + .getRepository(WarehouseInventory) + .findOne({ where: { bookingId } }); + if (existing) { + skip('Already received'); + continue; + } + } + + const receivedBefore = + booking.freightType === 'CONTAINER' + ? Number( + ( + await manager.query( + `SELECT COUNT(*) AS count + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 + AND bcu.received_to_port = true + AND bcu.deleted_at IS NULL`, + [bookingId], + ) + )[0]?.count ?? 0, + ) + : 0; + const receivedAfter = receivedBefore + selectedUnits.length; + const remainingAfter = Math.max(0, containerQuantity - receivedAfter); + const receiveNote = this.buildReceiveNote({ + grnNumber, + direction: dto.direction, + notes: + booking.freightType === 'CONTAINER' + ? `${selectedUnits.length} container(s) arrived: ${selectedUnits + .map((unit) => unit.containerNumber) + .join(', ')}. ${remainingAfter} container(s) left.` + : `Bulk received (${dto.direction})`, + truckEntrance, + }); + + // Validate capacity before saving + const weight = + booking.freightType === 'CONTAINER' + ? selectedUnits.reduce((total, unit) => total + Number(unit.weightTons || 0), 0) + : Number(booking.weight) || 0; + const containerCount = booking.freightType === 'CONTAINER' ? selectedUnits.length : 0; + this.assertCapacity('Warehouse', warehouse, weight, 0, containerCount); + this.assertCapacity('Yard', yard, weight, 0, containerCount); + this.assertCapacity('Zone', zone, weight, 0, containerCount); + + const inventoryIds: string[] = []; + if (booking.freightType === 'CONTAINER') { + const containers = manager.getRepository(Container); + for (const unit of selectedUnits) { + let container = await containers.findOne({ + where: { containerNumber: unit.containerNumber }, + withDeleted: true, + }); + if (!container && !unit.containerTypeId) { + throw new BadRequestException( + `Container ${unit.containerNumber} has no container type and cannot be received`, + ); + } + if (!container) { + container = await containers.save( + containers.create({ + containerNumber: unit.containerNumber, + containerTypeId: unit.containerTypeId as string, + bookingContainerId: unit.bookingContainerId, + bookingId, + sealNumber: unit.sealNumber, + tareWeight: 0, + maxGrossWeight: Number(unit.weightTons || 0), + status: 'LOADED', + wagonId: null, + position: null, + wagonBookingAllocationId: null, + }), + ); + } else { + await containers.update(container.id, { bookingId, + bookingContainerId: unit.bookingContainerId, sealNumber: unit.sealNumber, - tareWeight: 0, - maxGrossWeight: Number(unit.weightTons || 0), status: 'LOADED', - wagonId: null, - position: null, - wagonBookingAllocationId: null, + deletedAt: null, + }); + } + const saved = await manager.getRepository(WarehouseInventory).save( + manager.getRepository(WarehouseInventory).create({ + warehouseId: dto.warehouseId, + yardId: dto.yardId, + zoneId: dto.zoneId, + bookingId, + containerId: container.id, + quantity: 1, + weight: Number(unit.weightTons || 0), + grnNumber, + status: 'RECEIVED', + arrivedAt: now, + notes: receiveNote, }), ); - } else { - await containers.update(container.id, { - bookingId, - bookingContainerId: unit.bookingContainerId, - sealNumber: unit.sealNumber, - status: 'LOADED', - deletedAt: null, - }); + inventoryIds.push(saved.id); } + await manager.query( + `UPDATE freight.booking_container_units bcu + SET received_to_port = true, + received_at = COALESCE(bcu.received_at, NOW()), + grn_number = $3, + updated_at = NOW() + FROM freight.booking_container bc + WHERE bc.id = bcu.booking_container_id + AND bc.booking_id = $1 + AND UPPER(bcu.container_number) = ANY($2::varchar[]) + AND bc.deleted_at IS NULL + AND bcu.deleted_at IS NULL`, + [bookingId, selectedUnits.map((unit) => unit.containerNumber), grnNumber], + ); + } else { const saved = await manager.getRepository(WarehouseInventory).save( manager.getRepository(WarehouseInventory).create({ warehouseId: dto.warehouseId, yardId: dto.yardId, zoneId: dto.zoneId, bookingId, - containerId: container.id, quantity: 1, - weight: Number(unit.weightTons || 0), + weight, grnNumber, status: 'RECEIVED', arrivedAt: now, @@ -1917,90 +1994,60 @@ export class WarehouseInventoryService { ); inventoryIds.push(saved.id); } - await manager.query( - `UPDATE freight.booking_container_units bcu - SET received_to_port = true, - received_at = COALESCE(bcu.received_at, NOW()), - grn_number = $3, - updated_at = NOW() - FROM freight.booking_container bc - WHERE bc.id = bcu.booking_container_id - AND bc.booking_id = $1 - AND UPPER(bcu.container_number) = ANY($2::varchar[]) - AND bc.deleted_at IS NULL - AND bcu.deleted_at IS NULL`, - [bookingId, selectedUnits.map((unit) => unit.containerNumber), grnNumber], - ); - } else { - const saved = await manager.getRepository(WarehouseInventory).save( - manager.getRepository(WarehouseInventory).create({ + + // Update warehouse/yard/zone capacity counters + await this.applyCapacityDelta(manager, dto, weight, 0, containerCount); + + // Export self-haul: this receive IS the truck's arrival — see + // markCustomerTruckArrived / receive()'s single-booking mirror. + if (dto.direction === 'EXPORT') { + await this.markCustomerTruckArrived(manager, bookingId, truckEntrance?.truckPlateNumber); + } + + await this.activityLog.record( + { + activityType: 'INVENTORY_RECEIVED', + inventoryId: inventoryIds[0], warehouseId: dto.warehouseId, - yardId: dto.yardId, - zoneId: dto.zoneId, - bookingId, - quantity: 1, - weight, - grnNumber, - status: 'RECEIVED', - arrivedAt: now, - notes: receiveNote, - }), + description: truckEntrance?.truckPlateNumber + ? `GRN ${grnNumber}: bulk received ${dto.direction} booking via truck ${truckEntrance.truckPlateNumber}` + : `GRN ${grnNumber}: bulk received ${dto.direction} booking`, + performedBy: dto.performedBy, + }, + manager, ); - inventoryIds.push(saved.id); - } - // Update warehouse/yard/zone capacity counters - await this.applyCapacityDelta(manager, dto, weight, 0, containerCount); - - // Export self-haul: this receive IS the truck's arrival — see - // markCustomerTruckArrived / receive()'s single-booking mirror. - if (dto.direction === 'EXPORT') { - await this.markCustomerTruckArrived(manager, bookingId, truckEntrance?.truckPlateNumber); - } - - await this.activityLog.record( - { - activityType: 'INVENTORY_RECEIVED', - inventoryId: inventoryIds[0], - warehouseId: dto.warehouseId, - description: truckEntrance?.truckPlateNumber - ? `GRN ${grnNumber}: bulk received ${dto.direction} booking via truck ${truckEntrance.truckPlateNumber}` - : `GRN ${grnNumber}: bulk received ${dto.direction} booking`, - performedBy: dto.performedBy, - }, - manager, - ); - - // Queued, not sent here: an SMS/email round-trip inside the transaction - // holds capacity/location locks open for the whole gateway latency. - pendingNotifications.push({ - owner: { - phone: truckEntrance?.customerPhone ?? booking.customerPhone, - ownerName: truckEntrance?.ownerName ?? booking.customer, - bookingReference: truckEntrance?.edrDigitalBookingId ?? booking.reference, - grnNumber, - direction: dto.direction, - warehouseId: dto.warehouseId, + // Queued, not sent here: an SMS/email round-trip inside the transaction + // holds capacity/location locks open for the whole gateway latency. + pendingNotifications.push({ + owner: { + phone: truckEntrance?.customerPhone ?? booking.customerPhone, + ownerName: truckEntrance?.ownerName ?? booking.customer, + bookingReference: truckEntrance?.edrDigitalBookingId ?? booking.reference, + grnNumber, + direction: dto.direction, + warehouseId: dto.warehouseId, + bookingId, + }, + booking, bookingId, - }, - booking, - bookingId, - }); + }); - result.receivedCount += 1; - result.results.push({ - bookingId, - status: 'RECEIVED', - inventoryId: inventoryIds[0], - inventoryIds, - grnNumber, - ...(booking.freightType === 'CONTAINER' - ? { - receivedContainers: receivedAfter, - remainingContainers: remainingAfter, - } - : {}), - }); + result.receivedCount += 1; + result.results.push({ + bookingId, + status: 'RECEIVED', + inventoryId: inventoryIds[0], + inventoryIds, + grnNumber, + ...(booking.freightType === 'CONTAINER' + ? { + receivedContainers: receivedAfter, + remainingContainers: remainingAfter, + } + : {}), + }); + } } }); From 808af074e2ee85e0298f1cd5659bdcb47dcad844 Mon Sep 17 00:00:00 2001 From: hager Date: Sun, 6 Sep 2026 19:43:21 +0000 Subject: [PATCH 06/11] fix(empty-return-requests): map queue columns to camelCase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `findAll` selected `r.*` in raw SQL. `dataSource.query` bypasses the entity mapping, so rows came back under their database names — `container_numbers`, `submitted_at`, `quoted_total_amount` — while both clients read camelCase. Every field on the row was undefined; the backoffice queue crashed on `containerNumbers.join(", ")` and took the page down through the error boundary. Alias each column explicitly, the way `plannedReturns()` below it already does, and cast the two numeric columns with `::float8` — raw SQL skips the entity's numeric->Number transformer, which would otherwise hand the clients strings. Type the result as `EmptyReturnRequestRow` so the signature matches what the projection actually selects rather than claiming absent fields. Guard the three `containerNumbers.join(...)` call sites too, so one odd row cannot white-screen the queue again. Co-Authored-By: Claude Opus 5 --- .../empty-return-requests.service.ts | 60 +++++++++++++++++-- .../warehouses/EmptyReturnRequestsPage.tsx | 6 +- 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.service.ts b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.service.ts index aae59de39..424ff2357 100644 --- a/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.service.ts +++ b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.service.ts @@ -54,6 +54,33 @@ export interface EmptyReturnQuote { unavailableReason: string | null; } +/** A queue row: the request plus the booking and payer names staff read it by. */ +export type EmptyReturnRequestRow = Pick< + EmptyReturnRequest, + | 'id' + | 'bookingId' + | 'companyId' + | 'status' + | 'containerNumbers' + | 'containerCount' + | 'quotedUnitAmount' + | 'quotedTotalAmount' + | 'currency' + | 'invoiceId' + | 'paidAt' + | 'requestedReturnDate' + | 'truckPlateNumber' + | 'truckDriverName' + | 'truckType' + | 'scheduledAt' + | 'submittedByUserId' + | 'submittedAt' + | 'reviewedByStaffId' + | 'reviewedAt' + | 'rejectionReason' + | 'completedAt' +> & { bookingReference: string | null; companyName: string | null }; + export interface EmptyReturnEligibility { eligible: boolean; /** Why the customer cannot request one, when `eligible` is false. */ @@ -82,13 +109,34 @@ export class EmptyReturnRequestsService { async findAll(filter: { status?: EmptyReturnRequestStatus; bookingId?: string; - }): Promise< - Array - > { + }): Promise { + // Raw SQL bypasses the entity mapping, so every column is aliased to the + // property name the clients read — `r.*` would hand them snake_case. return this.dataSource.query( - `SELECT r.*, - b.reference AS "bookingReference", - c.name AS "companyName" + `SELECT r.id, + r.booking_id AS "bookingId", + r.company_id AS "companyId", + r.status, + r.container_numbers AS "containerNumbers", + r.container_count AS "containerCount", + r.quoted_unit_amount::float8 AS "quotedUnitAmount", + r.quoted_total_amount::float8 AS "quotedTotalAmount", + r.currency, + r.invoice_id AS "invoiceId", + r.paid_at AS "paidAt", + r.requested_return_date AS "requestedReturnDate", + r.truck_plate_number AS "truckPlateNumber", + r.truck_driver_name AS "truckDriverName", + r.truck_type AS "truckType", + r.scheduled_at AS "scheduledAt", + r.submitted_by_user_id AS "submittedByUserId", + r.submitted_at AS "submittedAt", + r.reviewed_by_staff_id AS "reviewedByStaffId", + r.reviewed_at AS "reviewedAt", + r.rejection_reason AS "rejectionReason", + r.completed_at AS "completedAt", + b.reference AS "bookingReference", + c.name AS "companyName" FROM freight.empty_return_requests r LEFT JOIN freight.bookings b ON b.id = r.booking_id AND b.deleted_at IS NULL LEFT JOIN freight.companies c ON c.id = r.company_id diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/EmptyReturnRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/EmptyReturnRequestsPage.tsx index 41b4c8679..ee22e8617 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/EmptyReturnRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/EmptyReturnRequestsPage.tsx @@ -79,7 +79,7 @@ export default function EmptyReturnRequestsPage() { const controls = useListControls(requests, { dateKey: "submittedAt", searchValue: (row) => - `${row.bookingReference ?? ""} ${row.companyName ?? ""} ${row.containerNumbers.join(" ")}`, + `${row.bookingReference ?? ""} ${row.companyName ?? ""} ${(row.containerNumbers ?? []).join(" ")}`, }); const invalidate = () => { @@ -143,7 +143,7 @@ export default function EmptyReturnRequestsPage() { {row.original.containerCount} - {row.original.containerNumbers.join(", ")} + {(row.original.containerNumbers ?? []).join(", ")} ), @@ -386,7 +386,7 @@ function ApproveModal({ Containers coming back - {request.containerNumbers.join(", ")} + {(request.containerNumbers ?? []).join(", ")} From 6ebb402131777a2e24de8128930abf1b0edd75b8 Mon Sep 17 00:00:00 2001 From: hager Date: Sun, 6 Sep 2026 19:52:46 +0000 Subject: [PATCH 07/11] feat(freight-backoffice): enter several trucks on one arrival The receive drawer captured one truck per arrival, so a customer whose containers came on three trucks had to be received three times over. Stage trucks instead. "Add another truck" validates the form exactly as a single-truck receive does, files the truck with its own plate, driver and containers, and clears the form for the next one. The staged trucks are listed above the form with their boxes, each removable, and the arrival is received in one operation. Containers already staged on an earlier truck drop out of the picker, so the same box cannot be sent twice. Receiving with a part-filled form and trucks already staged is treated as the operator still typing rather than a finished arrival: it asks them to finish or clear the truck instead of silently dropping what they entered. An untouched form receives just the staged trucks. With no trucks staged the drawer sends the original truckEntrance and containerNumbers fields, so a single-truck arrival takes exactly the path it did before. Verified: backoffice type-check reports 982 errors both with and without this change (all pre-existing, none in the touched files); 13 warehouse API suites pass (73 tests). Co-Authored-By: Claude Opus 5 --- .../warehouses/ReceiveInventoryModal.tsx | 206 +++++++++++++++--- .../backoffice/src/types/warehouse.ts | 17 ++ 2 files changed, 189 insertions(+), 34 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index 1f3a1cf81..63035a34f 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -72,6 +72,7 @@ import type { ImportUnloadedItem, ReadyToLoadRow, ReceiveInventoryPayload, + ReceiveTruckPayload, TruckEntrancePayload, Warehouse, WarehouseInventoryItem, @@ -864,6 +865,12 @@ function EligibleTab({ const [packagingFreightType, setPackagingFreightType] = useState('MIXED'); const [selectedCustomerTruckId, setSelectedCustomerTruckId] = useState(null); const [selectedContainerNumbers, setSelectedContainerNumbers] = useState([]); + /** + * Trucks already staged for this arrival. A customer's containers often come + * on several trucks at once; each is captured with its own plate, driver and + * boxes, then the whole arrival is received in one operation. + */ + const [stagedTrucks, setStagedTrucks] = useState([]); const locationReady = Boolean(location.warehouseId && location.yardId && location.zoneId); const canReceiveBooking = (row: EligibleBooking) => @@ -968,10 +975,18 @@ function EligibleTab({ const assignedNumbersForSelectedTruck = new Set( (selectedCustomerTruck?.containers ?? []).map((container) => container.containerNumber.toUpperCase()), ); + // A box already staged on an earlier truck is spoken for — offering it again + // would send the same container twice and be rejected by the API. + const stagedContainerNumbers = new Set( + stagedTrucks.flatMap((truck) => + (truck.containerNumbers ?? []).map((number) => number.toUpperCase()), + ), + ); const selectableContainerUnits = pendingContainerUnits.filter( (unit) => - assignedNumbersForSelectedTruck.size === 0 || - assignedNumbersForSelectedTruck.has(unit.containerNumber.toUpperCase()), + !stagedContainerNumbers.has(unit.containerNumber.toUpperCase()) && + (assignedNumbersForSelectedTruck.size === 0 || + assignedNumbersForSelectedTruck.has(unit.containerNumber.toUpperCase())), ); const selectedContainerUnits = pendingContainerUnits.filter((unit) => selectedContainerNumbers.includes(unit.containerNumber), @@ -1063,17 +1078,25 @@ function EligibleTab({ bookingIds: string[], truckEntrance?: TruckEntrancePayload, containerNumbers?: string[], + trucks?: ReceiveTruckPayload[], ) => { const documentBookingId = direction === 'EXPORT' && bookingIds.length === 1 ? bookingIds[0] : null; const grnWindow = documentBookingId ? window.open('', '_blank') : null; const acceptanceWindow = documentBookingId ? window.open('', '_blank') : null; try { + // A multi-truck arrival sends `trucks` and nothing else: the API reads the + // single-truck fields only when `trucks` is absent, so sending both would + // silently drop the staged list. const r = await bulkReceive.mutateAsync({ direction, ...location, bookingIds, - ...(containerNumbers?.length ? { containerNumbers } : {}), - ...(truckEntrance ? { truckEntrance } : {}), + ...(trucks?.length + ? { trucks } + : { + ...(containerNumbers?.length ? { containerNumbers } : {}), + ...(truckEntrance ? { truckEntrance } : {}), + }), }); const receivedProgress = r.results.find( (item) => item.receivedContainers != null && item.remainingContainers != null, @@ -1081,7 +1104,11 @@ function EligibleTab({ toast({ title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`, description: receivedProgress - ? `${containerNumbers?.length ?? 0} container(s) arrived. ${receivedProgress.remainingContainers} container(s) left. GRN ${receivedProgress.grnNumber}.` + ? `${ + trucks?.length + ? trucks.reduce((sum, t) => sum + (t.containerNumbers?.length ?? 0), 0) + : (containerNumbers?.length ?? 0) + } container(s) arrived on ${trucks?.length ?? 1} truck(s). ${receivedProgress.remainingContainers} container(s) left. GRN ${receivedProgress.grnNumber}.` : r.results.find((item) => item.grnNumber)?.grnNumber ?? skippedSummary(r.skippedCount, r.results), }); const firstGrn = r.results.find((item) => item.inventoryId && item.grnNumber); @@ -1195,6 +1222,7 @@ function EligibleTab({ setPendingReceiveIds(filteredIds); setSelectedCustomerTruckId(null); setSelectedContainerNumbers([]); + setStagedTrucks([]); setReceivedAt(new Date().toISOString()); setTruckForm(normalizedForm); setLockedTruckFields({ @@ -1214,40 +1242,98 @@ function EligibleTab({ setTruckOpen(true); }; - const receive = async () => { + /** + * Validate whatever is currently in the truck form. Shared by "Add truck" and + * the final receive so a staged truck is held to exactly the same rules as a + * single-truck arrival. + */ + const truckFormError = (): string | null => { if (!truckForm.truckPlateNumber.trim() || !truckForm.driverName.trim() || !truckForm.driverPhone.trim()) { - toast({ variant: 'destructive', title: 'Truck and driver information are required' }); - return; + return 'Truck and driver information are required'; } if (!pendingUsesFirstMile && truckForm.weighingRequired == null) { - toast({ variant: 'destructive', title: 'Select whether customer truck weighing is required' }); - return; + return 'Select whether customer truck weighing is required'; } if (truckForm.weighingRequired && (truckForm.grossWeightKg === '' || truckForm.exitTareWeightKg === '')) { - toast({ variant: 'destructive', title: 'Gross weight and exit tare weight are required when weighing is Yes' }); - return; + return 'Gross weight and exit tare weight are required when weighing is Yes'; } if (pendingContainerBooking && selectedContainerNumbers.length === 0) { - toast({ variant: 'destructive', title: 'Select the containers arriving on this truck' }); + return 'Select the containers arriving on this truck'; + } + return containerCapacityError; + }; + + /** The current form as a payload, with the container summary fields filled in. */ + const currentTruckPayload = (): ReceiveTruckPayload => ({ + truckEntrance: toTruckEntrancePayload({ + ...truckForm, + ...(pendingContainerBooking + ? { + assignedEquipmentNumber: selectedContainerNumbers.join(', '), + unitCount: selectedContainerNumbers.length, + netWeightKg: selectedContainerWeight, + } + : {}), + }), + ...(pendingContainerBooking ? { containerNumbers: selectedContainerNumbers } : {}), + }); + + /** Stage the truck on screen and clear the form for the next one. */ + const addTruck = () => { + const error = truckFormError(); + if (error) { + toast({ variant: 'destructive', title: error }); return; } - if (containerCapacityError) { - toast({ variant: 'destructive', title: 'Truck capacity exceeded', description: containerCapacityError }); + setStagedTrucks((current) => [...current, currentTruckPayload()]); + setTruckForm(emptyTruckEntrance()); + setSelectedContainerNumbers([]); + setSelectedCustomerTruckId(null); + setLockedTruckFields({}); + }; + + const removeStagedTruck = (index: number) => + setStagedTrucks((current) => current.filter((_, position) => position !== index)); + + const receive = async () => { + // With trucks staged, a part-filled form is the operator still typing the + // next truck — receiving would silently drop it, so make them finish or + // clear it. An empty form just means every truck is already staged. + const formTouched = + truckForm.truckPlateNumber.trim() !== '' || + truckForm.driverName.trim() !== '' || + selectedContainerNumbers.length > 0; + + if (stagedTrucks.length > 0 && !formTouched) { + await receiveBookings(pendingReceiveIds, undefined, undefined, stagedTrucks); return; } + + const error = truckFormError(); + if (error) { + toast({ + variant: 'destructive', + title: error, + ...(stagedTrucks.length > 0 + ? { description: 'Finish this truck or clear it, then receive the arrival.' } + : {}), + }); + return; + } + + if (stagedTrucks.length > 0) { + await receiveBookings(pendingReceiveIds, undefined, undefined, [ + ...stagedTrucks, + currentTruckPayload(), + ]); + return; + } + + const single = currentTruckPayload(); await receiveBookings( pendingReceiveIds, - toTruckEntrancePayload({ - ...truckForm, - ...(pendingContainerBooking - ? { - assignedEquipmentNumber: selectedContainerNumbers.join(', '), - unitCount: selectedContainerNumbers.length, - netWeightKg: selectedContainerWeight, - } - : {}), - }), - pendingContainerBooking ? selectedContainerNumbers : undefined, + single.truckEntrance, + single.containerNumbers, ); }; @@ -1629,6 +1715,44 @@ function EligibleTab({ + {stagedTrucks.length > 0 ? ( + + + Trucks in this arrival ({stagedTrucks.length}) + + {stagedTrucks.map((truck, index) => ( + + + + {truck.truckEntrance.truckPlateNumber} + {truck.truckEntrance.driverName ? ` — ${truck.truckEntrance.driverName}` : ''} + + + {truck.containerNumbers?.length + ? truck.containerNumbers.join(', ') + : 'Bulk arrival'} + + + + + ))} + + ) : null} - - - + + + + diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts index 47107ff9a..dae85dad9 100644 --- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts +++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts @@ -588,14 +588,31 @@ export interface EligibleBooking { remainingContainerCount: number; } +/** + * One truck in a multi-truck arrival, with the containers it carries. Each + * truck keeps its own plate, driver and load, and the API validates capacity + * and container-to-plate assignment per truck. + */ +export interface ReceiveTruckPayload { + truckEntrance: TruckEntrancePayload; + containerNumbers?: string[]; + /** Defaults to the operation's bookingIds when omitted. */ + bookingIds?: string[]; +} + export interface BulkReceivePayload { direction: 'IMPORT' | 'EXPORT'; warehouseId: string; yardId: string; zoneId: string; bookingIds: string[]; + /** + * Single-truck arrival. Superseded by `trucks` when several trucks deliver + * the same arrival; the API accepts either shape. + */ containerNumbers?: string[]; truckEntrance?: TruckEntrancePayload; + trucks?: ReceiveTruckPayload[]; } export interface BulkReceiveResult { From cb150e46b7156cbb686dfbbdbae825c91452fd51 Mon Sep 17 00:00:00 2001 From: hager Date: Sun, 6 Sep 2026 20:02:10 +0000 Subject: [PATCH 08/11] feat(warehouses): pass a whole booking when its cargo is inspected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marking a received item inspected advanced only the rows the operator ticked. A booking's cargo spans one inventory row per container, and a multi-truck arrival files a GRN batch per truck, so a six-container booking stayed half-inspected and never reached Ready To Load. Widen the selection to every still-inspectable row of the same booking before inspecting. Each row is then passed and advanced exactly as before — EXPORT to READY_FOR_LOADING, IMPORT to READY_FOR_PICKUP — so the whole booking moves together. The rows the operator actually ticked lead the response, and are kept even when ineligible so their skip reason still surfaces rather than being dropped silently. Rows already PASSED, rows in a status that cannot be inspected, and rows belonging to another booking are left alone. Ad-hoc inventory with no booking expands to itself. Also show yards by name in the receive and received queues. They selected yards.code, so the route column read KALITY, the internal code for the yard everyone calls GMP / Gelan Multipurpose Port. Verified: freight-api type-check passes; both new SQL statements EXPLAIN-validated against the dev database; 14 warehouse suites pass (78 tests, 5 of them new). Co-Authored-By: Claude Opus 5 --- .../inspection-booking-cascade.spec.ts | 72 +++++++++++++++++++ .../warehouses/warehouse-inventory.service.ts | 57 +++++++++++++-- 2 files changed, 124 insertions(+), 5 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/warehouses/inspection-booking-cascade.spec.ts diff --git a/apps/edr-freight-api/src/modules/warehouses/inspection-booking-cascade.spec.ts b/apps/edr-freight-api/src/modules/warehouses/inspection-booking-cascade.spec.ts new file mode 100644 index 000000000..79034312f --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/inspection-booking-cascade.spec.ts @@ -0,0 +1,72 @@ +import { WarehouseInventoryService } from './warehouse-inventory.service'; + +/** + * A booking's cargo spans one inventory row per container, and a multi-truck + * arrival files a GRN batch per truck. Inspection is a judgement on the cargo, + * not on the row it happens to sit in, so ticking one row must pass every + * still-inspectable row of the same booking — otherwise a six-container + * booking stays half-inspected and never reaches Ready To Load. + * + * Only the DataSource is touched, so the instance is built off the prototype + * rather than stubbing all 20-odd collaborators. + */ +type Expand = (inventoryIds: string[], eligibleStatuses: string[]) => Promise; + +const ELIGIBLE = ['UNLOADED', 'RECEIVED', 'STORED', 'RESERVED']; + +function makeExpand(rows: Array<{ id: string }>) { + const query = jest.fn().mockResolvedValue(rows); + const service = Object.create(WarehouseInventoryService.prototype) as Record; + service.dataSource = { query }; + const expand = ( + service as unknown as { expandInspectionToBooking: Expand } + ).expandInspectionToBooking.bind(service); + return { expand, query }; +} + +describe('bulkMarkInspected — booking cascade', () => { + it('pulls in the booking siblings of a selected row', async () => { + const { expand } = makeExpand([{ id: 'inv-1' }, { id: 'inv-2' }, { id: 'inv-3' }]); + + await expect(expand(['inv-1'], ELIGIBLE)).resolves.toEqual([ + 'inv-1', + 'inv-2', + 'inv-3', + ]); + }); + + it('leads with the rows the operator actually ticked', async () => { + // The response the operator reads should open with their own selection, + // whatever order the database returned the siblings in. + const { expand } = makeExpand([{ id: 'inv-3' }, { id: 'inv-2' }, { id: 'inv-1' }]); + + const result = await expand(['inv-1'], ELIGIBLE); + + expect(result[0]).toBe('inv-1'); + expect(result.slice(1).sort()).toEqual(['inv-2', 'inv-3']); + }); + + it('never repeats a row when two siblings are both selected', async () => { + const { expand } = makeExpand([{ id: 'inv-1' }, { id: 'inv-2' }]); + + const result = await expand(['inv-1', 'inv-2'], ELIGIBLE); + + expect(result).toEqual(['inv-1', 'inv-2']); + expect(new Set(result).size).toBe(result.length); + }); + + it('passes the eligible statuses to the query rather than hard-coding them', async () => { + const { expand, query } = makeExpand([{ id: 'inv-1' }]); + + await expand(['inv-1'], ELIGIBLE); + + expect(query).toHaveBeenCalledWith(expect.any(String), [['inv-1'], ELIGIBLE]); + }); + + it('does not query at all for an empty selection', async () => { + const { expand, query } = makeExpand([]); + + await expect(expand([], ELIGIBLE)).resolves.toEqual([]); + expect(query).not.toHaveBeenCalled(); + }); +}); 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 ef68f3673..5e4e7430d 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 @@ -1441,8 +1441,10 @@ export class WarehouseInventoryService { -- 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", + -- Operators know a yard by its name: KALITY is universally called + -- GMP / Gelan Multipurpose Port. Code is only a fallback. + COALESCE(oy.label, oy.code) AS "origin", + COALESCE(dy.label, dy.code) AS "destination", oy.country AS "originCountry", dy.country AS "destinationCountry", b.freight_type AS "freightType", @@ -2032,8 +2034,10 @@ export class WarehouseInventoryService { COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType", inv.weight AS "weight", COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber", - oy.code AS "origin", - dy.code AS "destination", + -- Operators know a yard by its name: KALITY is universally called + -- GMP / Gelan Multipurpose Port. Code is only a fallback. + COALESCE(oy.label, oy.code) AS "origin", + COALESCE(dy.label, dy.code) AS "destination", oy.country AS "originCountry", dy.country AS "destinationCountry", inv.inspection_status AS "inspectionStatus", @@ -3075,7 +3079,15 @@ export class WarehouseInventoryService { // UNLOADED added for Batch 9 import destination inspection (arrived-train unload landing state). const eligible = ['UNLOADED', 'RECEIVED', 'STORED', 'RESERVED']; - for (const inventoryId of dto.inventoryIds) { + // Inspection is a judgement on the booking's cargo, not on the row it + // happens to sit in. A booking's cargo spans one inventory row per + // container, and a multi-truck arrival adds a GRN batch per truck — so + // ticking one row passes every eligible row of the same booking and the + // whole booking advances together. Without this a six-container booking + // stayed half-inspected and never reached Ready To Load. + const inventoryIds = await this.expandInspectionToBooking(dto.inventoryIds, eligible); + + for (const inventoryId of inventoryIds) { const skip = (reason: string) => { result.skippedCount += 1; result.results.push({ inventoryId, status: 'SKIPPED', reason }); @@ -3144,6 +3156,41 @@ export class WarehouseInventoryService { return result; } + /** + * Widen a set of selected inventory rows to every still-inspectable row of + * the same booking. + * + * The originally selected ids are always kept, even when ineligible, so the + * caller still reports their skip reason rather than dropping them silently. + * Rows with no booking (ad-hoc inventory) expand to themselves. + */ + private async expandInspectionToBooking( + inventoryIds: string[], + eligibleStatuses: string[], + ): Promise { + if (inventoryIds.length === 0) return []; + const rows: Array<{ id: string }> = await this.dataSource.query( + `SELECT DISTINCT sibling.id AS id + FROM freight.warehouse_inventory selected + JOIN freight.warehouse_inventory sibling + ON sibling.booking_id = selected.booking_id + AND sibling.deleted_at IS NULL + AND sibling.inspection_status IS DISTINCT FROM 'PASSED' + AND sibling.status = ANY($2::text[]) + WHERE selected.id = ANY($1::uuid[]) + AND selected.deleted_at IS NULL + AND selected.booking_id IS NOT NULL + UNION + SELECT id FROM freight.warehouse_inventory + WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL`, + [inventoryIds, eligibleStatuses], + ); + // Selected rows first so their results lead the response the operator sees. + const expanded = rows.map((row) => row.id); + const selectedFirst = inventoryIds.filter((id) => expanded.includes(id)); + return [...selectedFirst, ...expanded.filter((id) => !selectedFirst.includes(id))]; + } + // ── Receive ────────────────────────────────────────────────────────────── private async acceptLastMileIfRequested(bookingId?: string | null): Promise { From 22250ebdb932430b2c76124efe2b117e3f610f70 Mon Sep 17 00:00:00 2001 From: hager Date: Sun, 6 Sep 2026 20:06:56 +0000 Subject: [PATCH 09/11] feat(warehouses): keep failed cargo off the train until it is re-passed An inspection report mirrored its outcome onto the inventory item but never touched the item's status, and the loading paths gated on status alone. Cargo that passed, reached READY_FOR_LOADING and was then re-inspected as FAILED kept that status and loaded anyway. Gate loading on the inspection outcome. load() is the single choke point every loading path runs through, including loadItemsOntoTrain, so the check sits there: nothing but PASSED travels, and the message names the outcome so the operator knows what to fix. A failed or under-review re-inspection also pulls the cargo back out of the ready queue. load() refuses it either way, but leaving it READY_FOR_* would keep it on the loading and pickup lists as though nothing had happened. Reversing a held inspection now needs a reason. Passing cargo whose current inspection is FAILED or NEEDS_REVIEW is rejected without remarks, so the record says why cargo that was deliberately held may now travel. A first-time pass is unaffected. Mark Selected as Inspected skips failed and under-review items instead of clearing them. Overturning a failure is a deliberate, reasoned act, never a side effect of ticking a row in a list; those items are reported back with a reason telling the operator to re-inspect them individually. Verified: freight-api type-check passes; 15 warehouse suites pass (90 tests, 12 of them new). Co-Authored-By: Claude Opus 5 --- .../warehouses/inspection-load-gate.spec.ts | 146 ++++++++++++++++++ .../warehouse-inspection.service.ts | 25 ++- .../warehouses/warehouse-inventory.service.ts | 19 +++ 3 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 apps/edr-freight-api/src/modules/warehouses/inspection-load-gate.spec.ts diff --git a/apps/edr-freight-api/src/modules/warehouses/inspection-load-gate.spec.ts b/apps/edr-freight-api/src/modules/warehouses/inspection-load-gate.spec.ts new file mode 100644 index 000000000..651ffd590 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/inspection-load-gate.spec.ts @@ -0,0 +1,146 @@ +import { BadRequestException } from '@nestjs/common'; + +import { WarehouseInspectionService } from './warehouse-inspection.service'; +import { WarehouseInventoryService } from './warehouse-inventory.service'; + +/** + * Cargo whose inspection failed or is under review must not travel. It becomes + * loadable only by being re-inspected and passed, and that reversal has to say + * why — the cargo was deliberately held, so its release is deliberate too. + * + * Only the collaborators each rule touches are stubbed; the instances are built + * off the prototype rather than wiring all 20-odd dependencies. + */ + +describe('load() — inspection gate', () => { + const loadWithInspection = (inspectionStatus: string | null) => { + const service = Object.create(WarehouseInventoryService.prototype) as Record; + service.findById = jest.fn().mockResolvedValue({ + id: 'inv-1', + status: 'READY_FOR_LOADING', + inspectionStatus, + warehouseId: 'w-1', + yardId: 'y-1', + zoneId: 'z-1', + }); + service.assertTransition = jest.fn(); + // Reached only if the gate lets the item through — failing loudly here + // proves the gate did NOT stop it. + service.scheduling = { + findWagon: jest.fn().mockRejectedValue(new Error('gate did not block')), + }; + return ( + service as unknown as { load: (id: string, dto: unknown) => Promise } + ).load.bind(service); + }; + + it.each(['FAILED', 'NEEDS_REVIEW'])('refuses to load %s cargo', async (status) => { + await expect(loadWithInspection(status)('inv-1', { wagonId: 'w' })).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it('refuses to load cargo that was never inspected', async () => { + await expect(loadWithInspection(null)('inv-1', { wagonId: 'w' })).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it('names the outcome so the operator knows what to fix', async () => { + await expect(loadWithInspection('FAILED')('inv-1', { wagonId: 'w' })).rejects.toThrow( + /FAILED/, + ); + }); + + it('lets passed cargo through the gate', async () => { + // It fails later, at the wagon lookup — which is the proof it got past the + // inspection gate rather than being stopped by it. + await expect(loadWithInspection('PASSED')('inv-1', { wagonId: 'w' })).rejects.toThrow( + 'gate did not block', + ); + }); +}); + +describe('inspection report — reversing a held inspection', () => { + const createReport = (previousStatus: string, itemStatus = 'RECEIVED') => { + const update = jest.fn().mockResolvedValue(undefined); + const service = Object.create(WarehouseInspectionService.prototype) as Record; + service.dataSource = { + getRepository: () => ({ + findOne: jest.fn().mockResolvedValue({ + id: 'inv-1', + bookingId: 'b-1', + inspectionStatus: previousStatus, + status: itemStatus, + }), + update, + }), + }; + service.inspectionRepository = { + findAll: jest.fn().mockResolvedValue([]), + create: jest.fn().mockResolvedValue({ id: 'rep-1' }), + }; + service.markImportPickupReadyAndAcceptLastMile = jest.fn().mockResolvedValue(undefined); + const create = ( + service as unknown as { + create: (id: string, dto: unknown) => Promise; + } + ).create.bind(service); + return { create, update }; + }; + + it.each(['FAILED', 'NEEDS_REVIEW'])( + 'rejects passing %s cargo with no reason given', + async (previous) => { + const { create } = createReport(previous); + + await expect( + create('inv-1', { reportType: 'INSPECTION', inspectionStatus: 'PASSED' }), + ).rejects.toBeInstanceOf(BadRequestException); + }, + ); + + it('rejects whitespace as a reason', async () => { + const { create } = createReport('FAILED'); + + await expect( + create('inv-1', { reportType: 'INSPECTION', inspectionStatus: 'PASSED', remarks: ' ' }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('accepts the reversal once a reason is recorded', async () => { + const { create } = createReport('FAILED'); + + await expect( + create('inv-1', { + reportType: 'INSPECTION', + inspectionStatus: 'PASSED', + remarks: 'Reworked packaging, re-weighed and verified.', + }), + ).resolves.toBeDefined(); + }); + + it('needs no reason for a first-time pass', async () => { + const { create } = createReport(null as unknown as string); + + await expect( + create('inv-1', { reportType: 'INSPECTION', inspectionStatus: 'PASSED' }), + ).resolves.toBeDefined(); + }); + + it('pulls failed cargo back out of the ready-to-load queue', async () => { + const { create, update } = createReport('PASSED', 'READY_FOR_LOADING'); + + await create('inv-1', { reportType: 'INSPECTION', inspectionStatus: 'FAILED' }); + + expect(update).toHaveBeenCalledWith('inv-1', { status: 'RECEIVED' }); + }); + + it('leaves cargo that never reached the ready queue where it is', async () => { + const { create, update } = createReport('PASSED', 'STORED'); + + await create('inv-1', { reportType: 'INSPECTION', inspectionStatus: 'FAILED' }); + + expect(update).not.toHaveBeenCalledWith('inv-1', { status: 'RECEIVED' }); + }); +}); 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 be1c68bbd..fd7c4cf6a 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 @@ -1,4 +1,4 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { NotificationAudience, NotificationType } from '@edr/types'; @@ -37,6 +37,20 @@ export class WarehouseInspectionService { throw new NotFoundException(`Inventory item ${inventoryId} not found`); } + // Overturning a held inspection is a deliberate act: the cargo was kept off + // the train, and the record has to say why it may now travel. A bare PASS + // with no remarks leaves the release unexplained. + const previousStatus = inventory.inspectionStatus; + if ( + dto.inspectionStatus === 'PASSED' && + (previousStatus === 'FAILED' || previousStatus === 'NEEDS_REVIEW') && + !dto.remarks?.trim() + ) { + throw new BadRequestException( + `Give a reason in Remarks for passing cargo whose inspection is ${previousStatus}`, + ); + } + const expected = dto.expectedWeight ?? null; const actual = dto.actualWeight ?? null; const weightLoss = expected !== null && actual !== null ? Math.max(0, expected - actual) : null; @@ -83,6 +97,15 @@ export class WarehouseInspectionService { if (dto.inspectionStatus === 'PASSED') { await this.markImportPickupReadyAndAcceptLastMile(inventoryId); + } else if ( + inventory.status === 'READY_FOR_LOADING' || + inventory.status === 'READY_FOR_PICKUP' + ) { + // A failed or under-review re-inspection pulls the cargo back out of the + // ready queue. load() refuses it either way, but leaving it READY_FOR_* + // would keep it sitting on the loading and pickup lists as if nothing + // had happened. + await inventoryRepo.update(inventoryId, { status: 'RECEIVED' }); } return report; 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 5e4e7430d..2c4fc256a 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 @@ -3096,6 +3096,13 @@ export class WarehouseInventoryService { const item = await this.inventoryRepository.findById(inventoryId); if (!item) { skip('Inventory not found'); continue; } if (item.inspectionStatus === 'PASSED') { skip('Already inspected'); continue; } + // Overturning a failure is a deliberate, reasoned act — never a side + // effect of ticking a row in a list. Those items are held back for an + // individual re-inspection that records why the cargo may now travel. + if (item.inspectionStatus === 'FAILED' || item.inspectionStatus === 'NEEDS_REVIEW') { + skip(`Inspection ${item.inspectionStatus} — re-inspect this item individually and give a reason`); + continue; + } if (!eligible.includes(item.status)) { skip(`Status ${item.status} not eligible for inspection`); continue; } // Reuse the existing inspection service: creates a minimal PASSED report + sets inspectionStatus/inspectedAt. @@ -5816,6 +5823,18 @@ export class WarehouseInventoryService { // 1. inventory status must be READY_FOR_LOADING (and not already LOADED). this.assertTransition(item.status, 'LOADED'); + // 1b. Failed or under-review cargo does not travel. Status alone is not + // enough: an item that passed, reached READY_FOR_LOADING and was then + // re-inspected as FAILED keeps that status, so the inspection outcome is + // checked here — the one choke point every loading path runs through. + if (item.inspectionStatus !== 'PASSED') { + throw new BadRequestException( + item.inspectionStatus + ? `Inspection is ${item.inspectionStatus} — the cargo must be re-inspected and passed, with a reason, before it can be loaded` + : 'Inventory must pass inspection before it can be loaded', + ); + } + // 2. inventory is at a valid warehouse/yard/zone location. if (!item.warehouseId || !item.yardId || !item.zoneId) { throw new BadRequestException('Inventory must be at a warehouse/yard/zone before loading'); From a66fee8eba28840cce51710530f11d47ea84cb63 Mon Sep 17 00:00:00 2001 From: marshal Date: Mon, 7 Sep 2026 07:56:22 +0000 Subject: [PATCH 10/11] import utility functions for booking operations in OperationRescheduleModal --- .../src/components/bookings/OperationRescheduleModal.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/OperationRescheduleModal.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/OperationRescheduleModal.tsx index 8fa1d5c46..0e31ceff4 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/OperationRescheduleModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/OperationRescheduleModal.tsx @@ -22,6 +22,11 @@ import { useBookingDetail, useBookingMutations, } from "@/hooks/bookings/useBookings"; +import { + eatDay, + exportTrainOption, + isExportRailBooking, +} from "@/features/bookings/shipmentDay"; export interface OperationRescheduleModalProps { bookingId: string; From cc7efe4af30ff47b356b0409a39142ae9f0bc65b Mon Sep 17 00:00:00 2001 From: marshal Date: Mon, 7 Sep 2026 08:03:20 +0000 Subject: [PATCH 11/11] temporarily disable crew gate enforcement on train dispatch --- .../services/train-scheduling.service.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index 9c83ed987..0cbe9c277 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -431,6 +431,12 @@ interface BookingWindowRow { route_stations: string[] | null; } +/** + * Dispatch crew gate (ITLMS Rolling Stock §1.2). Temporarily disabled so a + * train can depart with no crew assigned; set back to true to enforce. + */ +const ENFORCE_CREW_GATE_ON_DISPATCH = false; + @Injectable() export class TrainSchedulingService { private readonly logger = new Logger(TrainSchedulingService.name); @@ -3198,7 +3204,11 @@ export class TrainSchedulingService { // Stock §1.2 enforces composition "prior to departure", so an incomplete // crew saves freely on the assignment page but cannot depart. Optional // dependency: the positional spec constructors omit it. - await this.trainCrewAssignments?.assertCrewReadyForDispatch(scheduleId); + // TODO(crew-gate): temporarily off so trains can dispatch with no crew + // assigned. Flip ENFORCE_CREW_GATE_ON_DISPATCH once crew rostering is in use. + if (ENFORCE_CREW_GATE_ON_DISPATCH) { + await this.trainCrewAssignments?.assertCrewReadyForDispatch(scheduleId); + } // Staff may record the departure after the fact — past is fine, future is not. const now = dto.actualDepartureAt ? new Date(dto.actualDepartureAt) : new Date(); this.assertNotFuture(now, 'Departure time');