diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index 3e086101c..854594ffc 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -81,6 +81,32 @@ export const WagonTransferFulfill = () => export const WagonTransferHistoryAll = () => BookingStaff(FREIGHT_PERMS.wagons.transferHistoryAll); +/** + * Open the transfer-requests desk. `wagons:view` is accepted as a one-of + * fallback so staff who could already reach the queue keep it without a + * re-grant — same pattern the granular fleet keys use. + */ +export const WagonTransferView = () => + BookingStaff([FREIGHT_PERMS.wagons.transferView, FREIGHT_PERMS.wagons.view]); + +/** Withdraw a request that has not moved any wagon yet. */ +export const WagonTransferCancel = () => + BookingStaff([ + FREIGHT_PERMS.wagons.transferCancel, + FREIGHT_PERMS.wagons.transferRequest, + ]); + +/** + * End a request short of the requested count. Whoever may move wagons may also + * declare the yard has no more to give, so fulfil is accepted alongside the + * dedicated key. + */ +export const WagonTransferCloseShort = () => + BookingStaff([ + FREIGHT_PERMS.wagons.transferCloseShort, + FREIGHT_PERMS.wagons.transferFulfill, + ]); + /** Org-administration endpoints (user mgmt, billing config, company CRUD, settings). */ export const FreightAdmin = () => BookingStaff(FREIGHT_PERMS.admin); diff --git a/apps/edr-freight-api/src/migrations/2920000000000-AddRevisionActorName.ts b/apps/edr-freight-api/src/migrations/2920000000000-AddRevisionActorName.ts new file mode 100644 index 000000000..2263d78fc --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2920000000000-AddRevisionActorName.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Store WHO made a contract edit as a name, not just an id. Denormalised on + * purpose: an audit trail must still read correctly after the user is renamed, + * deactivated or deleted, and `iam.users` lives outside this module's schema. + */ +export class AddRevisionActorName2920000000000 implements MigrationInterface { + name = 'AddRevisionActorName2920000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.contract_document_revisions ADD COLUMN IF NOT EXISTS actor_name varchar(200);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.contract_document_revisions DROP COLUMN IF EXISTS actor_name;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2930000000000-AddDoCollectionDates.ts b/apps/edr-freight-api/src/migrations/2930000000000-AddDoCollectionDates.ts new file mode 100644 index 000000000..2a7797d82 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2930000000000-AddDoCollectionDates.ts @@ -0,0 +1,42 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Djibouti GL must record WHEN the vessel arrived and WHEN the Delivery Order + * was collected, not just attach the DO file. Both are mandatory on DO upload + * (enforced in the clearance services), so the columns are new and nullable — + * DOs uploaded before this change have no dates to backfill. + * + * `vessel_departure_date` is the EXPORT Release-Order date and stays as-is; the + * import arrival date gets its own column rather than overloading it. + */ +export class AddDoCollectionDates2930000000000 implements MigrationInterface { + name = 'AddDoCollectionDates2930000000000'; + + public async up(queryRunner: QueryRunner): Promise { + for (const table of [ + 'freight.contract_clearance_cycles', + 'freight.bookings', + ]) { + await queryRunner.query( + `ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS vessel_arrival_date date;`, + ); + await queryRunner.query( + `ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS do_collected_date date;`, + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + for (const table of [ + 'freight.contract_clearance_cycles', + 'freight.bookings', + ]) { + await queryRunner.query( + `ALTER TABLE ${table} DROP COLUMN IF EXISTS do_collected_date;`, + ); + await queryRunner.query( + `ALTER TABLE ${table} DROP COLUMN IF EXISTS vessel_arrival_date;`, + ); + } + } +} diff --git a/apps/edr-freight-api/src/migrations/2930000000000-AddWagonTransferPartialFulfilment.ts b/apps/edr-freight-api/src/migrations/2930000000000-AddWagonTransferPartialFulfilment.ts new file mode 100644 index 000000000..a9cc5e74d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2930000000000-AddWagonTransferPartialFulfilment.ts @@ -0,0 +1,40 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Partial wagon-transfer fulfilment. + * + * A request for 50 wagons no longer has to be met in one go: OCC moves what the + * source yard can spare, whenever it can, and the request stays open until the + * full count is met (FULFILLED) or OCC ends it short (CLOSED_SHORT) so the + * requester can ask another yard for the rest. + * + * Existing rows are back-filled so history keeps reading correctly: a FULFILLED + * request delivered its whole quantity; anything else delivered nothing. + */ +export class AddWagonTransferPartialFulfilment2930000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagon_transfer_requests + ADD COLUMN IF NOT EXISTS fulfilled_quantity integer NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS closed_short_at timestamptz NULL, + ADD COLUMN IF NOT EXISTS closed_short_by_user_id uuid NULL + `); + await queryRunner.query(` + UPDATE freight.wagon_transfer_requests + SET fulfilled_quantity = quantity + WHERE status = 'FULFILLED' + AND fulfilled_quantity = 0 + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagon_transfer_requests + DROP COLUMN IF EXISTS fulfilled_quantity, + DROP COLUMN IF EXISTS closed_short_at, + DROP COLUMN IF EXISTS closed_short_by_user_id + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2940000000000-AddBookingRequestCurrency.ts b/apps/edr-freight-api/src/migrations/2940000000000-AddBookingRequestCurrency.ts new file mode 100644 index 000000000..8d3fde437 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2940000000000-AddBookingRequestCurrency.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Currency moved from the contract to the shipment: a contract now quotes in + * USD and the customer picks the billing currency per booking. On a customs + * contract GL books on the customer's behalf, so the shipment request is where + * the customer states the currency — GL reads it when creating the booking. + * + * Nullable: requests submitted before this change fall back to the contract's + * own currency, which is exactly what their bookings already used. + */ +export class AddBookingRequestCurrency2940000000000 implements MigrationInterface { + name = 'AddBookingRequestCurrency2940000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.booking_requests ADD COLUMN IF NOT EXISTS payment_currency varchar(5);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.booking_requests DROP COLUMN IF EXISTS payment_currency;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2940000000000-AddFileVersionHistory.ts b/apps/edr-freight-api/src/migrations/2940000000000-AddFileVersionHistory.ts new file mode 100644 index 000000000..ad9bae99c --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2940000000000-AddFileVersionHistory.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Keep every version of a stored document. + * + * Replacing a file used to DELETE the previous row outright, so a staff + * correction erased the customer's original upload with no trail. Superseded + * versions are now soft-deleted (already excluded from every read by TypeORM's + * soft-delete filter) and stamped with who replaced them and why, which is what + * the document's version history reads back. + */ +export class AddFileVersionHistory2940000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.files + ADD COLUMN IF NOT EXISTS replaced_by_user_id uuid NULL, + ADD COLUMN IF NOT EXISTS replace_reason text NULL + `); + // History reads walk one document's versions, deleted rows included. + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_files_version_history" + ON freight.files (resource, resource_id, code, created_at DESC) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_files_version_history"`); + await queryRunner.query(` + ALTER TABLE freight.files + DROP COLUMN IF EXISTS replaced_by_user_id, + DROP COLUMN IF EXISTS replace_reason + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2950000000000-AddTransitAssigneeHandshake.ts b/apps/edr-freight-api/src/migrations/2950000000000-AddTransitAssigneeHandshake.ts new file mode 100644 index 000000000..bd9647d44 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2950000000000-AddTransitAssigneeHandshake.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Transit-assignee handshake before the customs declaration. + * + * GL Ethiopia must ask GL Djibouti who will handle the shipment in transit, and + * Djibouti answers with a name, before the declaration can be filed. The whole + * exchange lives on the clearance cycle so it repeats naturally with each cycle + * of a GENERAL contract. + */ +export class AddTransitAssigneeHandshake2950000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.contract_clearance_cycles + ADD COLUMN IF NOT EXISTS transit_assignee_requested_at timestamptz NULL, + ADD COLUMN IF NOT EXISTS transit_assignee_requested_by_user_id uuid NULL, + ADD COLUMN IF NOT EXISTS transit_assignee_request_note text NULL, + ADD COLUMN IF NOT EXISTS transit_assignee_name text NULL, + ADD COLUMN IF NOT EXISTS transit_assignee_assigned_at timestamptz NULL, + ADD COLUMN IF NOT EXISTS transit_assignee_assigned_by_user_id uuid NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.contract_clearance_cycles + DROP COLUMN IF EXISTS transit_assignee_requested_at, + DROP COLUMN IF EXISTS transit_assignee_requested_by_user_id, + DROP COLUMN IF EXISTS transit_assignee_request_note, + DROP COLUMN IF EXISTS transit_assignee_name, + DROP COLUMN IF EXISTS transit_assignee_assigned_at, + DROP COLUMN IF EXISTS transit_assignee_assigned_by_user_id + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index 290b46433..3908dbbac 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -200,7 +200,7 @@ export class BookingPricingService { // route's container freight, never a frozen OVERWEIGHT_PER_TON value. const frozen = isDerived ? null - : this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency); + : this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency, usdToEtb); const unitAmount = frozen ? Number(frozen.unitPrice) : isEtbBooking @@ -547,14 +547,15 @@ export class BookingPricingService { booking.originYardId, booking.destinationYardId, ); - // H15: frozen contract rate for this container size, when present — its - // unitPrice is already in the booking currency (no USD→currency convert). - // It also stands on its own: a contract line prices off the agreed rate - // even when nobody configured a live rate for this leg + type yet. + // H15: frozen contract rate for this container size, when present — + // converted into the booking currency by frozenRateForContainer. It also + // stands on its own: a contract line prices off the agreed rate even when + // nobody configured a live rate for this leg + type yet. const frozen = await this.frozenRateForContainer( frozenRates, container.containerTypeId, paymentCurrency, + usdToEtb, ); const label = await this.containerTypeLabel(container.containerTypeId); if (!rate && !frozen) { @@ -632,7 +633,7 @@ export class BookingPricingService { const unitUsd = Number(fallback.rateValue); // H15: bulk freight uses the frozen BULK_FREIGHT snapshot when present. const frozen = isBulk - ? this.frozenRateByCode(frozenRates, 'BULK_FREIGHT', paymentCurrency) + ? this.frozenRateByCode(frozenRates, 'BULK_FREIGHT', paymentCurrency, usdToEtb) : null; let amount: number; let unitAmount: number; @@ -744,12 +745,13 @@ export class BookingPricingService { break; } - // H15: frozen mile rate (already in booking currency) when the contract - // has one; else the live USD rate converted as before. + // H15: frozen mile rate (converted into the booking currency) when the + // contract has one; else the live USD rate converted as before. const frozen = this.frozenRateByCode( frozenRates, leg.rateType, paymentCurrency, + usdToEtb, ); let amount: number; let unitAmount: number; @@ -908,20 +910,45 @@ export class BookingPricingService { } /** - * The frozen snapshot for a rate code, or null when there is none, its price - * is negative, or it is in a different currency than the booking (in which - * case the live-rate path is safer than a mis-converted frozen price). + * The frozen snapshot for a rate code, expressed in the BOOKING's currency. + * + * A contract quotes in USD and freezes USD unit prices; the customer chooses + * the billing currency per booking. So a currency mismatch is the normal case + * now, not an error — the snapshot is converted rather than discarded. (It + * previously returned null on mismatch, which silently dropped the agreed + * contract price and re-priced the booking at whatever the live rate had + * drifted to.) Grandfathered ETB contracts convert the other way for the same + * reason. + * + * Returns null only when there is no snapshot or its price is unusable. */ private frozenRateByCode( frozenRates: Map | null, code: string, bookingCurrency: string, + usdToEtb: number, ): ContractRateSnapshot | null { const snap = frozenRates?.get(code); if (!snap) return null; - if (snap.currency !== bookingCurrency) return null; - if (!(Number(snap.unitPrice) >= 0)) return null; - return snap; + const unitPrice = Number(snap.unitPrice); + if (!(unitPrice >= 0)) return null; + if (snap.currency === bookingCurrency) return snap; + + // Only USD <-> ETB exist; a rate of 0/NaN would silently zero the price. + if (!(usdToEtb > 0)) return null; + const converted = + snap.currency === 'USD' && bookingCurrency === 'ETB' + ? Math.round(unitPrice * usdToEtb) + : snap.currency === 'ETB' && bookingCurrency === 'USD' + ? unitPrice / usdToEtb + : null; + if (converted == null) return null; + + // A copy — the snapshot rows are shared across the pricing pass. + return Object.assign(Object.create(Object.getPrototypeOf(snap)), snap, { + unitPrice: converted, + currency: bookingCurrency, + }) as ContractRateSnapshot; } /** @@ -933,6 +960,7 @@ export class BookingPricingService { frozenRates: Map | null, containerTypeId: string, bookingCurrency: string, + usdToEtb: number, ): Promise { if (!frozenRates) return null; let sizeFt: number | null = null; @@ -942,7 +970,7 @@ export class BookingPricingService { return null; } if (!sizeFt) return null; - return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency); + return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency, usdToEtb); } /** @@ -985,7 +1013,7 @@ export class BookingPricingService { const hasPerSizeSnapshot = frozenRates?.has('CUSTOMS_CLEARANCE_20FT') || frozenRates?.has('CUSTOMS_CLEARANCE_40FT'); - const legacyFlat = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency); + const legacyFlat = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency, usdToEtb); if (legacyFlat && !hasPerSizeSnapshot) { const amount = Number(legacyFlat.unitPrice); if (amount > 0) { @@ -1014,7 +1042,7 @@ export class BookingPricingService { // unknown type — falls through to the live per-type lookup below } const frozen = sizeFt - ? this.frozenRateByCode(frozenRates, `CUSTOMS_CLEARANCE_${sizeFt}FT`, currency) + ? this.frozenRateByCode(frozenRates, `CUSTOMS_CLEARANCE_${sizeFt}FT`, currency, usdToEtb) : null; const live = onLeg.find((r) => r.containerTypeId === bc.containerTypeId); if (!frozen && !live) { @@ -1049,7 +1077,7 @@ export class BookingPricingService { // flat snapshot share the CUSTOMS_CLEARANCE code; both are the agreed fee. // Live lookup: the rate scoped to the booking's commodity wins; a // commodity-less rate (legacy) is the catch-all fallback. - const frozen = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency); + const frozen = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency, usdToEtb); const live = (booking.cargoTypeId ? onLeg.find( diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index c292a0395..4ebfc9fab 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -916,14 +916,15 @@ export class BookingsController { async uploadBookingDeliveryOrder( @Param('id', ParseUUIDPipe) id: string, @UploadedFile() file: Express.Multer.File, - @Body('vesselDepartureDate') vesselDepartureDate: string | undefined, + @Body('vesselArrivalDate') vesselArrivalDate: string | undefined, + @Body('doCollectedDate') doCollectedDate: string | undefined, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingClearanceService.uploadDeliveryOrder( id, file, resolveAuthUserId(user), - vesselDepartureDate, + { vesselArrivalDate, doCollectedDate }, ); return this.transitionService.enrichBookingResponse(booking); } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 590bd7262..a0b3b4e0e 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -123,7 +123,9 @@ export class BookingsRepository extends BaseRepository { 'booking.files', FileRecord, 'file', - "file.resource_id = booking.id AND file.resource = 'bookings'", + // Superseded versions are soft-deleted, not dropped — keep them out of + // the live file list (a manual join condition is not filtered for us). + "file.resource_id = booking.id AND file.resource = 'bookings' AND file.deleted_at IS NULL", ) .getOne(); diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index e47ed378c..4ab9e397e 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -540,6 +540,14 @@ export class Booking extends BaseEntity { @Column({ name: 'vessel_departure_date', type: 'date', nullable: true }) vesselDepartureDate?: string | null; + /** Import DO: when the vessel arrived in Djibouti. Required on DO upload. */ + @Column({ name: 'vessel_arrival_date', type: 'date', nullable: true }) + vesselArrivalDate?: string | null; + + /** Import DO: when GL Djibouti collected the DO. Required on DO upload. */ + @Column({ name: 'do_collected_date', type: 'date', nullable: true }) + doCollectedDate?: string | null; + @Column({ name: 'ro_amendment_requested_at', type: 'timestamptz', nullable: true }) roAmendmentRequestedAt?: Date | null; diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 3867bef3a..6650dfca5 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -5,7 +5,7 @@ import { BadRequestException, ForbiddenException, } from "@nestjs/common"; -import { DataSource } from "typeorm"; +import { DataSource, EntityManager } from "typeorm"; import { CompaniesRepository } from "./companies.repository"; import { CompanyProfileRepository } from "./company-profile.repository"; import { CompanyChangeRequestRepository } from "./company-change-request.repository"; @@ -1111,9 +1111,11 @@ export class CompaniesService { } // Anything other than approval has no document gate and no concurrency - // hazard — apply it directly. + // hazard — no row lock, just the write. if (status !== ProfileStatus.Active) { - return this.applyProfileStatus(existing, status, note, reviewerId); + return this.dataSource.transaction((manager) => + this.applyProfileStatus(manager, existing, status, note, reviewerId), + ); } // Approving over an outstanding document correction would silently accept the @@ -1149,7 +1151,7 @@ export class CompaniesService { ); } - return this.applyProfileStatus(existing, status, note, reviewerId); + return this.applyProfileStatus(manager, existing, status, note, reviewerId); }); } @@ -1160,11 +1162,21 @@ export class CompaniesService { * transaction while every other status skips that overhead. */ private async applyProfileStatus( + manager: EntityManager, existing: CompanyProfile, status: ProfileStatus, note?: string, reviewerId?: string, ): Promise { + // Every write below goes through `manager`. The approval path holds a + // pessimistic_write lock on the company row, and the injected repositories + // are bound to the DataSource's default pool — writing the same row through + // one of them would block on a lock this very transaction holds, hanging the + // request until the statement timed out. That deadlocked the first approval + // of any customer: the profile went Active on its own connection while the + // company stayed Pending and the caller never got a response. + const profileRepo = manager.getRepository(CompanyProfile); + const companyRepo = manager.getRepository(Company); // A reference number is only minted the first time a profile is approved // (status → Active). Pending/unapproved profiles carry no reference. const patch: Partial = { status }; @@ -1190,7 +1202,8 @@ export class CompaniesService { patch.reviewedAt = new Date(); } - const updated = await this.companyProfilesRepo.update(existing.id, patch); + await profileRepo.update(existing.id, patch); + const updated = await profileRepo.findOne({ where: { id: existing.id } }); if (!updated) throw new NotFoundException(`Company profile ${existing.id} not found`); @@ -1208,7 +1221,9 @@ export class CompaniesService { : "approved" : null; if (change) { - const company = await this.companiesRepo.findById(updated.companyId); + const company = await companyRepo.findOne({ + where: { id: updated.companyId }, + }); if (company) { this.companyNotifier.profileStatusChanged( company, @@ -1222,7 +1237,7 @@ export class CompaniesService { status === ProfileStatus.Active && company.status === CompanyStatus.Pending ) { - await this.companiesRepo.update(updated.companyId, { + await companyRepo.update(updated.companyId, { status: CompanyStatus.Active, }); this.companyNotifier.companyApproved(company); diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts index 810232993..69773010c 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts @@ -19,6 +19,7 @@ import { } from './entities/clearance-milestone.entity'; import { Booking } from '../bookings/entities/booking.entity'; import { clearanceCodesForBooking } from '../bookings/clearance.util'; +import { assertDoCollectionDates } from './contract-clearance.util'; import { ClearanceWorkflowService } from './clearance-workflow.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { GlOperationsService } from './gl-operations.service'; @@ -64,6 +65,9 @@ export interface BookingClearanceView { roHold?: boolean; roHoldReason?: string | null; vesselDepartureDate?: string | null; + /** Import DO dates recorded by GL Djibouti on upload. */ + vesselArrivalDate?: string | null; + doCollectedDate?: string | null; roAmendmentRequestedAt?: string | null; operationReady?: boolean; preClearanceFinalized?: boolean; @@ -261,6 +265,8 @@ export class BookingClearanceService { roHold: Boolean(booking.roHoldReason), roHoldReason: booking.roHoldReason ?? null, vesselDepartureDate: booking.vesselDepartureDate ?? null, + vesselArrivalDate: booking.vesselArrivalDate ?? null, + doCollectedDate: booking.doCollectedDate ?? null, roAmendmentRequestedAt: booking.roAmendmentRequestedAt ? booking.roAmendmentRequestedAt.toISOString() : null, @@ -547,7 +553,7 @@ export class BookingClearanceService { bookingId: string, file: Express.Multer.File, userId?: string, - vesselDepartureDate?: string, + dates?: { vesselArrivalDate?: string; doCollectedDate?: string }, ): Promise { const booking = await this.loadBooking(bookingId); if (booking.tradeDirection !== 'IMPORT') { @@ -556,6 +562,8 @@ export class BookingClearanceService { if (!file) throw new BadRequestException('No Delivery Order uploaded'); + const { vesselArrivalDate, doCollectedDate } = assertDoCollectionDates(dates); + // DO upload is deliberately un-gated: GL Djibouti may attach it at any point, // any file type. The DO_COLLECTED milestone (and operation readiness) still // waits for GL Ethiopia to finalize pre-clearance so the workflow order holds. @@ -566,11 +574,10 @@ export class BookingClearanceService { file, }); - if (vesselDepartureDate?.trim()) { - await this.bookingsRepository.update(bookingId, { - vesselDepartureDate: vesselDepartureDate.trim(), - } as never); - } + await this.bookingsRepository.update(bookingId, { + vesselArrivalDate, + doCollectedDate, + } as never); if (booking.preClearanceFinalizedAt) { await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId); 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 a17406d64..8d26799c5 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 @@ -30,18 +30,35 @@ export class BookingRequestService { private readonly notifier: ContractNotifierService, ) {} - /** Only GENERAL contracts that bundle customs use the request → GL → clearance flow. */ - private assertGeneralCustoms(contract: Contract): void { - if ( - contract.contractKind !== 'GENERAL' || - !contract.customsClearingEnabled - ) { + /** + * Shipment requests exist because on a CUSTOMS contract the customer never + * books directly — GL Ethiopia does it for them. The request is how the + * customer states what to ship and, now, which currency to be invoiced in. + * + * GENERAL: each request opens its own per-booking clearance instance. + * ONE_TIME: clearance already ran at the contract level, so the request only + * records the customer's intent; GL creates the single booking from it. + */ + private assertCustomsContract(contract: Contract): void { + if (!contract.customsClearingEnabled) { throw new BadRequestException( - 'Shipment requests apply only to general customs-clearance contracts.', + 'Shipment requests apply only to customs-clearance contracts.', ); } } + /** + * Statuses in which a ONE_TIME customs contract may take a shipment request: + * both signatures are in and the contract is at (or past) its clearance + * phase, but GL has not booked yet. + */ + private static readonly ONE_TIME_REQUESTABLE_STATUSES = [ + 'FULLY_EXECUTED', + 'AWAITING_CLEARANCE_DOCUMENTS', + 'CLEARANCE_UNDER_REVIEW', + 'CLEARANCE_READY_FOR_BOOKING', + ]; + /** Customer submits a shipment request. */ async submit( contractId: string, @@ -50,13 +67,35 @@ export class BookingRequestService { ): Promise { const contract = await this.contractsService.findById(contractId); await this.contractsService.assertCustomerCanAccessContract(userId, contract); - this.assertGeneralCustoms(contract); + this.assertCustomsContract(contract); + const isOneTime = contract.contractKind === 'ONE_TIME'; + if (contract.status === 'CONTRACT_CLOSED') { throw new ConflictException( 'This contract is completed — the full contracted quantity has been booked.', ); } - if (contract.status !== 'CONTRACT_ACTIVE') { + if (isOneTime) { + if ( + !BookingRequestService.ONE_TIME_REQUESTABLE_STATUSES.includes( + contract.status, + ) + ) { + throw new ConflictException( + 'The contract must be fully executed before requesting its shipment.', + ); + } + // A one-time contract carries exactly one shipment, so it carries at most + // one open request — otherwise GL sees two conflicting currencies. + const open = (await this.repo.findForContract(contractId)).find( + (r) => r.status === 'PENDING', + ); + if (open) { + throw new ConflictException( + `Shipment request ${open.reference} is already open on this contract.`, + ); + } + } else if (contract.status !== 'CONTRACT_ACTIVE') { throw new ConflictException( 'The contract must be active before requesting a shipment.', ); @@ -90,10 +129,14 @@ export class BookingRequestService { } } } - await this.contractBookingService.assertRequestWithinCapacity(contract, { - containers: dto.containers, - bulk: dto.bulk, - }); + // Draw-down capacity is a GENERAL concept — a ONE_TIME contract's single + // shipment is bounded by the contract scope itself, checked when GL books. + if (!isOneTime) { + await this.contractBookingService.assertRequestWithinCapacity(contract, { + containers: dto.containers, + bulk: dto.bulk, + }); + } const requestedLines: Freight.RequestedShipmentLines = isContainer ? { @@ -119,10 +162,17 @@ export class BookingRequestService { // reviews the documents in the clearance queue and completes the booking // (container numbers, VGM, shipment day) once clearance is ready. The // instance is created first so a failure leaves no half-linked request. - const booking = await this.contractBookingService.initiateForShipmentRequest( - contract, - { contractRouteId: dto.contractRouteId, userId }, - ); + // GENERAL: the request immediately opens a BARE booking instance that runs + // per-booking phased customs clearance. ONE_TIME: clearance already ran on + // the contract, so there is nothing to open — the request stays PENDING + // until GL creates the contract's single booking from it. + const booking = isOneTime + ? null + : await this.contractBookingService.initiateForShipmentRequest(contract, { + contractRouteId: dto.contractRouteId, + userId, + paymentCurrency: dto.paymentCurrency, + }); const reference = await this.generateReference(); const request = await this.repo.create({ @@ -131,9 +181,14 @@ export class BookingRequestService { requestedByUserId: userId ?? null, contractRouteId: dto.contractRouteId ?? null, scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null, - status: 'ACCEPTED', - createdBookingId: booking.id, + status: booking ? 'ACCEPTED' : 'PENDING', + createdBookingId: booking?.id ?? null, requestedLines, + // Intercity is invoiced in birr whatever the customer picked. + paymentCurrency: + contract.tradeDirection === 'DOMESTIC' + ? 'ETB' + : (dto.paymentCurrency ?? contract.paymentCurrency ?? 'USD'), notes: dto.notes ?? null, } as never); this.notifier.shipmentRequestedToStaff(contract, request.id, request.reference); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 8e102e5a1..859c4e391 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -277,7 +277,7 @@ export class ContractBookingService { createdByUserId: user?.id ?? null, scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null, serviceTypeId: contract.serviceTypeId, - paymentCurrency: contract.paymentCurrency, + paymentCurrency: this.resolveShipmentCurrency(contract, dto.paymentCurrency), contractType: 'NEW', customsClearingEnabled: contract.customsClearingEnabled, customsClearingAgent: contract.customsClearingAgent ?? null, @@ -481,7 +481,7 @@ export class ContractBookingService { createdByUserId: user?.id ?? null, scheduledDate: null, serviceTypeId: contract.serviceTypeId, - paymentCurrency: contract.paymentCurrency, + paymentCurrency: this.resolveShipmentCurrency(contract, null), contractType: 'NEW', customsClearingEnabled: contract.customsClearingEnabled, customsClearingAgent: contract.customsClearingAgent ?? null, @@ -520,7 +520,12 @@ export class ContractBookingService { */ async initiateForShipmentRequest( contract: Contract, - opts: { contractRouteId?: string; userId?: string | null }, + opts: { + contractRouteId?: string; + userId?: string | null; + /** Billing currency the customer chose on the shipment request. */ + paymentCurrency?: string | null; + }, ): Promise { const generalCustoms = contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled); @@ -555,7 +560,7 @@ export class ContractBookingService { createdByUserId: opts.userId ?? null, scheduledDate: null, serviceTypeId: contract.serviceTypeId, - paymentCurrency: contract.paymentCurrency, + paymentCurrency: this.resolveShipmentCurrency(contract, opts?.paymentCurrency), contractType: 'NEW', customsClearingEnabled: contract.customsClearingEnabled, customsClearingAgent: contract.customsClearingAgent ?? null, @@ -733,6 +738,13 @@ export class ContractBookingService { cargoFreeText: dto.cargoFreeText?.trim() || null, cargoTotalWeightVgm: this.resolveBulkTons(dto), equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto), + // Completion is where the cargo — and therefore the price — is fixed, so + // it is also where the billing currency is chosen. A bare instance was + // created before the customer had any figure to look at. + paymentCurrency: this.resolveShipmentCurrency( + contract, + dto.paymentCurrency ?? booking.paymentCurrency, + ), } as never); const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id); @@ -1565,6 +1577,23 @@ export class ContractBookingService { * booking-level override (dto.equipmentReturn ?? contract default) applies. * Bulk freight keeps the legacy behaviour untouched. */ + /** + * The billing currency for a shipment under this contract. + * + * A contract quotes in USD only — the currency is a per-shipment choice now. + * Precedence: intercity is always ETB (domestic transport is invoiced in + * birr), then the customer's explicit choice, then the contract's own + * currency, which is USD for contracts created under the current rule and the + * grandfathered value for older ones. + */ + private resolveShipmentCurrency( + contract: Contract, + requested?: string | null, + ): string { + if (contract.tradeDirection === 'DOMESTIC') return 'ETB'; + return requested?.trim() || contract.paymentCurrency || 'USD'; + } + private resolveShipmentEquipmentReturn( contract: Contract, dto: CreateBookingUnderContractDto, @@ -1795,7 +1824,7 @@ export class ContractBookingService { contractId: contract.id, freightType: contract.freightType, tradeDirection: contract.tradeDirection, - paymentCurrency: contract.paymentCurrency, + paymentCurrency: this.resolveShipmentCurrency(contract, dto.paymentCurrency), serviceTypeId: contract.serviceTypeId, cargoTypeId: this.resolveCargoTypeId(contract, dto), isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'), diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index 88cb2149b..0ceea38ed 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -1,4 +1,9 @@ -import { BadRequestException, ConflictException, Injectable } from '@nestjs/common'; +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { ContractDocPhase, type ClearanceFinalInvoiceSummary, @@ -13,7 +18,10 @@ import { FilesService } from '../files/files.service'; import { ContractsRepository } from './contracts.repository'; import { ContractsService, PaginatedContracts } from './contracts.service'; import { BookingsService } from '../bookings/bookings.service'; -import { contractClearanceCodes } from './contract-clearance.util'; +import { + assertDoCollectionDates, + contractClearanceCodes, +} from './contract-clearance.util'; import { ClearanceWorkflowService } from './clearance-workflow.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ContractNotifierService } from './contract-notifier.service'; @@ -72,9 +80,23 @@ export interface ContractClearanceView { blockedReason?: string | null; } | null; dutyRequired?: boolean | null; + /** + * Pre-declaration handshake with GL Djibouti: who will handle the shipment in + * transit. `name` is null until Djibouti answers, and the declaration step is + * shut until it is set. + */ + transitAssignee?: { + requestedAt: string | null; + requestNote: string | null; + name: string | null; + assignedAt: string | null; + } | null; roHold?: boolean; roHoldReason?: string | null; vesselDepartureDate?: string | null; + /** Import DO dates recorded by GL Djibouti on upload. */ + vesselArrivalDate?: string | null; + doCollectedDate?: string | null; roAmendmentRequestedAt?: string | null; bookingReady?: boolean; preClearanceFinalized?: boolean; @@ -98,6 +120,16 @@ export interface ContractClearanceView { declarationSerial?: string | null; noticeFile?: { id: string; name: string; url: string } | null; } | null; + /** + * The customer's open objection to the advised duty — present only while GL + * has not re-advised (the advice milestone is back to PENDING). `rounds` is + * how many times it has been sent back, so both sides can see the loop. + */ + dutyDispute?: { + note: string; + raisedAt: string; + rounds: number; + } | null; workflowFiles?: ReturnType; /** Import post-allocation T1 transit document state (null until a booking is linked). */ t1?: ClearanceT1State | null; @@ -247,6 +279,19 @@ export class ContractClearanceService { contract = await this.reconcilePrematureBookingReady(contractId, contract, boundary); const phase = this.workflowService.resolvePhase(contract, cycle, milestones); const dutyAdvice = this.buildDutyAdvice(files, milestones); + const dutyDispute = await this.buildDutyDispute(contractId, milestones); + const transitAssignee = cycle + ? { + requestedAt: cycle.transitAssigneeRequestedAt + ? cycle.transitAssigneeRequestedAt.toISOString() + : null, + requestNote: cycle.transitAssigneeRequestNote ?? null, + name: cycle.transitAssigneeName ?? null, + assignedAt: cycle.transitAssigneeAssignedAt + ? cycle.transitAssigneeAssignedAt.toISOString() + : null, + } + : null; let workflowFiles = buildWorkflowFiles( files, contract.tradeDirection ?? 'IMPORT', @@ -361,6 +406,8 @@ export class ContractClearanceService { roHold: Boolean(cycle?.roHoldReason), roHoldReason: cycle?.roHoldReason ?? null, vesselDepartureDate: cycle?.vesselDepartureDate ?? null, + vesselArrivalDate: cycle?.vesselArrivalDate ?? null, + doCollectedDate: cycle?.doCollectedDate ?? null, roAmendmentRequestedAt: cycle?.roAmendmentRequestedAt ? cycle.roAmendmentRequestedAt.toISOString() : null, @@ -373,6 +420,8 @@ export class ContractClearanceService { linkedBookingReviewNote, linkedBookingScheduledDate, dutyAdvice, + dutyDispute, + transitAssignee, workflowFiles, t1, train, @@ -429,6 +478,32 @@ export class ContractClearanceService { }; } + /** + * The customer's duty objection, but only while it is still OPEN — i.e. the + * advice milestone sits back at PENDING because nobody has re-advised yet. + * Re-advising completes that milestone again, which closes the dispute here + * without any extra state to keep in sync; the notes stay as the audit trail + * and their count is the round number. + */ + private async buildDutyDispute( + contractId: string, + milestones: ClearanceMilestone[], + ): Promise { + const advised = milestones.find((m) => m.milestoneCode === 'DUTY_TAXES_ADVISED'); + if (!advised || advised.status === 'COMPLETED') return null; + const notes = await this.contractsRepository.findReviewNotes( + contractId, + 'DUTY_DISPUTE', + ); + const latest = notes[0]; + if (!latest) return null; + return { + note: latest.body, + raisedAt: latest.createdAt.toISOString(), + rounds: notes.length, + }; + } + /** * True when every REQUIRED customer-input field has an APPROVED review row in * the current cycle. The 100% gate before clearance can be finalized. @@ -654,6 +729,92 @@ export class ContractClearanceService { return this.applyReview(contractId, fileKey, status, staffId, 'OPERATIONS', note); } + /** + * GL corrects a clearance document in place instead of bouncing it back to + * the customer. The customer's upload is NOT lost — it is retired into the + * document's version history, stamped with who replaced it and why — and the + * new version starts unreviewed, so GL still has to approve it (or query it) + * before clearance can be finalized. + * + * Use this for the small fixes staff can make faster than the customer can + * (a wrong page order, a missing stamp scan); a query is still the right tool + * when only the customer can produce the correct document. + */ + async replaceDocument( + contractId: string, + fileKey: string, + file: Express.Multer.File, + staffId: string, + reason?: string, + ): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertClearanceReviewableStatus(contract); + if (!file) throw new BadRequestException('No replacement file uploaded'); + if (!reason?.trim()) { + throw new BadRequestException( + 'Say why the document is being replaced — it is kept on the file history.', + ); + } + + const cycle = await this.contractsRepository.currentCycle(contractId); + if (this.isPhasedCustoms(contract) && cycle?.preClearanceFinalizedAt) { + throw new BadRequestException( + 'Documents cannot be changed after pre-clearance is finalized.', + ); + } + + const existing = await this.filesService.findByCode( + contractId, + 'contracts', + fileKey, + ); + if (!existing) { + throw new NotFoundException( + `No document is stored under "${fileKey}" on this contract.`, + ); + } + + await this.filesService.upsertByCode( + { resourceId: contractId, resource: 'contracts', code: fileKey, file }, + { userId: staffId, reason: reason.trim() }, + ); + + // A fresh version is unreviewed by definition: clear any earlier verdict so + // the corrected file is signed off explicitly rather than inheriting a tick. + const { inputCode, outputCode } = contractClearanceCodes(contract); + const reviews = await this.contractsRepository.findDocumentReviews( + contractId, + cycle?.id ?? null, + ); + const settingCode = + reviews.find((r) => r.fileKey === fileKey)?.settingCode ?? + (fileKey.startsWith('custom_') ? 'custom' : (inputCode ?? outputCode ?? 'custom')); + await this.contractsRepository.setDocumentReviewStatus({ + contractId, + clearanceCycleId: cycle?.id ?? null, + settingCode, + fileKey, + status: 'PENDING', + staffId, + note: `Replaced by staff: ${reason.trim()}`, + }); + + await this.contractsRepository.createReviewNote( + contractId, + `Document "${fileKey}" replaced by staff: ${reason.trim()}`, + 'STAFF_NOTE', + staffId, + 'GL_ET', + ); + + return this.contractsService.findById(contractId); + } + + /** Every stored version of one clearance document, newest first. */ + async documentVersions(contractId: string, fileKey: string) { + return this.filesService.versionHistory(contractId, 'contracts', fileKey); + } + private async applyReview( contractId: string, fileKey: string, @@ -968,6 +1129,68 @@ export class ContractClearanceService { // ── Phased clearance actions (ONE_TIME customs, Phase 1) ─────────────────── /** Sync DOCUMENTS_APPROVED when reviews are done but the milestone row lags. */ + /** + * GL Ethiopia asks Djibouti to name the officer who will handle the shipment + * in transit. Nothing else moves until Djibouti answers — the declaration is + * gated on it — so this is the first thing ET does once the documents are + * approved. Re-requesting is allowed (a nudge) and simply restamps the ask. + */ + async requestTransitAssignee( + contractId: string, + note: string | undefined, + userId?: string, + ): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + const cycle = await this.contractsRepository.currentCycle(contractId); + if (!cycle) throw new BadRequestException('No clearance cycle found'); + + await this.contractsRepository.updateCycle(cycle.id, { + transitAssigneeRequestedAt: new Date(), + transitAssigneeRequestedByUserId: userId ?? null, + transitAssigneeRequestNote: note?.trim() || null, + }); + + const updated = await this.contractsService.findById(contractId); + this.notifier.transitAssigneeRequested(updated, note?.trim() ?? null); + return updated; + } + + /** + * GL Djibouti names the transit officer — free text, because the person is + * not a platform user. Answering unblocks the declaration for Ethiopia. A + * later call overwrites the name (reassignment) and re-notifies. + */ + async assignTransitAssignee( + contractId: string, + assignee: string, + userId?: string, + ): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (!assignee?.trim()) { + throw new BadRequestException('Name the officer who will handle the transit.'); + } + const cycle = await this.contractsRepository.currentCycle(contractId); + if (!cycle) throw new BadRequestException('No clearance cycle found'); + if (!cycle.transitAssigneeRequestedAt) { + throw new BadRequestException( + 'GL Ethiopia has not requested a transit assignee for this clearance yet.', + ); + } + + const previous = cycle.transitAssigneeName ?? null; + await this.contractsRepository.updateCycle(cycle.id, { + transitAssigneeName: assignee.trim(), + transitAssigneeAssignedAt: new Date(), + transitAssigneeAssignedByUserId: userId ?? null, + }); + + const updated = await this.contractsService.findById(contractId); + this.notifier.transitAssigneeAssigned(updated, assignee.trim(), previous); + return updated; + } + private async ensureDeclarationPrerequisites( contractId: string, contract: Contract, @@ -978,6 +1201,16 @@ export class ContractClearanceService { 'All required customer documents must be approved before uploading a declaration.', ); } + // The transit officer must be named by Djibouti first — the declaration is + // filed against whoever will physically handle the shipment there. + const cycle = await this.contractsRepository.currentCycle(contractId); + if (!cycle?.transitAssigneeName) { + throw new BadRequestException( + cycle?.transitAssigneeRequestedAt + ? 'GL Djibouti has not assigned the transit officer yet — the declaration cannot be filed until they do.' + : 'Request a transit assignee from GL Djibouti before filing the customs declaration.', + ); + } const milestones = await this.workflowService.listMilestones(contractId); const docsApproved = milestones.find((m) => m.milestoneCode === 'DOCUMENTS_APPROVED'); if (docsApproved?.status !== 'COMPLETED' && docsApproved?.status !== 'SKIPPED') { @@ -1084,6 +1317,67 @@ export class ContractClearanceService { return this.contractsService.findById(contractId); } + /** + * The customer disagrees with the advised duty & tax and asks GL Ethiopia to + * correct it. Nothing is paid; the advice milestone reopens so the Duty & tax + * step becomes actionable again on the GL clearance page, with the customer's + * message shown beside it. GL re-advises (same endpoint as the first time), + * which closes the dispute — the loop may run as many rounds as it takes. + */ + async disputeDuty( + contractId: string, + note: string, + userId?: string, + ): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (contract.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Duty applies only to import contracts.'); + } + if (!note?.trim()) { + throw new BadRequestException( + 'Say what is wrong with the advised amount so GL can correct it.', + ); + } + + const cycle = await this.contractsRepository.currentCycle(contractId); + if (!cycle?.dutyRequired) { + throw new BadRequestException('Duty/tax is not required for this clearance.'); + } + const milestones = await this.workflowService.listMilestones(contractId); + const byCode = new Map(milestones.map((m) => [m.milestoneCode, m])); + if (byCode.get('DUTY_TAXES_ADVISED')?.status !== 'COMPLETED') { + throw new BadRequestException( + 'There is no advised duty amount to dispute yet.', + ); + } + // Once the slip is in, the money is paid — a dispute then is a refund + // conversation, not a re-advice. + if (byCode.get('DUTY_TAX_PAID')?.status === 'COMPLETED') { + throw new BadRequestException( + 'The duty payment slip has already been submitted — contact GL Ethiopia directly.', + ); + } + + await this.contractsRepository.createReviewNote( + contractId, + note.trim(), + 'DUTY_DISPUTE', + userId, + 'CUSTOMER', + ); + // Back to GL: reopening the milestone is what re-arms the Duty & tax step + // (the stepper picks its active step from milestone completion). + await this.milestoneService.reopenForContract(contractId, 'DUTY_TAXES_ADVISED'); + await this.contractsRepository.updateCycle(cycle.id, { + currentPhase: ContractDocPhase.GlEtOutput, + }); + + const updated = await this.contractsService.findById(contractId); + this.notifier.dutyDisputed(updated, note.trim()); + return updated; + } + async uploadDutySlip( contractId: string, file: Express.Multer.File, @@ -1198,7 +1492,7 @@ export class ContractClearanceService { contractId: string, file: Express.Multer.File, userId?: string, - vesselDepartureDate?: string, + dates?: { vesselArrivalDate?: string; doCollectedDate?: string }, ): Promise { const contract = await this.contractsService.findById(contractId); this.assertPhasedCustoms(contract); @@ -1208,6 +1502,8 @@ export class ContractClearanceService { if (!file) throw new BadRequestException('No Delivery Order uploaded'); + const { vesselArrivalDate, doCollectedDate } = assertDoCollectionDates(dates); + // DO upload is deliberately un-gated: GL Djibouti may attach it at any point, // any file type. The DO_COLLECTED milestone (and booking readiness) still waits // for GL Ethiopia to finalize pre-clearance so the workflow order holds. @@ -1219,9 +1515,10 @@ export class ContractClearanceService { }); const cycle = await this.contractsRepository.currentCycle(contractId); - if (cycle && vesselDepartureDate?.trim()) { + if (cycle) { await this.contractsRepository.updateCycle(cycle.id, { - vesselDepartureDate: vesselDepartureDate.trim(), + vesselArrivalDate, + doCollectedDate, }); } if (cycle?.preClearanceFinalizedAt) { diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts index 2d26f51dc..512ce5288 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts @@ -1,3 +1,5 @@ +import { BadRequestException } from '@nestjs/common'; + import { Contract } from './entities/contract.entity'; import { INTERCITY_DOCUMENTS_SETTING_CODE } from '../bookings/clearance.util'; @@ -84,3 +86,47 @@ export function contractClearanceCodes(contract: Contract): { includesCustoms, }; } + +/** + * Djibouti GL cannot record a Delivery Order without saying WHEN the vessel + * arrived and WHEN the DO was collected — the file alone leaves the import + * timeline unauditable. Shared by the contract and per-booking DO uploads so + * one endpoint can never be laxer than the other. + * + * Returns the normalized `YYYY-MM-DD` pair; throws if either is missing, + * unparseable, or the DO predates the vessel's arrival. + */ +export function assertDoCollectionDates(dates?: { + vesselArrivalDate?: string; + doCollectedDate?: string; +}): { vesselArrivalDate: string; doCollectedDate: string } { + const vesselArrivalDate = normalizeDoDate( + dates?.vesselArrivalDate, + 'Vessel arrival date', + ); + const doCollectedDate = normalizeDoDate( + dates?.doCollectedDate, + 'DO collected date', + ); + + if (doCollectedDate < vesselArrivalDate) { + throw new BadRequestException( + 'DO collected date cannot be earlier than the vessel arrival date.', + ); + } + + return { vesselArrivalDate, doCollectedDate }; +} + +/** `YYYY-MM-DD` or throw — the column is a DATE, so time zones never enter. */ +function normalizeDoDate(value: string | undefined, label: string): string { + const trimmed = value?.trim(); + if (!trimmed) { + throw new BadRequestException(`${label} is required to upload a Delivery Order.`); + } + const date = trimmed.slice(0, 10); + if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || Number.isNaN(Date.parse(date))) { + throw new BadRequestException(`${label} is not a valid date.`); + } + return date; +} diff --git a/apps/edr-freight-api/src/modules/contracts/contract-document-diff.util.ts b/apps/edr-freight-api/src/modules/contracts/contract-document-diff.util.ts index 154777aea..ac2c12075 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-document-diff.util.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-document-diff.util.ts @@ -25,7 +25,18 @@ export type ContractDocumentChange = toOrder: number; } | { kind: 'DOCUMENT_TITLE_CHANGED'; title: string; fromTitle: string | null } - | { kind: 'WHEREAS_CHANGED'; added: number; removed: number }; + | { kind: 'WHEREAS_CHANGED'; added: number; removed: number } + /** + * A contract field (not a document article) changed — the customer editing a + * DRAFT/CHANGES_REQUESTED contract, e.g. its route, cargo or service type. + */ + | { + kind: 'FIELD_CHANGED'; + field: string; + label: string; + from: string | null; + to: string | null; + }; type SnapshotLike = Pick< ContractDocumentSnapshot, @@ -134,6 +145,60 @@ export function diffSnapshots( return changes; } +/** Human label per audited contract field, in the order they read on the form. */ +export const CONTRACT_FIELD_LABELS: Record = { + contractKind: 'Contract kind', + tradeDirection: 'Trade direction', + freightType: 'Freight type', + serviceType: 'Service type', + paymentCurrency: 'Payment currency', + contractType: 'Contract type', + isHazardous: 'Hazardous', + hazardClass: 'Hazard class', + unNumber: 'UN number', + isReefer: 'Reefer', + equipmentReturn: 'Equipment return', + customsClearingAgent: 'Customs clearing agent', + firstMilePickupAddress: 'First-mile pickup address', + lastMileDeliveryAddress: 'Last-mile delivery address', + routes: 'Routes', + cargoScope: 'Cargo scope', +}; + +/** Render a field value for the audit trail — never "[object Object]". */ +function displayValue(value: unknown): string | null { + if (value === null || value === undefined || value === '') return null; + if (typeof value === 'boolean') return value ? 'Yes' : 'No'; + return String(value); +} + +/** + * Compare two flat maps of contract fields and report what changed. Only keys + * present in `after` are considered, so a partial update never reports the + * fields it did not touch. + */ +export function diffContractFields( + before: Record, + after: Record, +): ContractDocumentChange[] { + const changes: ContractDocumentChange[] = []; + + for (const [field, nextRaw] of Object.entries(after)) { + const next = displayValue(nextRaw); + const previous = displayValue(before[field]); + if (next === previous) continue; + changes.push({ + kind: 'FIELD_CHANGED', + field, + label: CONTRACT_FIELD_LABELS[field] ?? field, + from: previous, + to: next, + }); + } + + return changes; +} + /** Short human summary of a change set, e.g. "2 articles edited, 1 article added". */ export function summarizeChanges(changes: ContractDocumentChange[]): string { if (changes.length === 0) return 'No changes'; @@ -148,6 +213,7 @@ export function summarizeChanges(changes: ContractDocumentChange[]): string { const counts = new Map(); const parts: string[] = []; + const fields: string[] = []; for (const change of changes) { const verb = articleVerbs[change.kind]; @@ -157,9 +223,19 @@ export function summarizeChanges(changes: ContractDocumentChange[]): string { parts.push('document title changed'); } else if (change.kind === 'WHEREAS_CHANGED') { parts.push('recitals changed'); + } else if (change.kind === 'FIELD_CHANGED') { + fields.push(change.label.toLowerCase()); } } + if (fields.length > 0) { + parts.push( + fields.length <= 3 + ? `${fields.join(', ')} changed` + : `${fields.length} contract fields changed`, + ); + } + const articleParts = [...counts.entries()].map( ([verb, count]) => `${count} article${count === 1 ? '' : 's'} ${verb}`, ); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts index 2808ea6cf..e38f737c6 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts @@ -1,8 +1,12 @@ import { Injectable, Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; +import { DataSource, Repository } from 'typeorm'; -import { diffSnapshots, summarizeChanges } from './contract-document-diff.util'; +import { + ContractDocumentChange, + diffSnapshots, + summarizeChanges, +} from './contract-document-diff.util'; import { ContractDocumentRevision } from './entities/contract-document-revision.entity'; import type { ContractDocumentSnapshot } from './entities/contract.entity'; @@ -12,6 +16,39 @@ export interface RecordRevisionInput { after: ContractDocumentSnapshot | null; actorId?: string | null; actorRole?: string | null; + actorName?: string | null; + stepId?: string | null; +} + +/** + * `iam.users.name` is a localized object ({ en, am, … }), not a string — a + * plain `String(name)` there yields "[object Object]" in the audit trail. + */ +interface IamUserRow { + name?: Record | string | null; + username?: string | null; + email?: string | null; +} + +/** Best display name for a user row: English label → any locale → login → email. */ +function pickUserName(user: IamUserRow): string | null { + const { name } = user; + if (typeof name === 'string' && name.trim()) return name.trim(); + if (name && typeof name === 'object') { + const localized = + name.en ?? Object.values(name).find((v) => typeof v === 'string' && v.trim()); + if (localized?.trim()) return localized.trim(); + } + return user.username?.trim() || user.email?.trim() || null; +} + +/** Pre-computed changes (contract fields), rather than a document diff. */ +export interface RecordChangesInput { + contractId: string; + changes: ContractDocumentChange[]; + actorId?: string | null; + actorRole?: string | null; + actorName?: string | null; stepId?: string | null; } @@ -22,6 +59,7 @@ export class ContractDocumentHistoryService { constructor( @InjectRepository(ContractDocumentRevision) private readonly revisionRepo: Repository, + @InjectDataSource() private readonly dataSource: DataSource, ) {} /** @@ -30,18 +68,32 @@ export class ContractDocumentHistoryService { * and swallowed. A no-op edit records nothing. */ async record(input: RecordRevisionInput): Promise { + return this.recordChanges({ + ...input, + changes: diffSnapshots(input.before, input.after), + }); + } + + /** + * Append a revision from an already-computed change set — the contract-field + * path, where there is no document snapshot to diff. Same best-effort + * contract as {@link record}: a no-op change set records nothing, and a + * failure here never breaks the edit that triggered it. + */ + async recordChanges(input: RecordChangesInput): Promise { try { - const changes = diffSnapshots(input.before, input.after); - if (changes.length === 0) return; + if (input.changes.length === 0) return; await this.revisionRepo.save( this.revisionRepo.create({ contractId: input.contractId, actorId: input.actorId ?? null, actorRole: input.actorRole ?? null, + actorName: + input.actorName ?? (await this.resolveActorName(input.actorId)), stepId: input.stepId ?? null, - summary: summarizeChanges(changes), - changes, + summary: summarizeChanges(input.changes), + changes: input.changes, }), ); } catch (err) { @@ -51,11 +103,62 @@ export class ContractDocumentHistoryService { } } + /** + * Name for the acting user. `iam.users` is owned by the auth system and has + * no entity here, so it is read directly; a miss is not an error — the trail + * still carries the id, role and timestamp. + */ + private async resolveActorName( + actorId?: string | null, + ): Promise { + if (!actorId) return null; + const names = await this.resolveActorNames([actorId]); + return names.get(actorId) ?? null; + } + + /** Batched {@link resolveActorName} — one query for a whole revision list. */ + private async resolveActorNames( + actorIds: string[], + ): Promise> { + const resolved = new Map(); + const ids = [...new Set(actorIds.filter(Boolean))]; + if (ids.length === 0) return resolved; + + try { + const rows = (await this.dataSource.query( + `SELECT id, name, username, email FROM iam.users WHERE id = ANY($1::uuid[])`, + [ids], + )) as Array; + for (const row of rows) { + const name = pickUserName(row); + if (name) resolved.set(row.id, name); + } + } catch (err) { + this.logger.warn(`Could not resolve actor names: ${String(err)}`); + } + return resolved; + } + /** Revision history for a contract, newest first. */ - list(contractId: string): Promise { - return this.revisionRepo.find({ + async list(contractId: string): Promise { + const revisions = await this.revisionRepo.find({ where: { contractId }, order: { createdAt: 'DESC' }, }); + + // Rows written before actor_name existed still carry an actor_id — resolve + // those for display (one query for the whole list) rather than backfilling. + const missing = revisions + .filter((r) => !r.actorName && r.actorId) + .map((r) => r.actorId as string); + if (missing.length === 0) return revisions; + + const names = await this.resolveActorNames(missing); + for (const revision of revisions) { + if (!revision.actorName && revision.actorId) { + revision.actorName = names.get(revision.actorId) ?? null; + } + } + return revisions; } } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-duty-dispute.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-duty-dispute.spec.ts new file mode 100644 index 000000000..0ba682294 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-duty-dispute.spec.ts @@ -0,0 +1,164 @@ +import { BadRequestException } from '@nestjs/common'; + +import { ContractClearanceService } from './contract-clearance.service'; +import type { Contract } from './entities/contract.entity'; + +/** + * The duty advice → dispute → re-advice loop. GL Ethiopia advises an amount; + * the customer either pays it or sends it back with a reason. Sending it back + * reopens the advice milestone — that is what puts the Duty & tax step back in + * GL's hands — and the round can repeat until the amount is agreed. + */ +describe('ContractClearanceService — duty dispute', () => { + const contract = (over: Partial = {}): Contract => + ({ + id: 'ctr-1', + reference: 'CTR-2026-00042', + tradeDirection: 'IMPORT', + customsClearingEnabled: true, + contractKind: 'ONE_TIME', + ...over, + }) as Contract; + + const milestone = (code: string, status: string) => + ({ milestoneCode: code, status }) as never; + + let repo: { + currentCycle: jest.Mock; + createReviewNote: jest.Mock; + updateCycle: jest.Mock; + findReviewNotes: jest.Mock; + }; + let contractsService: { findById: jest.Mock }; + let workflowService: { listMilestones: jest.Mock }; + let milestoneService: { reopenForContract: jest.Mock }; + let notifier: { dutyDisputed: jest.Mock }; + let service: ContractClearanceService; + + const build = (milestones: unknown[]) => { + workflowService.listMilestones.mockResolvedValue(milestones); + }; + + beforeEach(() => { + repo = { + currentCycle: jest.fn().mockResolvedValue({ id: 'cyc-1', dutyRequired: true }), + createReviewNote: jest.fn().mockResolvedValue(undefined), + updateCycle: jest.fn().mockResolvedValue(undefined), + findReviewNotes: jest.fn().mockResolvedValue([]), + }; + contractsService = { findById: jest.fn().mockResolvedValue(contract()) }; + workflowService = { listMilestones: jest.fn().mockResolvedValue([]) }; + milestoneService = { reopenForContract: jest.fn().mockResolvedValue(undefined) }; + notifier = { dutyDisputed: jest.fn() }; + + service = new ContractClearanceService( + repo as never, + contractsService as never, + {} as never, // bookingsService + {} as never, // filesService + {} as never, // fileUploadSettingsService + workflowService as never, + milestoneService as never, + {} as never, // dropdownSettingsService + {} as never, // glOperationsService + notifier as never, + ); + build([ + milestone('DUTY_TAXES_ADVISED', 'COMPLETED'), + milestone('DUTY_TAX_PAID', 'PENDING'), + ]); + }); + + it('records the objection and hands the step back to GL', async () => { + await service.disputeDuty('ctr-1', ' Declared value is wrong ', 'user-1'); + + expect(repo.createReviewNote).toHaveBeenCalledWith( + 'ctr-1', + 'Declared value is wrong', + 'DUTY_DISPUTE', + 'user-1', + 'CUSTOMER', + ); + // Reopening the advice milestone is what re-arms the Duty & tax step. + expect(milestoneService.reopenForContract).toHaveBeenCalledWith( + 'ctr-1', + 'DUTY_TAXES_ADVISED', + ); + expect(repo.updateCycle).toHaveBeenCalledWith('cyc-1', { + currentPhase: 'GL_ET_OUTPUT', + }); + }); + + it('tells GL Ethiopia, not the customer', async () => { + await service.disputeDuty('ctr-1', 'Too high', 'user-1'); + expect(notifier.dutyDisputed).toHaveBeenCalledWith( + expect.objectContaining({ id: 'ctr-1' }), + 'Too high', + ); + }); + + it('requires a reason — GL cannot correct an unexplained objection', async () => { + await expect(service.disputeDuty('ctr-1', ' ')).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(milestoneService.reopenForContract).not.toHaveBeenCalled(); + }); + + it('refuses when nothing has been advised yet', async () => { + build([milestone('DUTY_TAXES_ADVISED', 'PENDING')]); + await expect(service.disputeDuty('ctr-1', 'Too high')).rejects.toThrow( + /no advised duty amount/i, + ); + }); + + it('refuses once the payment slip is in — that is a refund, not a re-advice', async () => { + build([ + milestone('DUTY_TAXES_ADVISED', 'COMPLETED'), + milestone('DUTY_TAX_PAID', 'COMPLETED'), + ]); + await expect(service.disputeDuty('ctr-1', 'Too high')).rejects.toThrow( + /already been submitted/i, + ); + }); + + it('refuses when duty was never required for this clearance', async () => { + repo.currentCycle.mockResolvedValue({ id: 'cyc-1', dutyRequired: false }); + await expect(service.disputeDuty('ctr-1', 'Too high')).rejects.toThrow( + /not required/i, + ); + }); + + describe('the view', () => { + const buildDispute = (milestones: unknown[]) => + ( + service as unknown as { + buildDutyDispute: (id: string, m: unknown[]) => Promise; + } + ).buildDutyDispute('ctr-1', milestones); + + it('shows the objection while GL still owes a corrected advice', async () => { + repo.findReviewNotes.mockResolvedValue([ + { body: 'Second look please', createdAt: new Date('2026-07-20T09:00:00Z') }, + { body: 'First objection', createdAt: new Date('2026-07-18T09:00:00Z') }, + ]); + + const dispute = await buildDispute([ + milestone('DUTY_TAXES_ADVISED', 'PENDING'), + ]); + + expect(dispute).toMatchObject({ note: 'Second look please', rounds: 2 }); + }); + + it('clears itself once GL re-advises', async () => { + repo.findReviewNotes.mockResolvedValue([ + { body: 'First objection', createdAt: new Date('2026-07-18T09:00:00Z') }, + ]); + + const dispute = await buildDispute([ + milestone('DUTY_TAXES_ADVISED', 'COMPLETED'), + ]); + + expect(dispute).toBeNull(); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-field-diff.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-field-diff.spec.ts new file mode 100644 index 000000000..8726f6c64 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-field-diff.spec.ts @@ -0,0 +1,89 @@ +import { + diffContractFields, + summarizeChanges, +} from './contract-document-diff.util'; + +/** + * The contract-field audit runs on the customer's own edits, so it has to be + * exact: never report a field the edit did not touch, and never render a value + * as "[object Object]" or "true" in the trail a reviewer reads. + */ +describe('diffContractFields', () => { + it('reports only the fields that actually changed', () => { + const changes = diffContractFields( + { freightType: 'BULK', paymentCurrency: 'USD', isReefer: false }, + { freightType: 'CONTAINER', paymentCurrency: 'USD', isReefer: false }, + ); + + expect(changes).toEqual([ + { + kind: 'FIELD_CHANGED', + field: 'freightType', + label: 'Freight type', + from: 'BULK', + to: 'CONTAINER', + }, + ]); + }); + + it('renders booleans as Yes/No, not true/false', () => { + const [change] = diffContractFields({ isHazardous: false }, { isHazardous: true }); + + expect(change).toMatchObject({ label: 'Hazardous', from: 'No', to: 'Yes' }); + }); + + it('treats null, undefined and empty string as "not set"', () => { + expect(diffContractFields({ unNumber: null }, { unNumber: '' })).toEqual([]); + expect(diffContractFields({ unNumber: undefined }, { unNumber: null })).toEqual([]); + + const [set] = diffContractFields({ unNumber: null }, { unNumber: 'UN1234' }); + expect(set).toMatchObject({ from: null, to: 'UN1234' }); + }); + + it('ignores fields absent from the update', () => { + // A partial edit must not report the fields it never sent. + expect(diffContractFields({ freightType: 'BULK', isReefer: true }, {})).toEqual([]); + }); + + it('records a route swap that keeps the same lane count', () => { + const [change] = diffContractFields( + { routes: 'Nagad → Mojo' }, + { routes: 'Nagad → Adama' }, + ); + + expect(change).toMatchObject({ + label: 'Routes', + from: 'Nagad → Mojo', + to: 'Nagad → Adama', + }); + }); + + it('summarises field changes by name, and by count once there are many', () => { + const few = diffContractFields( + { freightType: 'BULK', paymentCurrency: 'USD' }, + { freightType: 'CONTAINER', paymentCurrency: 'ETB' }, + ); + expect(summarizeChanges(few)).toBe('freight type, payment currency changed'); + + const many = diffContractFields( + { a: '1', b: '1', c: '1', d: '1' }, + { a: '2', b: '2', c: '2', d: '2' }, + ); + expect(summarizeChanges(many)).toBe('4 contract fields changed'); + }); + + it('summarises document and field changes together', () => { + const summary = summarizeChanges([ + { kind: 'ARTICLE_BODY_CHANGED', articleId: 'a-1', title: 'Article 1' }, + { + kind: 'FIELD_CHANGED', + field: 'routes', + label: 'Routes', + from: 'A → B', + to: 'A → C', + }, + ]); + + expect(summary).toBe('1 article edited, routes changed'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts index fd81083fc..11681a28f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts @@ -192,6 +192,56 @@ export class ContractNotifierService { }); } + /** + * GL Ethiopia asked Djibouti to name the transit officer. Staff-only, and + * deep-linked to the Djibouti clearance page where the name is entered — the + * customs declaration is blocked until they answer. + */ + transitAssigneeRequested(c: Contract, note: string | null): void { + const msg = + `GL Ethiopia needs a transit assignee for contract ${c.reference} before ` + + `the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`; + this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${c.reference}`); + this.inAppStaff(c, `Transit assignee needed — ${c.reference}`, msg, { + type: NotificationType.CLEARANCE_REVIEW, + link: `/dashboard/gl-djibouti/clearance/${c.id}`, + }); + } + + /** Djibouti named (or changed) the transit officer — Ethiopia can proceed. */ + transitAssigneeAssigned( + c: Contract, + assignee: string, + previous: string | null, + ): void { + const msg = previous + ? `GL Djibouti changed the transit assignee for contract ${c.reference} from ` + + `"${previous}" to "${assignee}".` + : `GL Djibouti assigned ${assignee} to handle contract ${c.reference} in transit. ` + + `The customs declaration can now be filed.`; + this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${c.reference}`); + this.inAppStaff(c, `Transit assignee set — ${c.reference}`, msg, { + type: NotificationType.CLEARANCE_REVIEW, + link: `/dashboard/contracts/clearance/${c.id}`, + }); + } + + /** + * The customer disputed the advised duty & tax. This goes to STAFF, not the + * customer: GL Ethiopia is the one who has to re-advise, and the clearance + * page is where they do it. + */ + dutyDisputed(c: Contract, note: string): void { + const msg = + `The customer disputed the duty & tax advised on contract ${c.reference}: ` + + `"${note}". Review and re-advise the amount on the clearance page.`; + this.logger.log(`DUTY DISPUTED — ${c.reference}`); + this.inAppStaff(c, `Duty disputed on ${c.reference}`, msg, { + type: NotificationType.CLEARANCE_REVIEW, + link: `/dashboard/contracts/clearance/${c.id}`, + }); + } + /** A clearance document was queried — customer must re-upload it. */ clearanceDocumentQueried(c: Contract, fileKey: string, note: string): void { const msg = diff --git a/apps/edr-freight-api/src/modules/contracts/contract-revision-actor.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-revision-actor.spec.ts new file mode 100644 index 000000000..3f579d32b --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-revision-actor.spec.ts @@ -0,0 +1,115 @@ +import { ContractDocumentHistoryService } from './contract-document-history.service'; + +/** + * `iam.users.name` is a localized jsonb object, not a string. Reading it + * naively puts "[object Object]" in the audit trail — or, worse, throws and + * leaves every revision anonymous. These specs pin the resolution rules. + */ +describe('ContractDocumentHistoryService actor names', () => { + const build = (rows: unknown[]) => { + const saved: Array> = []; + const service = Object.create( + ContractDocumentHistoryService.prototype, + ) as ContractDocumentHistoryService; + Object.assign(service, { + logger: { warn: jest.fn(), error: jest.fn() }, + dataSource: { query: jest.fn().mockResolvedValue(rows) }, + revisionRepo: { + create: (row: Record) => row, + save: jest.fn((row: Record) => { + saved.push(row); + return Promise.resolve(row); + }), + find: jest.fn().mockResolvedValue([]), + }, + }); + return { service, saved }; + }; + + const change = { + kind: 'FIELD_CHANGED' as const, + field: 'routes', + label: 'Routes', + from: 'A → B', + to: 'A → C', + }; + + it('prefers the English label from the localized name object', async () => { + const { service, saved } = build([ + { id: 'u-1', name: { am: 'ሱፐር አድሚን', en: 'Super Admin' }, username: 'superadmin' }, + ]); + + await service.recordChanges({ contractId: 'c-1', changes: [change], actorId: 'u-1' }); + + expect(saved[0].actorName).toBe('Super Admin'); + }); + + it('falls back to another locale, then username, then email', async () => { + const onlyAmharic = build([{ id: 'u-1', name: { am: 'ሱፐር' }, username: 'x' }]); + await onlyAmharic.service.recordChanges({ + contractId: 'c-1', + changes: [change], + actorId: 'u-1', + }); + expect(onlyAmharic.saved[0].actorName).toBe('ሱፐር'); + + const noName = build([{ id: 'u-1', name: null, username: 'operator', email: 'o@edr' }]); + await noName.service.recordChanges({ + contractId: 'c-1', + changes: [change], + actorId: 'u-1', + }); + expect(noName.saved[0].actorName).toBe('operator'); + + const emailOnly = build([{ id: 'u-1', name: {}, username: null, email: 'o@edr.local' }]); + await emailOnly.service.recordChanges({ + contractId: 'c-1', + changes: [change], + actorId: 'u-1', + }); + expect(emailOnly.saved[0].actorName).toBe('o@edr.local'); + }); + + it('never writes "[object Object]" as the actor name', async () => { + const { service, saved } = build([{ id: 'u-1', name: { en: 'Real Name' } }]); + + await service.recordChanges({ contractId: 'c-1', changes: [change], actorId: 'u-1' }); + + expect(String(saved[0].actorName)).not.toContain('object Object'); + }); + + it('records nothing when the change set is empty', async () => { + const { service, saved } = build([]); + + await service.recordChanges({ contractId: 'c-1', changes: [], actorId: 'u-1' }); + + expect(saved).toHaveLength(0); + }); + + it('still records the revision when the user lookup fails', async () => { + const { service, saved } = build([]); + Object.assign(service, { + dataSource: { query: jest.fn().mockRejectedValue(new Error('iam down')) }, + }); + + await service.recordChanges({ contractId: 'c-1', changes: [change], actorId: 'u-1' }); + + expect(saved).toHaveLength(1); + expect(saved[0].actorName).toBeNull(); + }); + + it('resolves names for legacy rows that predate the actor_name column', async () => { + const { service } = build([{ id: 'u-1', name: { en: 'Abenezer Haile' } }]); + Object.assign(service, { + revisionRepo: { + find: jest + .fn() + .mockResolvedValue([{ id: 'r-1', actorId: 'u-1', actorName: null }]), + }, + }); + + const [revision] = await service.list('c-1'); + + expect(revision.actorName).toBe('Abenezer Haile'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index 5a22d2fb9..dfd4ea840 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -274,6 +274,20 @@ export class ContractTransitionService { // shared six templates are never written here. const snapshot = await this.resolveDocumentSnapshot(contract, documentSnapshot); + // Audit whatever staff changed in the accept dialog. The baseline is the + // template this contract would otherwise have frozen as-is, so an untouched + // accept diffs to nothing and records no revision. + if (documentSnapshot) { + const baseline = await this.resolveDocumentSnapshot(contract); + await this.documentHistory.record({ + contractId, + before: baseline, + after: snapshot, + actorId, + actorRole: 'Reviewing staff', + }); + } + await this.contractsRepository.update(contractId, { status: 'PENDING_APPROVAL', approvedByStaffId: actorId, diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index a015cd469..f6b9ec1c4 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -303,8 +303,10 @@ export class ContractsController { @Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContractDto, @UploadedFiles() files: Express.Multer.File[], + // Recorded on the edit's audit revision — who changed the contract. + @CurrentUser() user?: TCurrentUser, ) { - return this.contractsService.update(id, dto, files ?? []); + return this.contractsService.update(id, dto, files ?? [], user?.id); } @Delete(':id') @@ -738,6 +740,92 @@ export class ContractsController { return this.clearanceService.finalizePreClearance(id); } + @Post(':id/clearance/transit-assignee/request') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @ApiOperation({ + summary: + 'GL ET asks GL Djibouti to name the transit officer — required before the customs declaration', + }) + requestTransitAssignee( + @Param('id', ParseUUIDPipe) id: string, + @Body('note') note: string | undefined, + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.requestTransitAssignee( + id, + note, + resolveAuthUserId(user), + ); + } + + @Post(':id/clearance/transit-assignee/assign') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @ApiOperation({ + summary: + 'GL Djibouti names the transit officer (free text) — unblocks the customs declaration; calling again reassigns', + }) + assignTransitAssignee( + @Param('id', ParseUUIDPipe) id: string, + @Body('assignee') assignee: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.assignTransitAssignee( + id, + assignee, + resolveAuthUserId(user), + ); + } + + @Get(':id/clearance/documents/:fileKey/versions') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceReview) + @ApiOperation({ + summary: + 'Version history of one clearance document — the customer original plus every staff replacement', + }) + documentVersions( + @Param('id', ParseUUIDPipe) id: string, + @Param('fileKey') fileKey: string, + ) { + return this.clearanceService.documentVersions(id, fileKey); + } + + @Post(':id/clearance/documents/:fileKey/replace') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceReview) + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ + summary: + 'GL replaces a clearance document in place (reason required) — the previous version is kept in the file history and the new one needs approving', + }) + replaceClearanceDocument( + @Param('id', ParseUUIDPipe) id: string, + @Param('fileKey') fileKey: string, + @UploadedFile() file: Express.Multer.File, + @Body('reason') reason: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.replaceDocument( + id, + fileKey, + file, + resolveAuthUserId(user), + reason, + ); + } + + @Post(':id/clearance/duty/dispute') + @ApiOperation({ + summary: + 'Customer disputes the advised duty/tax with a reason — reopens the step so GL Ethiopia can re-advise (repeatable)', + }) + disputeContractDuty( + @Param('id', ParseUUIDPipe) id: string, + @Body('note') note: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.disputeDuty(id, note, resolveAuthUserId(user)); + } + @Post(':id/clearance/duty-slip') @UseInterceptors(FileInterceptor('file')) @ApiConsumes('multipart/form-data') @@ -766,19 +854,21 @@ export class ContractsController { @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) @UseInterceptors(FileInterceptor('file')) @ApiConsumes('multipart/form-data') - @ApiOperation({ summary: 'GL DJ uploads Delivery Order (import)' }) + @ApiOperation({ + summary: + 'GL DJ uploads Delivery Order (import) with vessel arrival + DO collected dates', + }) uploadDeliveryOrder( @Param('id', ParseUUIDPipe) id: string, @UploadedFile() file: Express.Multer.File, - @Body('vesselDepartureDate') vesselDepartureDate: string | undefined, + @Body('vesselArrivalDate') vesselArrivalDate: string | undefined, + @Body('doCollectedDate') doCollectedDate: string | undefined, @CurrentUser() user: AuthUserPayload, ) { - return this.clearanceService.uploadDeliveryOrder( - id, - file, - resolveAuthUserId(user), - vesselDepartureDate, - ); + return this.clearanceService.uploadDeliveryOrder(id, file, resolveAuthUserId(user), { + vesselArrivalDate, + doCollectedDate, + }); } @Post(':id/clearance/release-order') diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index 6b16d5647..b9c22d1af 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -86,6 +86,17 @@ export class ContractsRepository extends BaseRepository { .andWhere('contract.status NOT IN (:...terminal)', { terminal: TERMINAL_CONTRACT_STATUSES, }) + // A ONE_TIME contract allows a single booking, so once that booking + // exists the contract is spent and can never carry another shipment. + // Without this it kept blocking new requests on the same service type + + // route until its validity lapsed — locking a customer out of a lane for + // the rest of the term after one completed shipment. + .andWhere( + `(contract.contract_kind <> 'ONE_TIME' OR NOT EXISTS ( + SELECT 1 FROM freight.bookings b + WHERE b.contract_id = contract.id AND b.deleted_at IS NULL + ))`, + ) .getMany(); } @@ -153,7 +164,9 @@ export class ContractsRepository extends BaseRepository { 'contract.files', FileRecord, 'file', - "file.resource_id = contract.id AND file.resource = 'contracts'", + // Superseded versions are soft-deleted, not dropped — keep them out of + // the live file list (a manual join condition is not filtered for us). + "file.resource_id = contract.id AND file.resource = 'contracts' AND file.deleted_at IS NULL", ) .getOne(); @@ -547,6 +560,17 @@ export class ContractsRepository extends BaseRepository { ); } + /** Review notes of one type, newest first — the duty advice/dispute rounds. */ + async findReviewNotes( + contractId: string, + noteType: ContractReviewNoteType, + ): Promise { + return this.dataSource.getRepository(ContractReviewNote).find({ + where: { contractId, noteType }, + order: { createdAt: 'DESC' }, + }); + } + async findLatestReviewNote( contractId: string, noteType?: ContractReviewNoteType, @@ -714,12 +738,20 @@ export class ContractsRepository extends BaseRepository { ContractClearanceCycle, | 'dutyRequired' | 'vesselDepartureDate' + | 'vesselArrivalDate' + | 'doCollectedDate' | 'roAmendmentRequestedAt' | 'roHoldReason' | 'currentPhase' | 'status' | 'preClearanceFinalizedAt' | 'completedAt' + | 'transitAssigneeRequestedAt' + | 'transitAssigneeRequestedByUserId' + | 'transitAssigneeRequestNote' + | 'transitAssigneeName' + | 'transitAssigneeAssignedAt' + | 'transitAssigneeAssignedByUserId' > >, ): Promise { diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index 02459f2a5..43aab750f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -26,6 +26,8 @@ import { Contract, CONTRACT_STATUSES, CONTRACT_CUSTOMER_EDITABLE_STATUSES } from import { ContractRoute } from './entities/contract-route.entity'; import { ContractCargoScope } from './entities/contract-cargo-scope.entity'; import { isEffectivelyExpired } from './utils/contract-expiry.util'; +import { diffContractFields } from './contract-document-diff.util'; +import { ContractDocumentHistoryService } from './contract-document-history.service'; import { FileRecord } from '../files/entities/file.entity'; /** Paginated contract list: flat `total` (backoffice) + `meta` block (portal). */ @@ -42,6 +44,35 @@ export interface PaginatedContracts { }; } +/** Route list as a readable lane string, e.g. "Nagad → Mojo, Mojo → Adama". */ +function describeRoutes(routes?: ContractRoute[]): string | null { + if (!routes?.length) return null; + return [...routes] + .sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)) + .map( + (r) => + `${r.originYard?.label ?? r.originYardId} → ${r.destinationYard?.label ?? r.destinationYardId}`, + ) + .join(', '); +} + +/** Cargo scope as a readable string, e.g. "20ft ×2, 40ft ×1" or "Wheat ×500". */ +function describeCargoScope(scope?: ContractCargoScope[]): string | null { + if (!scope?.length) return null; + return scope + .map((row) => { + const label = + row.containerSize ?? + row.cargoType?.cargoTypeName ?? + row.cargoFreeText ?? + row.cargoTypeId ?? + 'cargo'; + return row.quantityCap != null ? `${label} ×${row.quantityCap}` : String(label); + }) + .sort() + .join(', '); +} + const NEEDS_ACTION_STATUSES = [ 'SUBMITTED', 'PENDING_APPROVAL', @@ -57,6 +88,7 @@ export class ContractsService { private readonly companiesService: CompaniesService, private readonly filesService: FilesService, private readonly minioService: MinioService, + private readonly documentHistory: ContractDocumentHistoryService, ) {} /** Generate a unique contract reference number (CTR-YYYY-NNNNN). */ @@ -332,7 +364,11 @@ export class ContractsService { tradeDirection: dto.tradeDirection, freightType: dto.freightType, serviceTypeId: dto.serviceTypeId, - paymentCurrency: dto.paymentCurrency, + // A contract is always QUOTED in USD — the billing currency is chosen per + // booking (or on the shipment request when GL books for the customer), so + // any client-supplied currency here is ignored. Contracts created before + // this rule keep whatever they stored; update() never rewrites it. + paymentCurrency: 'USD', customsClearingEnabled: includesCustoms, customsClearingAgent: includesCustoms ? null : (dto.customsClearingAgent ?? null), equipmentReturn: dto.equipmentReturn ?? null, @@ -490,6 +526,7 @@ export class ContractsService { id: string, dto: UpdateContractDto, files: Express.Multer.File[], + actorId?: string, ): Promise<{ contract: Contract; warnings: string[] }> { const existing = await this.findById(id); if (!CONTRACT_CUSTOMER_EDITABLE_STATUSES.includes(existing.status as never)) { @@ -516,7 +553,9 @@ export class ContractsService { tradeDirection: dto.tradeDirection ?? existing.tradeDirection, freightType, serviceTypeId: dto.serviceTypeId ?? existing.serviceTypeId, - paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency, + // Never rewritten: grandfathered contracts keep the currency (and frozen + // snapshots) they were signed with. + paymentCurrency: existing.paymentCurrency, isHazardous: dto.isHazardous ?? existing.isHazardous, isReefer: dto.isReefer ?? existing.isReefer, // Same rule as create: clearing the flag clears the declaration with it. @@ -576,7 +615,52 @@ export class ContractsService { existing.companyProfileId ?? null, ); - return { contract: await this.findById(id), warnings }; + const updated = await this.findById(id); + // Audit what this edit actually changed. Runs after the writes so the + // "after" side is read back from the contract rather than from the DTO. + await this.recordFieldRevision(existing, updated, actorId); + + return { contract: updated, warnings }; + } + + /** Fields worth auditing on a customer edit, read off a loaded contract. */ + private auditableFields(contract: Contract): Record { + return { + contractKind: contract.contractKind, + tradeDirection: contract.tradeDirection, + freightType: contract.freightType, + serviceType: contract.serviceType?.serviceName ?? contract.serviceTypeId, + paymentCurrency: contract.paymentCurrency, + contractType: contract.contractType, + isHazardous: contract.isHazardous, + hazardClass: contract.hazardClass, + unNumber: contract.unNumber, + isReefer: contract.isReefer, + equipmentReturn: contract.equipmentReturn, + customsClearingAgent: contract.customsClearingAgent, + firstMilePickupAddress: contract.firstMilePickupAddress, + lastMileDeliveryAddress: contract.lastMileDeliveryAddress, + routes: describeRoutes(contract.routes), + cargoScope: describeCargoScope(contract.cargoScope), + }; + } + + /** Append a revision describing a customer's edit to the contract itself. */ + private async recordFieldRevision( + before: Contract, + after: Contract, + actorId?: string, + ): Promise { + const changes = diffContractFields( + this.auditableFields(before), + this.auditableFields(after), + ); + await this.documentHistory.recordChanges({ + contractId: after.id, + changes, + actorId: actorId ?? null, + actorRole: 'Customer', + }); } /** Parse comma-separated or repeated status query values. */ diff --git a/apps/edr-freight-api/src/modules/contracts/do-collection-dates.spec.ts b/apps/edr-freight-api/src/modules/contracts/do-collection-dates.spec.ts new file mode 100644 index 000000000..37ed6c267 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/do-collection-dates.spec.ts @@ -0,0 +1,46 @@ +import { BadRequestException } from '@nestjs/common'; + +import { assertDoCollectionDates } from './contract-clearance.util'; + +describe('assertDoCollectionDates', () => { + it('requires both dates', () => { + expect(() => assertDoCollectionDates(undefined)).toThrow(BadRequestException); + expect(() => + assertDoCollectionDates({ vesselArrivalDate: '2026-07-01' }), + ).toThrow(/DO collected date is required/); + expect(() => + assertDoCollectionDates({ doCollectedDate: '2026-07-01' }), + ).toThrow(/Vessel arrival date is required/); + // Whitespace is not a date. + expect(() => + assertDoCollectionDates({ vesselArrivalDate: ' ', doCollectedDate: ' ' }), + ).toThrow(BadRequestException); + }); + + it('rejects a DO collected before the vessel arrived', () => { + expect(() => + assertDoCollectionDates({ + vesselArrivalDate: '2026-07-10', + doCollectedDate: '2026-07-09', + }), + ).toThrow(/cannot be earlier than the vessel arrival date/); + }); + + it('normalizes an ISO datetime down to its date part', () => { + expect( + assertDoCollectionDates({ + vesselArrivalDate: '2026-07-10T21:00:00.000Z', + doCollectedDate: '2026-07-10T05:00:00.000Z', + }), + ).toEqual({ vesselArrivalDate: '2026-07-10', doCollectedDate: '2026-07-10' }); + }); + + it('rejects a malformed date', () => { + expect(() => + assertDoCollectionDates({ + vesselArrivalDate: '10/07/2026', + doCollectedDate: '2026-07-10', + }), + ).toThrow(/not a valid date/); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-request.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-request.dto.ts index 11d50494a..9f596bbef 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-request.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-request.dto.ts @@ -2,6 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform, Type } from 'class-transformer'; import { IsArray, + IsIn, IsInt, IsNumber, IsOptional, @@ -91,6 +92,15 @@ export class CreateBookingRequestDto { @Type(() => RequestBulkLineDto) bulk?: RequestBulkLineDto; + @ApiPropertyOptional({ + enum: ['ETB', 'USD'], + description: + 'Billing currency for the shipment GL will book. Intercity is always ETB.', + }) + @IsOptional() + @IsIn(['ETB', 'USD']) + paymentCurrency?: string; + @ApiPropertyOptional() @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts index 93a0e9e76..2ab616c00 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts @@ -15,6 +15,8 @@ import { ValidateNested, } from 'class-validator'; +import { PAYMENT_CURRENCIES } from './create-contract.dto'; + /** Per-shipment equipment return — "NA" stays contract-level only. */ const SHIPMENT_EQUIPMENT_RETURNS = ['WITH_RETURN', 'WITHOUT_RETURN'] as const; @@ -150,6 +152,20 @@ export class CreateBookingUnderContractDto { @IsUUID() contractRouteId?: string; + /** + * The contract quotes in USD; the customer picks the billing currency here. + * Omitted → the contract's own currency (USD for contracts created under the + * current rule, the grandfathered currency for older ones). Intercity is + * forced to ETB by the service regardless of what is sent. + */ + @ApiPropertyOptional({ + enum: PAYMENT_CURRENCIES, + description: 'Billing currency for this shipment. Intercity is always ETB.', + }) + @IsOptional() + @IsIn([...PAYMENT_CURRENCIES]) + paymentCurrency?: string; + @ApiPropertyOptional({ description: 'Binding shipment day. Omitted for intercity (DOMESTIC) bookings — staff assign a passing train later.', diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts index 6e68765f1..88ab8beb7 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts @@ -158,9 +158,20 @@ export class CreateContractDto { @IsUUID() serviceTypeId!: string; - @ApiProperty({ enum: PAYMENT_CURRENCIES }) + /** + * Deprecated at the contract level. A contract now always quotes in USD; the + * customer picks the billing currency per booking (or on the shipment request + * when GL books on their behalf). Accepted but ignored on create so older + * clients don't break — the service forces USD. + */ + @ApiPropertyOptional({ + enum: PAYMENT_CURRENCIES, + deprecated: true, + description: 'Ignored — contracts always quote in USD. Choose currency at booking.', + }) + @IsOptional() @IsIn([...PAYMENT_CURRENCIES]) - paymentCurrency!: string; + paymentCurrency?: string; @ApiPropertyOptional({ description: 'Whether EDR/GL handles customs clearance' }) @IsOptional() diff --git a/apps/edr-freight-api/src/modules/contracts/entities/booking-request.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/booking-request.entity.ts index 6582376d3..b530bb5e9 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/booking-request.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/booking-request.entity.ts @@ -43,6 +43,14 @@ export class BookingRequest extends BaseEntity { @Column({ name: 'requested_lines', type: 'jsonb', default: () => "'{}'::jsonb" }) requestedLines!: Freight.RequestedShipmentLines; + /** + * Billing currency the customer chose for this shipment. The contract quotes + * in USD; on a customs contract GL creates the booking, so this is where the + * customer states which currency to be invoiced in. + */ + @Column({ name: 'payment_currency', type: 'varchar', length: 5, nullable: true }) + paymentCurrency?: string | null; + @Column({ name: 'notes', type: 'text', nullable: true }) notes?: string | null; diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract-clearance-cycle.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract-clearance-cycle.entity.ts index 3c101f58e..8f5aa7e81 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract-clearance-cycle.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract-clearance-cycle.entity.ts @@ -43,6 +43,41 @@ export class ContractClearanceCycle extends BaseEntity { @Column({ name: 'vessel_departure_date', type: 'date', nullable: true }) vesselDepartureDate?: string | null; + /** Import DO: when the vessel arrived in Djibouti. Required on DO upload. */ + @Column({ name: 'vessel_arrival_date', type: 'date', nullable: true }) + vesselArrivalDate?: string | null; + + /** Import DO: when GL Djibouti collected the DO. Required on DO upload. */ + @Column({ name: 'do_collected_date', type: 'date', nullable: true }) + doCollectedDate?: string | null; + + /** + * Transit-assignee handshake that runs BEFORE the customs declaration: GL + * Ethiopia asks Djibouti for the officer who will handle the shipment in + * transit, and Djibouti answers with a name. The declaration step stays shut + * until `transitAssigneeName` is set; Djibouti may overwrite it later + * (reassignment) and the newer name simply wins. + */ + @Column({ name: 'transit_assignee_requested_at', type: 'timestamptz', nullable: true }) + transitAssigneeRequestedAt?: Date | null; + + @Column({ name: 'transit_assignee_requested_by_user_id', type: 'uuid', nullable: true }) + transitAssigneeRequestedByUserId?: string | null; + + /** What GL Ethiopia asked for — shown on the Djibouti queue. */ + @Column({ name: 'transit_assignee_request_note', type: 'text', nullable: true }) + transitAssigneeRequestNote?: string | null; + + /** The officer Djibouti named — free text, no user directory to bind to. */ + @Column({ name: 'transit_assignee_name', type: 'text', nullable: true }) + transitAssigneeName?: string | null; + + @Column({ name: 'transit_assignee_assigned_at', type: 'timestamptz', nullable: true }) + transitAssigneeAssignedAt?: Date | null; + + @Column({ name: 'transit_assignee_assigned_by_user_id', type: 'uuid', nullable: true }) + transitAssigneeAssignedByUserId?: string | null; + @Column({ name: 'ro_amendment_requested_at', type: 'timestamptz', nullable: true }) roAmendmentRequestedAt?: Date | null; diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract-document-revision.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract-document-revision.entity.ts index bc7e12e3e..a832cb50c 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract-document-revision.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract-document-revision.entity.ts @@ -21,6 +21,13 @@ export class ContractDocumentRevision extends BaseEntity { @Column({ name: 'actor_id', type: 'uuid', nullable: true }) actorId?: string | null; + /** + * Who made the edit, captured at the time. Denormalised so the trail still + * names them after a rename or a deactivated account. + */ + @Column({ name: 'actor_name', type: 'varchar', length: 200, nullable: true }) + actorName?: string | null; + /** The approval step's required role at the time of the edit. */ @Column({ name: 'actor_role', type: 'varchar', length: 64, nullable: true }) actorRole?: string | null; diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract-review-note.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract-review-note.entity.ts index 5b744338e..4d2a46365 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract-review-note.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract-review-note.entity.ts @@ -8,6 +8,11 @@ export const CONTRACT_REVIEW_NOTE_TYPES = [ 'STAFF_NOTE', 'CUSTOMER_NOTE', 'AMENDMENT', + /** + * The customer disputed the advised duty & tax and asked GL Ethiopia to + * correct it. One row per round — the advice/dispute loop can repeat. + */ + 'DUTY_DISPUTE', ] as const; export type ContractReviewNoteType = (typeof CONTRACT_REVIEW_NOTE_TYPES)[number]; diff --git a/apps/edr-freight-api/src/modules/contracts/shipment-currency.spec.ts b/apps/edr-freight-api/src/modules/contracts/shipment-currency.spec.ts new file mode 100644 index 000000000..7bd109430 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/shipment-currency.spec.ts @@ -0,0 +1,85 @@ +import { ContractBookingService } from './contract-booking.service'; +import { BookingPricingService } from '../bookings/booking-pricing.service'; +import type { Contract } from './entities/contract.entity'; +import type { ContractRateSnapshot } from './entities/contract-rate-snapshot.entity'; + +const contract = (over: Partial): Contract => + ({ tradeDirection: 'IMPORT', paymentCurrency: 'USD', ...over }) as Contract; + +/** The private resolver, reached without standing up the whole Nest graph. */ +const resolveCurrency = (c: Contract, requested?: string | null): string => + ( + ContractBookingService.prototype as unknown as { + resolveShipmentCurrency: (c: Contract, r?: string | null) => string; + } + ).resolveShipmentCurrency(c, requested); + +const snapshot = (currency: string, unitPrice: number): ContractRateSnapshot => + ({ rateCode: 'CONTAINER_20FT', currency, unitPrice }) as ContractRateSnapshot; + +const frozenByCode = ( + snap: ContractRateSnapshot | null, + bookingCurrency: string, + usdToEtb: number, +): ContractRateSnapshot | null => + ( + BookingPricingService.prototype as unknown as { + frozenRateByCode: ( + m: Map | null, + code: string, + bookingCurrency: string, + usdToEtb: number, + ) => ContractRateSnapshot | null; + } + ).frozenRateByCode( + snap ? new Map([['CONTAINER_20FT', snap]]) : null, + 'CONTAINER_20FT', + bookingCurrency, + usdToEtb, + ); + +describe('per-shipment billing currency', () => { + it('takes the customer choice over the contract', () => { + expect(resolveCurrency(contract({}), 'ETB')).toBe('ETB'); + expect(resolveCurrency(contract({}), 'USD')).toBe('USD'); + }); + + it('falls back to the contract currency when none is chosen', () => { + // Grandfathered ETB contract with no explicit choice. + expect(resolveCurrency(contract({ paymentCurrency: 'ETB' }))).toBe('ETB'); + expect(resolveCurrency(contract({}), ' ')).toBe('USD'); + }); + + it('forces ETB on intercity whatever was requested', () => { + const domestic = contract({ tradeDirection: 'DOMESTIC' }); + expect(resolveCurrency(domestic, 'USD')).toBe('ETB'); + expect(resolveCurrency(domestic)).toBe('ETB'); + }); +}); + +describe('frozen contract rate in the booking currency', () => { + it('converts a USD snapshot for an ETB booking instead of dropping it', () => { + // The old behaviour returned null here, which silently re-priced the + // booking at live rates and lost the agreed contract price. + expect(frozenByCode(snapshot('USD', 400), 'ETB', 150)?.unitPrice).toBe(60_000); + }); + + it('converts a grandfathered ETB snapshot back for a USD booking', () => { + expect(frozenByCode(snapshot('ETB', 60_000), 'USD', 150)?.unitPrice).toBe(400); + }); + + it('passes a matching-currency snapshot through untouched', () => { + const snap = snapshot('USD', 400); + expect(frozenByCode(snap, 'USD', 1)).toBe(snap); + }); + + it('refuses to price off an unusable exchange rate', () => { + // Converting with 0 would zero the whole line. + expect(frozenByCode(snapshot('USD', 400), 'ETB', 0)).toBeNull(); + expect(frozenByCode(snapshot('USD', 400), 'ETB', Number.NaN)).toBeNull(); + }); + + it('returns null when there is no snapshot', () => { + expect(frozenByCode(null, 'ETB', 150)).toBeNull(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/transit-assignee.spec.ts b/apps/edr-freight-api/src/modules/contracts/transit-assignee.spec.ts new file mode 100644 index 000000000..da4b2f34b --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/transit-assignee.spec.ts @@ -0,0 +1,175 @@ +import { BadRequestException } from '@nestjs/common'; + +import { ContractClearanceService } from './contract-clearance.service'; +import type { Contract } from './entities/contract.entity'; + +/** + * Pre-declaration transit-assignee handshake. GL Ethiopia asks Djibouti who will + * handle the shipment in transit; Djibouti answers with a name. The customs + * declaration stays shut until that name exists, and Djibouti may send a + * different one later. + */ +describe('ContractClearanceService — transit assignee', () => { + const contract = (over: Partial = {}): Contract => + ({ + id: 'ctr-1', + reference: 'CTR-2026-00042', + tradeDirection: 'IMPORT', + customsClearingEnabled: true, + contractKind: 'ONE_TIME', + ...over, + }) as Contract; + + let repo: { currentCycle: jest.Mock; updateCycle: jest.Mock }; + let contractsService: { findById: jest.Mock }; + let notifier: { + transitAssigneeRequested: jest.Mock; + transitAssigneeAssigned: jest.Mock; + }; + let service: ContractClearanceService; + + const cycle = (over: Record = {}) => ({ + id: 'cyc-1', + transitAssigneeRequestedAt: null, + transitAssigneeName: null, + ...over, + }); + + beforeEach(() => { + repo = { + currentCycle: jest.fn().mockResolvedValue(cycle()), + updateCycle: jest.fn().mockResolvedValue(undefined), + }; + contractsService = { findById: jest.fn().mockResolvedValue(contract()) }; + notifier = { + transitAssigneeRequested: jest.fn(), + transitAssigneeAssigned: jest.fn(), + }; + service = new ContractClearanceService( + repo as never, + contractsService as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + notifier as never, + ); + }); + + describe('request (GL Ethiopia)', () => { + it('stamps the ask and pings Djibouti', async () => { + await service.requestTransitAssignee('ctr-1', ' Reefer, needs a cold-chain officer ', 'et-1'); + + const patch = repo.updateCycle.mock.calls[0][1]; + expect(patch.transitAssigneeRequestedAt).toBeInstanceOf(Date); + expect(patch.transitAssigneeRequestedByUserId).toBe('et-1'); + expect(patch.transitAssigneeRequestNote).toBe( + 'Reefer, needs a cold-chain officer', + ); + expect(notifier.transitAssigneeRequested).toHaveBeenCalled(); + }); + }); + + describe('assign (GL Djibouti)', () => { + it('records the officer and tells Ethiopia they can proceed', async () => { + repo.currentCycle.mockResolvedValue( + cycle({ transitAssigneeRequestedAt: new Date() }), + ); + + await service.assignTransitAssignee('ctr-1', ' Ahmed Bourhan ', 'dj-1'); + + const patch = repo.updateCycle.mock.calls[0][1]; + expect(patch.transitAssigneeName).toBe('Ahmed Bourhan'); + expect(patch.transitAssigneeAssignedByUserId).toBe('dj-1'); + expect(notifier.transitAssigneeAssigned).toHaveBeenCalledWith( + expect.objectContaining({ id: 'ctr-1' }), + 'Ahmed Bourhan', + null, + ); + }); + + it('reassigns, carrying the previous name into the notice', async () => { + repo.currentCycle.mockResolvedValue( + cycle({ + transitAssigneeRequestedAt: new Date(), + transitAssigneeName: 'Ahmed Bourhan', + }), + ); + + await service.assignTransitAssignee('ctr-1', 'Fatouma Ali', 'dj-1'); + + expect(notifier.transitAssigneeAssigned).toHaveBeenCalledWith( + expect.anything(), + 'Fatouma Ali', + 'Ahmed Bourhan', + ); + }); + + it('refuses an empty name', async () => { + repo.currentCycle.mockResolvedValue( + cycle({ transitAssigneeRequestedAt: new Date() }), + ); + await expect( + service.assignTransitAssignee('ctr-1', ' ', 'dj-1'), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('refuses before Ethiopia has asked', async () => { + await expect( + service.assignTransitAssignee('ctr-1', 'Ahmed Bourhan', 'dj-1'), + ).rejects.toThrow(/not requested/i); + }); + }); + + describe('declaration gate', () => { + const ensure = (c: Contract) => + ( + service as unknown as { + ensureDeclarationPrerequisites: (id: string, c: Contract) => Promise; + } + ).ensureDeclarationPrerequisites('ctr-1', c); + + beforeEach(() => { + // Documents are approved; only the assignee decides the outcome here. + ( + service as unknown as { isClearanceFullyApproved: unknown } + ).isClearanceFullyApproved = jest.fn().mockResolvedValue(true); + }); + + it('tells GL to raise the request when none exists', async () => { + await expect(ensure(contract())).rejects.toThrow( + /Request a transit assignee/i, + ); + }); + + it('tells GL to wait when Djibouti has not answered', async () => { + repo.currentCycle.mockResolvedValue( + cycle({ transitAssigneeRequestedAt: new Date() }), + ); + await expect(ensure(contract())).rejects.toThrow(/has not assigned/i); + }); + + it('lets the declaration through once the officer is named', async () => { + repo.currentCycle.mockResolvedValue( + cycle({ + transitAssigneeRequestedAt: new Date(), + transitAssigneeName: 'Ahmed Bourhan', + }), + ); + ( + service as unknown as { workflowService: unknown } + ).workflowService = { + listMilestones: jest + .fn() + .mockResolvedValue([ + { milestoneCode: 'DOCUMENTS_APPROVED', status: 'COMPLETED' }, + ]), + }; + + await expect(ensure(contract())).resolves.toBeUndefined(); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/files/entities/file.entity.ts b/apps/edr-freight-api/src/modules/files/entities/file.entity.ts index 1fbd1459f..7624800d8 100644 --- a/apps/edr-freight-api/src/modules/files/entities/file.entity.ts +++ b/apps/edr-freight-api/src/modules/files/entities/file.entity.ts @@ -53,4 +53,16 @@ export class FileRecord extends BaseEntity { @Column({ name: "reviewed_at", type: "timestamptz", nullable: true }) reviewedAt!: Date | null; + + /** + * Who replaced this version, when a newer file took its place. Superseded + * versions are soft-deleted rather than dropped, so the original a customer + * uploaded survives a staff correction and the two can be compared. + */ + @Column({ name: "replaced_by_user_id", type: "uuid", nullable: true }) + replacedByUserId!: string | null; + + /** Why the file was replaced — shown on the document's version history. */ + @Column({ name: "replace_reason", type: "text", nullable: true }) + replaceReason!: string | null; } diff --git a/apps/edr-freight-api/src/modules/files/files.repository.ts b/apps/edr-freight-api/src/modules/files/files.repository.ts index 583e221ed..9a2552c7a 100644 --- a/apps/edr-freight-api/src/modules/files/files.repository.ts +++ b/apps/edr-freight-api/src/modules/files/files.repository.ts @@ -41,12 +41,47 @@ export class FilesRepository extends BaseRepository { return this.repository.findOne({ where: { resourceId, resource, code } }); } + /** + * Retire the live version(s) of a document code. SOFT delete on purpose: the + * bytes and the row stay so the original upload can still be read back from + * the version history after staff replace it. Every normal read already + * filters soft-deleted rows, so callers see only the current version. + * + * `replacedBy` / `reason` are stamped on the retired row when a newer file is + * taking its place (as opposed to a plain removal). + */ async deleteByCode( resourceId: string, resource: string, code: string, + replacedBy?: { userId?: string | null; reason?: string | null }, ): Promise { - await this.repository.delete({ resourceId, resource, code }); + if (replacedBy) { + await this.repository.update( + { resourceId, resource, code }, + { + replacedByUserId: replacedBy.userId ?? null, + replaceReason: replacedBy.reason ?? null, + }, + ); + } + await this.repository.softDelete({ resourceId, resource, code }); + } + + /** + * Every version of one document code, newest first — superseded versions + * included. The only read that deliberately looks past the soft-delete filter. + */ + findVersionHistory( + resourceId: string, + resource: string, + code: string, + ): Promise { + return this.repository.find({ + where: { resourceId, resource, code }, + withDeleted: true, + order: { createdAt: "DESC" }, + }); } /** diff --git a/apps/edr-freight-api/src/modules/files/files.service.spec.ts b/apps/edr-freight-api/src/modules/files/files.service.spec.ts new file mode 100644 index 000000000..fc6fb82d3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/files/files.service.spec.ts @@ -0,0 +1,112 @@ +import { FilesService } from './files.service'; + +/** + * Replacing a stored document must never destroy the previous one: the customer + * uploaded it, and a staff correction has to stay auditable against it. The old + * row is soft-deleted (so every normal read still returns exactly the current + * version) and stamped with who replaced it and why. + */ +describe('FilesService — document versions', () => { + const file = { + originalname: 'bill-of-lading.pdf', + size: 1234, + mimetype: 'application/pdf', + buffer: Buffer.from('x'), + } as Express.Multer.File; + + let filesRepository: { + deleteByCode: jest.Mock; + create: jest.Mock; + findVersionHistory: jest.Mock; + }; + let service: FilesService; + + beforeEach(() => { + filesRepository = { + deleteByCode: jest.fn().mockResolvedValue(undefined), + create: jest.fn(async (row) => ({ id: 'file-new', ...row })), + findVersionHistory: jest.fn().mockResolvedValue([]), + }; + service = new FilesService( + filesRepository as never, + { + uploadFile: jest.fn().mockResolvedValue('https://minio/bucket/new.pdf'), + getObjectNameFromUrl: (u: string) => u, + getSignedUrl: jest.fn(), + } as never, + ); + }); + + it('stamps the retired version with who replaced it and why', async () => { + await service.upsertByCode( + { resourceId: 'ctr-1', resource: 'contracts', code: 'bill_of_lading', file }, + { userId: 'gl-user-1', reason: 'Customer sent page 2 only' }, + ); + + expect(filesRepository.deleteByCode).toHaveBeenCalledWith( + 'ctr-1', + 'contracts', + 'bill_of_lading', + { userId: 'gl-user-1', reason: 'Customer sent page 2 only' }, + ); + }); + + it('still replaces silently when no replacer is given (system overwrites)', async () => { + await service.upsertByCode({ + resourceId: 'ctr-1', + resource: 'contracts', + code: 'contract_pdf', + file, + }); + + expect(filesRepository.deleteByCode).toHaveBeenCalledWith( + 'ctr-1', + 'contracts', + 'contract_pdf', + undefined, + ); + }); + + it('marks the live row current and the soft-deleted ones superseded', async () => { + filesRepository.findVersionHistory.mockResolvedValue([ + { + id: 'v2', + name: 'corrected.pdf', + url: 'u2', + size: 2, + mimeType: 'application/pdf', + createdAt: new Date('2026-07-20T10:00:00Z'), + deletedAt: null, + replacedByUserId: null, + replaceReason: null, + }, + { + id: 'v1', + name: 'original.pdf', + url: 'u1', + size: 1, + mimeType: 'application/pdf', + createdAt: new Date('2026-07-18T10:00:00Z'), + deletedAt: new Date('2026-07-20T10:00:00Z'), + replacedByUserId: 'gl-user-1', + replaceReason: 'Wrong page order', + }, + ]); + + const versions = await service.versionHistory( + 'ctr-1', + 'contracts', + 'bill_of_lading', + ); + + expect(versions[0]).toMatchObject({ id: 'v2', isCurrent: true, replacedAt: null }); + expect(versions[1]).toMatchObject({ + id: 'v1', + isCurrent: false, + replacedByUserId: 'gl-user-1', + replaceReason: 'Wrong page order', + }); + // The customer's original is still readable — that is the whole point. + expect(versions[1].url).toBe('u1'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/files/files.service.ts b/apps/edr-freight-api/src/modules/files/files.service.ts index e959fa556..ea11e5fe8 100644 --- a/apps/edr-freight-api/src/modules/files/files.service.ts +++ b/apps/edr-freight-api/src/modules/files/files.service.ts @@ -104,13 +104,66 @@ export class FilesService { }); } - /** Replace existing file row for the same resource + code (e.g. contract PDF). */ - async upsertByCode(input: CreateFileInput): Promise { + /** + * Replace the file stored under a resource + code (e.g. contract PDF). The + * previous version is retired, not destroyed — pass `replacedBy` to record who + * swapped it and why, which is what the version history shows. + */ + async upsertByCode( + input: CreateFileInput, + replacedBy?: { userId?: string | null; reason?: string | null }, + ): Promise { const { resourceId, resource, code } = input; - await this.filesRepository.deleteByCode(resourceId, resource, code); + await this.filesRepository.deleteByCode( + resourceId, + resource, + code, + replacedBy, + ); return this.upload(input); } + /** + * Every stored version of one document, newest first. `isCurrent` marks the + * live row; the rest are superseded uploads kept for audit. + */ + async versionHistory( + resourceId: string, + resource: string, + code: string, + ): Promise< + Array<{ + id: string; + name: string; + url: string; + size: number; + mimeType: string; + uploadedAt: string; + isCurrent: boolean; + replacedAt: string | null; + replacedByUserId: string | null; + replaceReason: string | null; + }> + > { + const rows = await this.filesRepository.findVersionHistory( + resourceId, + resource, + code, + ); + return rows.map((row) => ({ + id: row.id, + name: row.name, + url: row.url, + size: row.size, + mimeType: row.mimeType, + uploadedAt: row.createdAt.toISOString(), + isCurrent: row.deletedAt == null, + replacedAt: row.deletedAt ? row.deletedAt.toISOString() : null, + replacedByUserId: row.replacedByUserId, + replaceReason: row.replaceReason, + })); + } + async deleteByCode( resourceId: string, resource: string, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/consist-order.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/consist-order.util.spec.ts new file mode 100644 index 000000000..44fff49e8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/consist-order.util.spec.ts @@ -0,0 +1,94 @@ +import { orderConsistWagons } from './consist-order.util'; + +// Built train: A-B-C-D coupled in that order. Slots are created by the wagon +// PLAN, so their sequenceNo says nothing about where the wagon actually sits. +const TRAIN = ['A', 'B', 'C', 'D']; + +const slot = (sequenceNo: number, physicalWagonId: string | null) => ({ + sequenceNo, + physicalWagonId, +}); + +describe('orderConsistWagons', () => { + it('draws slots in the train coupling order, not slot order', () => { + // Plan order says D then B; the train says B sits ahead of D. + const drawn = orderConsistWagons([slot(1, 'D'), slot(2, 'B')], { + physicalWagonIdsInOrder: TRAIN, + }); + + expect(drawn.map((w) => w.physicalWagonId)).toEqual(['B', 'D']); + expect(drawn.map((w) => w.position)).toEqual([1, 2]); + }); + + it('interleaves empty consist wagons in their real place', () => { + // Loaded slots on A and C; B and D ride along empty. The empties used to be + // appended after every loaded slot, so the drawing was never the train. + const drawn = orderConsistWagons( + [slot(1, 'A'), slot(2, 'C'), slot(98, 'B'), slot(99, 'D')], + { physicalWagonIdsInOrder: TRAIN }, + ); + + expect(drawn.map((w) => w.physicalWagonId)).toEqual(['A', 'B', 'C', 'D']); + }); + + it('keeps every wagon in place when a load moves between wagons', () => { + // Load sat on A (slot 1); staff drag it onto empty D. The move repins the + // slot, so the SAME slot now reads as wagon D and A falls back to empty. + const before = orderConsistWagons([slot(1, 'A'), slot(98, 'D')], { + physicalWagonIdsInOrder: TRAIN, + }); + const after = orderConsistWagons([slot(1, 'D'), slot(98, 'A')], { + physicalWagonIdsInOrder: TRAIN, + }); + + // A is drawn first and D last, before and after — the train did not shuffle. + expect(before.map((w) => w.physicalWagonId)).toEqual(['A', 'D']); + expect(after.map((w) => w.physicalWagonId)).toEqual(['A', 'D']); + }); + + it('follows a train-builder reorder without touching any slot row', () => { + const slots = [slot(1, 'A'), slot(2, 'B')]; + + // Builder swaps the coupling order; the slots are untouched. + const drawn = orderConsistWagons(slots, { + physicalWagonIdsInOrder: ['B', 'A', 'C', 'D'], + }); + + expect(drawn.map((w) => w.physicalWagonId)).toEqual(['B', 'A']); + }); + + it('draws back-to-front when the caller reverses the train', () => { + const drawn = orderConsistWagons([slot(1, 'A'), slot(2, 'C')], { + physicalWagonIdsInOrder: [...TRAIN].reverse(), + reverseWagonOrder: true, + }); + + expect(drawn.map((w) => w.physicalWagonId)).toEqual(['C', 'A']); + }); + + it('parks unpinned slots last, in slot order', () => { + const drawn = orderConsistWagons([slot(9, null), slot(4, null), slot(1, 'C')], { + physicalWagonIdsInOrder: TRAIN, + }); + + expect(drawn.map((w) => [w.physicalWagonId, w.sequenceNo])).toEqual([ + ['C', 1], + [null, 4], + [null, 9], + ]); + }); + + it('falls back to slot order when there is no built train', () => { + // Frozen schedules and loose-wagon schedules pass no physical order. + const drawn = orderConsistWagons([slot(2, 'X'), slot(1, 'Y')], { + physicalWagonIdsInOrder: [], + }); + expect(drawn.map((w) => w.sequenceNo)).toEqual([1, 2]); + + const reversed = orderConsistWagons([slot(1, 'X'), slot(2, 'Y')], { + physicalWagonIdsInOrder: [], + reverseWagonOrder: true, + }); + expect(reversed.map((w) => w.sequenceNo)).toEqual([2, 1]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/consist-order.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/consist-order.util.ts new file mode 100644 index 000000000..496b91952 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/consist-order.util.ts @@ -0,0 +1,54 @@ +/** + * Draw order for a schedule's consist. + * + * A slot's stored `sequenceNo` is its place in the wagon PLAN, not its place in + * the train. The train's real coupling order lives on the physical wagons + * (`wagons.sequence_number`), which the caller passes in already ordered — ASC + * normally, DESC for a `reverseWagonOrder` schedule. + * + * Ordering by the physical wagon is what keeps the drawing honest: + * - moving a load between wagons repaints WHICH wagon is loaded and never + * shuffles the train, because each slot is drawn wherever its wagon sits; + * - a train-builder reorder lands on the next read, allocations included, + * since the order is derived on every read instead of copied at pin time. + * + * Slots with no physical wagon (not pinned yet, or a schedule that isn't tied + * to a built train) have no place in the consist — they keep slot order, last. + */ +export interface ConsistOrderable { + sequenceNo: number; + physicalWagonId?: string | null; +} + +export interface ConsistOrderOptions { + /** + * Every wagon coupled to the built train, in real coupling order (already + * reversed by the caller for a `reverseWagonOrder` schedule). Empty for a + * frozen schedule or one with no built train — the consist then keeps slot + * order. + */ + physicalWagonIdsInOrder: string[]; + reverseWagonOrder?: boolean; +} + +export const orderConsistWagons = ( + wagons: T[], + { physicalWagonIdsInOrder, reverseWagonOrder }: ConsistOrderOptions, +): (T & { position: number })[] => { + const physicalOrder = new Map(physicalWagonIdsInOrder.map((id, index) => [id, index])); + const bySlotSequence = (a: T, b: T) => + reverseWagonOrder ? b.sequenceNo - a.sequenceNo : a.sequenceNo - b.sequenceNo; + + const ordered = physicalOrder.size + ? [...wagons].sort((a, b) => { + const ai = a.physicalWagonId ? physicalOrder.get(a.physicalWagonId) : undefined; + const bi = b.physicalWagonId ? physicalOrder.get(b.physicalWagonId) : undefined; + if (ai == null && bi == null) return bySlotSequence(a, b); + if (ai == null) return 1; + if (bi == null) return -1; + return ai - bi; + }) + : [...wagons].sort(bySlotSequence); + + return ordered.map((wagon, index) => ({ ...wagon, position: index + 1 })); +}; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index da8982d95..fe5bb3a8e 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -147,6 +147,7 @@ import { DEFAULT_CONTAINER_WAGON_LENGTH_METERS, DEFAULT_CONTAINER_WAGON_TARE_TONS, } from './booking-batch.constants'; +import { orderConsistWagons } from './consist-order.util'; import { computeExportWindowTimes, computeImportWindowTimes, @@ -6696,6 +6697,18 @@ export class TrainSchedulingService { consistOnly: true, })); + // The consist is DRAWN in the built train's real coupling order (rawConsistWagons + // is already ASC/DESC per reverseWagonOrder), not in slot order — see + // consist-order.util. `position` is the drawn place, 1..n; `sequenceNo` stays + // the slot's own stored value. + const drawConsist = ( + list: T[], + ) => + orderConsistWagons(list, { + physicalWagonIdsInOrder: rawConsistWagons.map((wagon) => wagon.id), + reverseWagonOrder: schedule.reverseWagonOrder, + }); + return { id: schedule.id, reference: schedule.reference ?? null, @@ -6785,7 +6798,8 @@ export class TrainSchedulingService { maxPullWeightTons: roundTons(Number(loco.maxPullWeightTons)), maxTrainLengthMeters: roundTons(Number(loco.maxTrainLengthMeters)), })), - wagons: (schedule.trainSet.wagons ?? []) + wagons: drawConsist( + (schedule.trainSet.wagons ?? []) .map((wagon) => { // Frozen schedules read the wagon number + allocations from the // snapshot slot; the immutable slot geometry (capacity/type) still @@ -6875,12 +6889,8 @@ export class TrainSchedulingService { })) ?? [], }; }) - .concat(emptyConsistWagons) - .sort((a, b) => - schedule.reverseWagonOrder - ? b.sequenceNo - a.sequenceNo - : a.sequenceNo - b.sequenceNo, - ), + .concat(emptyConsistWagons), + ), } : null, bookings: @@ -7458,8 +7468,12 @@ export class TrainSchedulingService { ]; const cargoOf = (allocs: WagonBookingAllocation[]) => allocs.reduce((sum, a) => sum + Number(a.allocatedWeightTons || 0), 0); - const wagonLabel = (slot: { sequenceNo: number } | null, wagon: Wagon | null) => - slot ? `#${slot.sequenceNo}` : (wagon?.wagonNumber ?? 'the target wagon'); + // Name wagons by their physical number — the consist is drawn in the train's + // coupling order, so a slot's sequenceNo is not the position staff can see. + const slotLabel = (slot: TrainSetWagon) => + slot.physicalWagon?.wagonNumber ?? `#${slot.sequenceNo}`; + const wagonLabel = (slot: TrainSetWagon | null, wagon: Wagon | null) => + slot ? slotLabel(slot) : (wagon?.wagonNumber ?? 'the target wagon'); const checkReceives = ( allocs: WagonBookingAllocation[], label: string, @@ -7501,7 +7515,7 @@ export class TrainSchedulingService { if (targetAllocs.length) { checkReceives( targetAllocs, - `#${source.sequenceNo}`, + slotLabel(source), source.wagonType, Number(source.capacityTons), ); diff --git a/apps/edr-freight-api/src/modules/wagons/dto/close-short-transfer-request.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/close-short-transfer-request.dto.ts new file mode 100644 index 000000000..a277cfe72 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/dto/close-short-transfer-request.dto.ts @@ -0,0 +1,17 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, MaxLength } from 'class-validator'; + +/** + * OCC ends a transfer request with fewer wagons than asked for. The note is + * carried into the requester's notification — it is what tells them WHY the + * yard could not give the rest. + */ +export class CloseShortTransferRequestDto { + @ApiPropertyOptional({ + description: 'Why the source yard cannot supply the remainder', + }) + @IsOptional() + @IsString() + @MaxLength(2000) + note?: string; +} diff --git a/apps/edr-freight-api/src/modules/wagons/dto/list-transfer-requests-query.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/list-transfer-requests-query.dto.ts new file mode 100644 index 000000000..733b774bb --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/dto/list-transfer-requests-query.dto.ts @@ -0,0 +1,44 @@ +import { WagonTransferRequestStatus } from '@edr/types'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsIn, IsOptional, IsUUID } from 'class-validator'; + +import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto'; + +const SORT_FIELDS = ['createdAt', 'quantity', 'status'] as const; + +/** + * Transfer-desk list query. `status` accepts a comma-separated list so the + * "Open" tab can ask for PENDING + PARTIALLY_FULFILLED in one call. + */ +export class ListTransferRequestsQueryDto extends PaginationQueryDto { + @ApiPropertyOptional({ + description: 'One status or a comma-separated list', + enum: WagonTransferRequestStatus, + }) + @IsOptional() + @Transform(({ value }) => + typeof value === 'string' && value.trim() ? value.trim() : undefined, + ) + status?: string; + + @ApiPropertyOptional({ description: 'Source yard' }) + @IsOptional() + @IsUUID() + fromYardId?: string; + + @ApiPropertyOptional({ description: 'Destination yard' }) + @IsOptional() + @IsUUID() + toYardId?: string; + + @ApiPropertyOptional({ description: 'Wagon type' }) + @IsOptional() + @IsUUID() + wagonTypeId?: string; + + @ApiPropertyOptional({ enum: SORT_FIELDS, default: 'createdAt' }) + @IsOptional() + @IsIn([...SORT_FIELDS]) + sortBy?: (typeof SORT_FIELDS)[number]; +} diff --git a/apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts b/apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts index c81b6c365..c39b12b9d 100644 --- a/apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts +++ b/apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts @@ -40,6 +40,14 @@ export class WagonTransferRequest extends BaseEntity { @Column({ name: 'quantity', type: 'int' }) quantity!: number; + /** + * How many have actually moved so far. OCC sends what the yard can spare, + * whenever it can — the request stays open until this reaches `quantity` or + * OCC closes it short. + */ + @Column({ name: 'fulfilled_quantity', type: 'int', default: 0 }) + fulfilledQuantity!: number; + @Column({ name: 'status', type: 'varchar', @@ -54,9 +62,17 @@ export class WagonTransferRequest extends BaseEntity { @Column({ name: 'fulfilled_by_user_id', type: 'uuid', nullable: true }) fulfilledByUserId?: string | null; + /** When the LAST transfer against this request ran (not necessarily the full count). */ @Column({ name: 'fulfilled_at', type: 'timestamptz', nullable: true }) fulfilledAt?: Date | null; + /** Set when OCC ended the request with fewer wagons than asked for. */ + @Column({ name: 'closed_short_at', type: 'timestamptz', nullable: true }) + closedShortAt?: Date | null; + + @Column({ name: 'closed_short_by_user_id', type: 'uuid', nullable: true }) + closedShortByUserId?: string | null; + @Column({ name: 'note', type: 'text', nullable: true }) note?: string | null; diff --git a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts index b00e4a64f..9e69c3bf6 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts @@ -1,4 +1,3 @@ -import { WagonTransferRequestStatus } from '@edr/types'; import { Body, Controller, @@ -13,26 +12,36 @@ import { CurrentUser } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { - FleetManage, - FleetView, + WagonTransferCancel, + WagonTransferCloseShort, WagonTransferFulfill, WagonTransferHistoryAll, WagonTransferRequest, + WagonTransferView, } from '../../common/booking-guards'; -import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { BulkFulfillTransferRequestsDto } from './dto/bulk-fulfill-transfer-requests.dto'; +import { CloseShortTransferRequestDto } from './dto/close-short-transfer-request.dto'; import { CreateTransferRequestDto } from './dto/create-transfer-request.dto'; import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto'; +import { ListTransferRequestsQueryDto } from './dto/list-transfer-requests-query.dto'; import { WagonTransferRequestsService } from './wagon-transfer-requests.service'; +/** Query-string number, or undefined when absent/garbage (service defaults it). */ +const toInt = (value?: string): number | undefined => { + const n = Number.parseInt(String(value ?? ''), 10); + return Number.isFinite(n) && n > 0 ? n : undefined; +}; + /** - * Two-person wagon-transfer queue. Requester (transfer_request perm) files a - * count-only request; OCC (transfer_fulfill perm) picks the wagons and executes - * the move. Separate top-level path so it never collides with `wagons/:id`. + * The wagon-transfer desk. A requester (transfer_request) files a count-only + * request; OCC (transfer_fulfill) moves wagons against it in as many + * instalments as the source yard allows, and closes it short + * (transfer_close_short) when the yard has no more to give. Separate top-level + * path so it never collides with `wagons/:id`. */ @ApiTags('wagon-transfer-requests') @Controller('wagon-transfer-requests') -@FleetView(FREIGHT_PERMS.wagons.view) +@WagonTransferView() export class WagonTransferRequestsController { constructor(private readonly service: WagonTransferRequestsService) {} @@ -47,10 +56,12 @@ export class WagonTransferRequestsController { } @Get() - @ApiQuery({ name: 'status', required: false, enum: WagonTransferRequestStatus }) - @ApiOperation({ summary: 'List transfer requests (OCC queue: status=PENDING)' }) - list(@Query('status') status?: WagonTransferRequestStatus) { - return this.service.listRequests(status); + @ApiOperation({ + summary: + 'Transfer desk list — paginated, filterable by status (comma-separated), yards and wagon type', + }) + list(@Query() query: ListTransferRequestsQueryDto) { + return this.service.listRequests(query); } // NOTE: static routes (`history`, `bulk-fulfill`) MUST stay above `@Get(':id')` @@ -73,24 +84,48 @@ export class WagonTransferRequestsController { // matches in declaration order, so `/history` would otherwise be captured by // the `:id` param route (and rejected by ParseUUIDPipe). @Get('history') + @ApiQuery({ name: 'page', required: false }) + @ApiQuery({ name: 'pageSize', required: false }) @ApiOperation({ summary: "Caller's own transfer history (requests filed/fulfilled + wagons moved)", }) - myHistory(@CurrentUser() user: TCurrentUser) { + myHistory( + @CurrentUser() user: TCurrentUser, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { // Never fall through to the all-staff view: getHistory(undefined) means // "everyone", so a missing caller id must return empty, not leak scope. - if (!user?.id) return { requests: [], movements: [] }; - return this.service.getHistory(user.id); + if (!user?.id) { + return { + requests: [], + movements: [], + meta: { + page: 1, + pageSize: 20, + requestsTotal: 0, + movementsTotal: 0, + totalPages: 1, + }, + }; + } + return this.service.getHistory(user.id, toInt(page), toInt(pageSize)); } @Get('history/all') @WagonTransferHistoryAll() @ApiQuery({ name: 'userId', required: false }) + @ApiQuery({ name: 'page', required: false }) + @ApiQuery({ name: 'pageSize', required: false }) @ApiOperation({ summary: "Admin: any/all staff's transfer history (optional ?userId filter)", }) - allHistory(@Query('userId') userId?: string) { - return this.service.getHistory(userId); + allHistory( + @Query('userId') userId?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + return this.service.getHistory(userId, toInt(page), toInt(pageSize)); } @Get(':id') @@ -110,9 +145,26 @@ export class WagonTransferRequestsController { return this.service.fulfillRequest(id, dto, user?.id); } + @Post(':id/close-short') + @WagonTransferCloseShort() + @ApiOperation({ + summary: + 'OCC: end the request with fewer wagons than asked for — what moved stays, the requester is told the shortfall', + }) + closeShort( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: CloseShortTransferRequestDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.closeShort(id, dto, user?.id); + } + @Post(':id/cancel') - @FleetManage(FREIGHT_PERMS.wagons.transferRequest) - @ApiOperation({ summary: 'Withdraw a pending transfer request' }) + @WagonTransferCancel() + @ApiOperation({ + summary: + 'Withdraw a request that has not moved any wagon yet (use close-short once wagons have moved)', + }) cancel(@Param('id', ParseUUIDPipe) id: string) { return this.service.cancelRequest(id); } diff --git a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts new file mode 100644 index 000000000..ecf512aac --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts @@ -0,0 +1,235 @@ +import { WagonTransferRequestStatus } from '@edr/types'; +import { ConflictException, BadRequestException } from '@nestjs/common'; + +import { WagonTransferRequestsService } from './wagon-transfer-requests.service'; +import type { WagonTransferRequest } from './entities/wagon-transfer-request.entity'; + +/** + * Instalment fulfilment: a request for 50 wagons is met with whatever the source + * yard can spare, whenever it can spare it. It stays open until the full count + * lands or OCC closes it short — which is what tells the requester to go ask + * another yard. + */ +describe('WagonTransferRequestsService — partial fulfilment', () => { + const request = (over: Partial = {}): WagonTransferRequest => + ({ + id: 'req-1', + fromYardId: 'yard-a', + toYardId: 'yard-b', + wagonTypeId: 'type-1', + quantity: 50, + fulfilledQuantity: 0, + status: WagonTransferRequestStatus.Pending, + requestedByUserId: 'user-1', + ...over, + }) as WagonTransferRequest; + + let requestRepo: { + findOne: jest.Mock; + find: jest.Mock; + save: jest.Mock; + create: jest.Mock; + createQueryBuilder: jest.Mock; + }; + let wagonRepo: { find: jest.Mock; count: jest.Mock }; + let wagonsService: { bulkTransfer: jest.Mock }; + let inbox: { notify: jest.Mock }; + let service: WagonTransferRequestsService; + let stored: WagonTransferRequest; + + const flush = () => new Promise((resolve) => setImmediate(resolve)); + + const build = (row: WagonTransferRequest) => { + stored = row; + requestRepo.findOne.mockImplementation(async () => stored); + requestRepo.save.mockImplementation(async (r: WagonTransferRequest) => { + stored = r; + return r; + }); + }; + + beforeEach(() => { + requestRepo = { + findOne: jest.fn(), + find: jest.fn().mockResolvedValue([]), + save: jest.fn(), + create: jest.fn((r) => r), + createQueryBuilder: jest.fn(), + }; + wagonRepo = { find: jest.fn().mockResolvedValue([]), count: jest.fn() }; + wagonsService = { bulkTransfer: jest.fn().mockResolvedValue(undefined) }; + inbox = { notify: jest.fn().mockResolvedValue(undefined) }; + service = new WagonTransferRequestsService( + requestRepo as never, + wagonRepo as never, + { find: jest.fn(), findAndCount: jest.fn() } as never, + wagonsService as never, + inbox as never, + ); + build(request()); + }); + + const availableWagons = (n: number) => + Array.from({ length: n }, (_, i) => ({ + id: `w-${i}`, + wagonNumber: `100${i}`, + currentYardId: 'yard-a', + wagonTypeId: 'type-1', + status: 'AVAILABLE', + })); + + describe('fulfillRequest', () => { + it('books an instalment and keeps the request open', async () => { + wagonRepo.find.mockResolvedValue(availableWagons(20)); + + await service.fulfillRequest('req-1', { + wagonIds: availableWagons(20).map((w) => w.id), + }); + + expect(stored.fulfilledQuantity).toBe(20); + expect(stored.status).toBe(WagonTransferRequestStatus.PartiallyFulfilled); + expect(wagonsService.bulkTransfer).toHaveBeenCalledTimes(1); + }); + + it('completes the request when the last instalment lands', async () => { + build(request({ fulfilledQuantity: 30, status: WagonTransferRequestStatus.PartiallyFulfilled })); + wagonRepo.find.mockResolvedValue(availableWagons(20)); + + await service.fulfillRequest('req-1', { + wagonIds: availableWagons(20).map((w) => w.id), + }); + + expect(stored.fulfilledQuantity).toBe(50); + expect(stored.status).toBe(WagonTransferRequestStatus.Fulfilled); + }); + + it('refuses to move more than is still owed', async () => { + build(request({ fulfilledQuantity: 45, status: WagonTransferRequestStatus.PartiallyFulfilled })); + wagonRepo.find.mockResolvedValue(availableWagons(10)); + + await expect( + service.fulfillRequest('req-1', { + wagonIds: availableWagons(10).map((w) => w.id), + }), + ).rejects.toBeInstanceOf(BadRequestException); + expect(wagonsService.bulkTransfer).not.toHaveBeenCalled(); + }); + + it('refuses to touch a request that is already closed', async () => { + build(request({ status: WagonTransferRequestStatus.ClosedShort, fulfilledQuantity: 20 })); + + await expect( + service.fulfillRequest('req-1', { wagonIds: ['w-0'] }), + ).rejects.toBeInstanceOf(ConflictException); + }); + + it('tells the requester what landed and what is still owed', async () => { + wagonRepo.find.mockResolvedValue(availableWagons(20)); + + await service.fulfillRequest('req-1', { + wagonIds: availableWagons(20).map((w) => w.id), + }); + await flush(); + + const sent = inbox.notify.mock.calls[0][0]; + expect(sent.recipients).toEqual({ userIds: ['user-1'] }); + expect(sent.body).toContain('20 wagon(s) have arrived'); + expect(sent.body).toContain('30 of 50 still to come'); + }); + }); + + describe('bulkFulfill', () => { + it('sends what the yard has instead of skipping a short request', async () => { + wagonRepo.find.mockResolvedValue(availableWagons(20)); + + const result = await service.bulkFulfill(['req-1']); + + expect(stored.fulfilledQuantity).toBe(20); + expect(stored.status).toBe(WagonTransferRequestStatus.PartiallyFulfilled); + expect(result.skipped).toHaveLength(0); + }); + + it('skips only when the yard has nothing to give', async () => { + wagonRepo.find.mockResolvedValue([]); + + const result = await service.bulkFulfill(['req-1']); + + expect(wagonsService.bulkTransfer).not.toHaveBeenCalled(); + expect(result.skipped[0].reason).toContain('No available wagons'); + }); + }); + + describe('closeShort', () => { + it('ends the request and tells the requester to ask another yard', async () => { + build(request({ fulfilledQuantity: 20, status: WagonTransferRequestStatus.PartiallyFulfilled })); + + await service.closeShort('req-1', { note: 'Yard is empty until Friday' }); + await flush(); + + expect(stored.status).toBe(WagonTransferRequestStatus.ClosedShort); + expect(stored.closedShortAt).toBeInstanceOf(Date); + const sent = inbox.notify.mock.calls[0][0]; + expect(sent.body).toContain('Only 20 of the 50'); + expect(sent.body).toContain('Yard is empty until Friday'); + expect(sent.body).toContain('Request the remaining 30'); + }); + + it('refuses when the request is already fully supplied', async () => { + build(request({ fulfilledQuantity: 50, status: WagonTransferRequestStatus.PartiallyFulfilled })); + + await expect(service.closeShort('req-1', {})).rejects.toBeInstanceOf( + ConflictException, + ); + }); + }); + + describe('cancelRequest', () => { + it('withdraws a request that never moved a wagon', async () => { + await service.cancelRequest('req-1'); + expect(stored.status).toBe(WagonTransferRequestStatus.Cancelled); + }); + + it('refuses once wagons have moved — close it short instead', async () => { + build(request({ fulfilledQuantity: 20, status: WagonTransferRequestStatus.PartiallyFulfilled })); + + await expect(service.cancelRequest('req-1')).rejects.toThrow( + /close it short/i, + ); + }); + }); + + describe('createRequest', () => { + it('accepts a count larger than what the yard holds today', async () => { + wagonRepo.count.mockResolvedValue(20); + + await service.createRequest( + { + fromYardId: 'yard-a', + toYardId: 'yard-b', + wagonTypeId: 'type-1', + quantity: 50, + reason: 'Grain campaign', + }, + 'user-1', + ); + + expect(requestRepo.save).toHaveBeenCalled(); + expect(stored.quantity).toBe(50); + }); + + it('still refuses a same-yard move', async () => { + await expect( + service.createRequest( + { + fromYardId: 'yard-a', + toYardId: 'yard-a', + wagonTypeId: 'type-1', + quantity: 5, + reason: 'x', + }, + 'user-1', + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts index 068d4dc6d..79a74f4ce 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts @@ -1,15 +1,27 @@ -import { WagonStatus, WagonTransferRequestStatus } from '@edr/types'; +import { + NotificationAudience, + NotificationType, + OPEN_WAGON_TRANSFER_STATUSES, + PaginatedResponse, + WagonStatus, + WagonTransferRequestStatus, +} from '@edr/types'; import { BadRequestException, ConflictException, Injectable, + Logger, NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { In, IsNull, Not, Repository } from 'typeorm'; +import { paginateQuery } from '../../common/utils/pagination.util'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { CloseShortTransferRequestDto } from './dto/close-short-transfer-request.dto'; import { CreateTransferRequestDto } from './dto/create-transfer-request.dto'; import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto'; +import { ListTransferRequestsQueryDto } from './dto/list-transfer-requests-query.dto'; import { Wagon } from './entities/wagon.entity'; import { WagonMovement } from './entities/wagon-movement.entity'; import { WagonTransferRequest } from './entities/wagon-transfer-request.entity'; @@ -19,10 +31,21 @@ import { WagonsService } from './wagons.service'; export interface TransferHistory { requests: WagonTransferRequest[]; movements: WagonMovement[]; + /** + * One pager drives both lists (they are shown side by side), so it carries a + * total per list and the page count of the longer one. + */ + meta: { + page: number; + pageSize: number; + requestsTotal: number; + movementsTotal: number; + totalPages: number; + }; } -/** How many ledger rows the history returns at most (newest first). */ -const HISTORY_LIMIT = 500; +/** Hard ceiling on a single history page, whatever the client asks for. */ +const HISTORY_LIMIT = 100; const REQUEST_RELATIONS = { fromYard: true, @@ -38,6 +61,8 @@ const REQUEST_RELATIONS = { */ @Injectable() export class WagonTransferRequestsService { + private readonly logger = new Logger(WagonTransferRequestsService.name); + constructor( @InjectRepository(WagonTransferRequest) private readonly requestRepo: Repository, @@ -46,13 +71,14 @@ export class WagonTransferRequestsService { @InjectRepository(WagonMovement) private readonly movementRepo: Repository, private readonly wagonsService: WagonsService, + private readonly inbox: NotificationInboxService, ) {} /** - * Record a PENDING request. Count-only — no wagons are picked here, but the - * count is capped at the AVAILABLE wagons of that type currently sitting in - * the source yard: staff may only ask for wagons that are actually there to - * give. A reason is mandatory and is shown on the OCC queue. + * Record a PENDING request. Count-only — no wagons are picked here, and the + * count is NOT capped by what the source yard holds today: OCC fulfils in + * instalments, so asking for 50 while only 20 sit there is a normal, useful + * request. A reason is mandatory and is shown on the OCC queue. */ async createRequest( dto: CreateTransferRequestDto, @@ -63,14 +89,6 @@ export class WagonTransferRequestsService { 'Source and destination yard must be different', ); } - const available = await this.countAvailable(dto.fromYardId, dto.wagonTypeId); - if (available < dto.quantity) { - throw new BadRequestException( - available === 0 - ? 'No available wagons of this type in the source yard' - : `Only ${available} available wagon(s) of this type in the source yard — request at most ${available}`, - ); - } const request = this.requestRepo.create({ fromYardId: dto.fromYardId, toYardId: dto.toYardId, @@ -85,8 +103,12 @@ export class WagonTransferRequestsService { return this.findById(saved.id); } - /** AVAILABLE wagons of `wagonTypeId` currently in `yardId`. */ - private countAvailable(yardId: string, wagonTypeId: string): Promise { + /** + * AVAILABLE wagons of `wagonTypeId` currently in `yardId` — what OCC can move + * right now. Shown on the desk beside the outstanding count so staff see at a + * glance how much of a request the yard can cover today. + */ + countAvailable(yardId: string, wagonTypeId: string): Promise { return this.wagonRepo.count({ where: { currentYardId: yardId, @@ -96,15 +118,51 @@ export class WagonTransferRequestsService { }); } - /** Requests, newest first, optionally filtered by status (OCC queue = PENDING). */ + /** + * The transfer desk list: paginated, newest first, filterable by status (one + * or a comma-separated set — the "Open" tab asks for PENDING + + * PARTIALLY_FULFILLED), yards and wagon type. Search matches the reason text. + */ async listRequests( - status?: WagonTransferRequestStatus, - ): Promise { - return this.requestRepo.find({ - where: status ? { status } : {}, - relations: REQUEST_RELATIONS, - order: { createdAt: 'DESC' }, - }); + query: ListTransferRequestsQueryDto, + ): Promise> { + const qb = this.requestRepo + .createQueryBuilder('r') + .leftJoinAndSelect('r.fromYard', 'fromYard') + .leftJoinAndSelect('r.toYard', 'toYard') + .leftJoinAndSelect('r.wagonType', 'wagonType'); + + const statuses = (query.status ?? '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + if (statuses.length) { + qb.andWhere('r.status IN (:...statuses)', { statuses }); + } + if (query.fromYardId) { + qb.andWhere('r.from_yard_id = :fromYardId', { fromYardId: query.fromYardId }); + } + if (query.toYardId) { + qb.andWhere('r.to_yard_id = :toYardId', { toYardId: query.toYardId }); + } + if (query.wagonTypeId) { + qb.andWhere('r.wagon_type_id = :wagonTypeId', { + wagonTypeId: query.wagonTypeId, + }); + } + if (query.search) { + qb.andWhere('r.reason ILIKE :search', { search: `%${query.search}%` }); + } + + const sortColumn = + query.sortBy === 'quantity' + ? 'r.quantity' + : query.sortBy === 'status' + ? 'r.status' + : 'r.created_at'; + qb.orderBy(sortColumn, query.sortOrder ?? 'DESC'); + + return paginateQuery(qb, { page: query.page, pageSize: query.pageSize }); } async findById(id: string): Promise { @@ -116,11 +174,23 @@ export class WagonTransferRequestsService { return request; } + /** Wagons still owed on an open request. */ + private remainingOn(request: WagonTransferRequest): number { + return Math.max(0, request.quantity - (request.fulfilledQuantity ?? 0)); + } + + /** True while OCC can still move wagons against this request. */ + private isOpen(request: WagonTransferRequest): boolean { + return OPEN_WAGON_TRANSFER_STATUSES.includes(request.status); + } + /** - * OCC fulfils a PENDING request with hand-picked wagons. Every wagon must sit - * in the request's source yard, match its wagon type, and the count must equal - * the requested quantity — then the transfer runs and the request is marked - * FULFILLED. + * OCC moves hand-picked wagons against an open request. Any number from 1 up + * to whatever is still owed — the yard rarely has the whole ask at once, so a + * request for 50 can be met 20 now, 30 later. Every wagon must sit in the + * source yard, match the type and be available. The request completes on its + * own once the full count has moved; short of that it stays open as + * PARTIALLY_FULFILLED and the requester is told what landed. */ async fulfillRequest( id: string, @@ -128,16 +198,17 @@ export class WagonTransferRequestsService { userId?: string | null, ): Promise { const request = await this.findById(id); - if (request.status !== WagonTransferRequestStatus.Pending) { + if (!this.isOpen(request)) { throw new ConflictException( - `Request is already ${request.status.toLowerCase()}`, + `Request is already ${request.status.toLowerCase().replace(/_/g, ' ')}`, ); } const wagonIds = [...new Set(dto.wagonIds)]; - if (wagonIds.length !== request.quantity) { + const remaining = this.remainingOn(request); + if (wagonIds.length > remaining) { throw new BadRequestException( - `Select exactly ${request.quantity} wagon(s); you selected ${wagonIds.length}`, + `Only ${remaining} wagon(s) still owed on this request; you selected ${wagonIds.length}`, ); } @@ -178,21 +249,124 @@ export class WagonTransferRequestsService { { transferRequestId: request.id }, ); - request.status = WagonTransferRequestStatus.Fulfilled; - request.fulfilledByUserId = userId ?? null; - request.fulfilledAt = new Date(); - await this.requestRepo.save(request); + await this.recordDelivery(request, wagonIds.length, userId); return this.findById(id); } /** - * OCC accepts AND executes a subset of pending requests in one action. For - * each selected request the system auto-picks the required number of - * AVAILABLE wagons of the requested type from the source yard (lowest wagon - * number first) and runs the audited transfer. A request that cannot be - * executed — already decided, or not enough available wagons left after the - * ones processed before it — is SKIPPED and simply stays PENDING, visible to - * both teams; nothing is rolled back for the others. + * Book an instalment against a request: bump the delivered count, complete it + * when the full ask has landed, and tell the requester what moved. Shared by + * the hand-picked and auto-picked (bulk) fulfilment paths. + */ + private async recordDelivery( + request: WagonTransferRequest, + moved: number, + userId?: string | null, + ): Promise { + request.fulfilledQuantity = (request.fulfilledQuantity ?? 0) + moved; + request.status = + request.fulfilledQuantity >= request.quantity + ? WagonTransferRequestStatus.Fulfilled + : WagonTransferRequestStatus.PartiallyFulfilled; + request.fulfilledByUserId = userId ?? null; + request.fulfilledAt = new Date(); + await this.requestRepo.save(request); + this.notifyRequester(request, moved); + } + + /** + * Tell the requester what landed. Fire-and-forget: a notification failure must + * never undo a transfer that already moved wagons. + */ + private notifyRequester( + request: WagonTransferRequest, + moved: number, + closedShortNote?: string | null, + ): void { + if (!request.requestedByUserId) return; + const outstanding = this.remainingOn(request); + const complete = request.status === WagonTransferRequestStatus.Fulfilled; + const closedShort = + request.status === WagonTransferRequestStatus.ClosedShort; + + const title = complete + ? `All ${request.quantity} wagon(s) transferred` + : closedShort + ? `Transfer closed short — ${request.fulfilledQuantity} of ${request.quantity} wagon(s)` + : `${moved} of ${request.quantity} wagon(s) transferred`; + + const body = complete + ? `Your wagon transfer request is complete — all ${request.quantity} wagon(s) have arrived.` + : closedShort + ? `Only ${request.fulfilledQuantity} of the ${request.quantity} wagon(s) you asked for could be supplied` + + `${closedShortNote ? `: ${closedShortNote}` : '.'} ` + + `Request the remaining ${outstanding} from another yard.` + : `${moved} wagon(s) have arrived against your request. ` + + `${outstanding} of ${request.quantity} still to come.`; + + void this.inbox + .notify({ + recipients: { userIds: [request.requestedByUserId] }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.GENERIC, + title, + body, + link: `/dashboard/wagon-transfers/${request.id}`, + data: { + transferRequestId: request.id, + delivered: request.fulfilledQuantity, + requested: request.quantity, + outstanding, + }, + }) + .catch((err) => + this.logger.warn( + `Transfer notification failed for ${request.id}: ${(err as Error).message}`, + ), + ); + } + + /** + * OCC ends a request with fewer wagons than asked for — the source yard has + * nothing more to give. What already moved stays moved; the requester is told + * the shortfall so they can raise it against another yard. Cancelling is for + * requests that never moved anything; this is the close for ones that did. + */ + async closeShort( + id: string, + dto: CloseShortTransferRequestDto, + userId?: string | null, + ): Promise { + const request = await this.findById(id); + if (!this.isOpen(request)) { + throw new ConflictException( + `Request is already ${request.status.toLowerCase().replace(/_/g, ' ')}`, + ); + } + if (this.remainingOn(request) === 0) { + throw new ConflictException( + 'Nothing outstanding — this request is already fully supplied', + ); + } + + request.status = WagonTransferRequestStatus.ClosedShort; + request.closedShortAt = new Date(); + request.closedShortByUserId = userId ?? null; + if (dto.note?.trim()) { + request.note = dto.note.trim(); + } + await this.requestRepo.save(request); + this.notifyRequester(request, 0, dto.note ?? null); + return this.findById(id); + } + + /** + * OCC executes a set of open requests in one action, auto-picking AVAILABLE + * wagons of the requested type from each source yard (lowest wagon number + * first). A yard that cannot cover the whole ask still sends what it has — + * the request stays open for the rest rather than being skipped, which is the + * whole point of instalments. Only a request with NOTHING available is + * skipped, and nothing is rolled back for the others. */ async bulkFulfill( requestIds: string[], @@ -212,13 +386,14 @@ export class WagonTransferRequestsService { skipped.push({ id, reason: 'Request not found' }); continue; } - if (request.status !== WagonTransferRequestStatus.Pending) { + if (!this.isOpen(request)) { skipped.push({ id, - reason: `Already ${request.status.toLowerCase()}`, + reason: `Already ${request.status.toLowerCase().replace(/_/g, ' ')}`, }); continue; } + const remaining = this.remainingOn(request); const wagons = await this.wagonRepo.find({ where: { currentYardId: request.fromYardId, @@ -226,12 +401,12 @@ export class WagonTransferRequestsService { status: WagonStatus.Available, }, order: { wagonNumber: 'ASC' }, - take: request.quantity, + take: remaining, }); - if (wagons.length < request.quantity) { + if (wagons.length === 0) { skipped.push({ id, - reason: `Only ${wagons.length} of ${request.quantity} wagon(s) available in the source yard — left pending`, + reason: 'No available wagons of this type in the source yard — left open', }); continue; } @@ -240,10 +415,7 @@ export class WagonTransferRequestsService { userId, { transferRequestId: request.id }, ); - request.status = WagonTransferRequestStatus.Fulfilled; - request.fulfilledByUserId = userId ?? null; - request.fulfilledAt = new Date(); - await this.requestRepo.save(request); + await this.recordDelivery(request, wagons.length, userId); fulfilled.push(await this.findById(id)); } @@ -258,17 +430,25 @@ export class WagonTransferRequestsService { * (the controller passes the caller's id unless they hold the history-all * permission) — this method trusts its argument. */ - async getHistory(userId?: string | null): Promise { - const requests = await this.requestRepo.find({ + async getHistory( + userId?: string | null, + page?: number, + pageSize?: number, + ): Promise { + const take = Math.min(pageSize ?? 20, HISTORY_LIMIT); + const skip = ((page ?? 1) - 1) * take; + + const [requests, requestsTotal] = await this.requestRepo.findAndCount({ where: userId ? [{ requestedByUserId: userId }, { fulfilledByUserId: userId }] : {}, relations: REQUEST_RELATIONS, order: { createdAt: 'DESC' }, - take: HISTORY_LIMIT, + skip, + take, }); - const movements = await this.movementRepo.find({ + const [movements, movementsTotal] = await this.movementRepo.findAndCount({ // Own view: moves I made. All view: every user-attributed move (skip the // system-written loaded/reposition legs that carry no mover). where: userId @@ -276,18 +456,39 @@ export class WagonTransferRequestsService { : { movedByUserId: Not(IsNull()) }, relations: { wagon: true, fromYard: true, toYard: true, transferRequest: true }, order: { occurredAt: 'DESC' }, - take: HISTORY_LIMIT, + skip, + take, }); - return { requests, movements }; + return { + requests, + movements, + meta: { + page: page ?? 1, + pageSize: take, + requestsTotal, + movementsTotal, + // Whichever list is longer decides how far the pager can go. + totalPages: Math.max( + 1, + Math.ceil(Math.max(requestsTotal, movementsTotal) / take), + ), + }, + }; } - /** Withdraw a still-PENDING request. */ + /** + * Withdraw a request before anything moved. Once wagons have been delivered + * the request can only be completed or closed short — cancelling would erase + * the fact that a transfer happened. + */ async cancelRequest(id: string): Promise { const request = await this.findById(id); if (request.status !== WagonTransferRequestStatus.Pending) { throw new ConflictException( - `Only pending requests can be cancelled (this one is ${request.status.toLowerCase()})`, + request.status === WagonTransferRequestStatus.PartiallyFulfilled + ? 'Wagons have already moved against this request — close it short instead of cancelling' + : `Only pending requests can be cancelled (this one is ${request.status.toLowerCase().replace(/_/g, ' ')})`, ); } request.status = WagonTransferRequestStatus.Cancelled; diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.module.ts b/apps/edr-freight-api/src/modules/wagons/wagons.module.ts index 05162ea23..8c8a0d11f 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.module.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.module.ts @@ -5,6 +5,7 @@ import { WagonMovement } from './entities/wagon-movement.entity'; import { WagonTransferRequest } from './entities/wagon-transfer-request.entity'; import { Train } from '../trains/entities/train.entity'; import { Yard } from '../rule-engine/entities/yard.entity'; +import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; import { WagonsController } from './wagons.controller'; import { WagonTransferRequestsController } from './wagon-transfer-requests.controller'; import { WagonsService } from './wagons.service'; @@ -19,6 +20,8 @@ import { WagonTransferRequestsService } from './wagon-transfer-requests.service' Train, Yard, ]), + // The transfer desk notifies the requester as instalments land. + NotificationInboxModule, ], controllers: [ WagonsController, diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 65ca3bcde..2924169eb 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -233,6 +233,12 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [ perm('e1b00001-0001-4000-8000-000000000005', 'edr_freight_app:wagons:transfer_request', 'Request wagon transfer'), perm('e1b00001-0001-4000-8000-000000000006', 'edr_freight_app:wagons:transfer_fulfill', 'Fulfil wagon transfer (OCC)'), perm('e1b00001-0001-4000-8000-000000000007', 'edr_freight_app:wagons:transfer_history_all', "View all staff's transfer history"), + // The transfer desk is its own screen, so it carries its own per-action keys — + // seeing the queue, withdrawing a request and short-closing one are separate + // grants from filing or fulfilling. + perm('e1b00001-0001-4000-8000-000000000008', 'edr_freight_app:wagons:transfer_view', 'View wagon transfer requests'), + perm('e1b00001-0001-4000-8000-000000000009', 'edr_freight_app:wagons:transfer_cancel', 'Withdraw a wagon transfer request'), + perm('e1b00001-0001-4000-8000-00000000000a', 'edr_freight_app:wagons:transfer_close_short', 'Close a transfer request short of the requested count'), perm('e1c00001-0001-4000-8000-000000000001', 'edr_freight_app:trains:view', 'View trains'), perm('e1c00001-0001-4000-8000-000000000002', 'edr_freight_app:trains:create', 'Create train'), perm('e1c00001-0001-4000-8000-000000000003', 'edr_freight_app:trains:update', 'Update train'), @@ -524,6 +530,12 @@ export const FREIGHT_PERMS = { // executes the move). Distinct keys so OCC can hold fulfil without request. transferRequest: 'edr_freight_app:wagons:transfer_request', transferFulfill: 'edr_freight_app:wagons:transfer_fulfill', + /** Open the transfer-requests desk (list + detail). */ + transferView: 'edr_freight_app:wagons:transfer_view', + /** Withdraw a request that has not moved any wagon yet. */ + transferCancel: 'edr_freight_app:wagons:transfer_cancel', + /** End a request short — anyone who can fulfil may also do this. */ + transferCloseShort: 'edr_freight_app:wagons:transfer_close_short', // Admin: read every staffer's transfer history. Without it, a user only sees // their own (the /history endpoint uses the caller id, backend-enforced). transferHistoryAll: 'edr_freight_app:wagons:transfer_history_all', @@ -905,6 +917,12 @@ export const POSITION_PERMISSION_PRESETS = { ...ROLE_PERMISSION_PRESETS.director, ...ROLE_PERMISSION_PRESETS.operationsOfficer, FREIGHT_PERMS.allocation.manage, + // Customer desk: onboarding intake lands on the chief — open the customer + // list and approve/suspend a submitted profile. Deliberately NOT granted: + // create, update and password reset, which stay with the customer admins. + FREIGHT_PERMS.customers.view, + FREIGHT_PERMS.customers.verify, + FREIGHT_PERMS.customers.deactivate, ]), director: dedupe([...ROLE_PERMISSION_PRESETS.director]), ceo: dedupe([...ROLE_PERMISSION_PRESETS.ceo]), diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index bb4d024c8..5e2e48f32 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -1,4 +1,5 @@ import { + ArrowLeftRight, Boxes, Building2, Container, @@ -86,6 +87,7 @@ import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage"; import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage"; import FleetResourcePage from "./pages/fleet/FleetResourcePage"; +import WagonTransfersPage from "./pages/wagons/WagonTransfersPage"; import VehicleDetailPage from "./pages/fleet/VehicleDetailPage"; import DriverDetailPage from "./pages/fleet/DriverDetailPage"; import RoutesPage from "./pages/fleet/RoutesPage"; @@ -297,6 +299,15 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: [FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view], }, + { + label: "Wagon Transfers", + href: "/dashboard/wagon-transfers", + icon: , + permission: [ + FREIGHT_PERMS.wagons.transferView, + FREIGHT_PERMS.wagons.view, + ], + }, { label: "Vehicles", href: "/dashboard/vehicles", @@ -1180,6 +1191,19 @@ const App = () => { } /> + + + + } + /> { } /> + + + + } + /> void; + /** Hide the replace form (finalized clearance, read-only viewers). */ + canReplace?: boolean; + onReplaced?: () => void; + onView?: (file: { name: string; url: string }) => void; +} + +const fmt = (iso: string) => + new Date(iso).toLocaleString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + +/** + * Version history of one clearance document, and the way to add a version. + * + * Staff can correct a document without bouncing it back to the customer, but + * the customer's original is never overwritten — it drops down this list as a + * superseded version, stamped with who replaced it and why. The corrected file + * comes back unreviewed, so it still has to be approved before finalizing. + */ +export function ClearanceDocumentVersionsModal({ + contractId, + doc, + onClose, + canReplace = false, + onReplaced, + onView, +}: ClearanceDocumentVersionsModalProps) { + const queryClient = useQueryClient(); + const [file, setFile] = useState(null); + const [reason, setReason] = useState(""); + + const { data: versions = [], isLoading } = useQuery({ + queryKey: ["contracts", "clearance-doc-versions", contractId, doc?.fileKey], + queryFn: () => + contractsService.getClearanceDocumentVersions(contractId, doc!.fileKey), + enabled: Boolean(doc), + }); + + const replace = useMutation({ + mutationFn: () => + contractsService.replaceClearanceDocument( + contractId, + doc!.fileKey, + file!, + reason.trim(), + ), + onSuccess: async () => { + toast.success("Document replaced — the previous version is kept on file"); + setFile(null); + setReason(""); + await queryClient.invalidateQueries({ + queryKey: ["contracts", "clearance-doc-versions", contractId, doc?.fileKey], + }); + await queryClient.invalidateQueries({ + queryKey: QUERY_KEYS.CONTRACTS.clearance(contractId), + }); + onReplaced?.(); + }, + }); + + const close = () => { + setFile(null); + setReason(""); + onClose(); + }; + + return ( + + + {doc?.label ?? "Document"} — version history + + } + > + + {isLoading ? ( + + + + ) : versions.length === 0 ? ( + + Nothing uploaded under this document yet. + + ) : ( + + {versions.map((v, index) => ( + + + + + + {v.name} + + {v.isCurrent ? ( + + Current + + ) : index === versions.length - 1 ? ( + + Original + + ) : ( + + Superseded + + )} + + + Uploaded {fmt(v.uploadedAt)} + {v.replacedAt ? ` · replaced ${fmt(v.replacedAt)}` : ""} + + {v.replaceReason ? ( + + Reason: {v.replaceReason} + + ) : null} + + + {isViewable({ name: v.name, url: "" }) && onView ? ( + + ) : null} + + + + + ))} + + )} + + {canReplace ? ( + + + + Replace this document + + + Use this for a correction you can make yourself. The customer's + copy stays in the history above, and the new file has to be + approved before clearance is finalized. + + +