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/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-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-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'); 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/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/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 ef68f3673..a1fa835df 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", @@ -1580,335 +1582,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 +1996,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, + } + : {}), + }); + } } }); @@ -2032,8 +2081,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 +3126,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 }); @@ -3084,6 +3143,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. @@ -3144,6 +3210,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 { @@ -5769,6 +5870,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'); 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/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index cfcf3ef4f..b8b020148 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([]); @@ -2395,7 +2399,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 1f64a6478..61c4c5b7f 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 @@ -624,7 +624,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/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/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 5809a7b39..1d69a892a 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/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/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/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({ 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/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(", ")} 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/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 { 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 f064ea48b..507c29df4 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -58,6 +58,8 @@ import { ExportTrainPicker, OperationDatePicker, } from "@edr/ui-common"; + +import { usePaymentCurrencies } from "@/hooks/usePaymentCurrencies"; import { api } from "@/services/api"; import { contractsService, @@ -1327,6 +1329,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). @@ -1483,7 +1486,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} />