From bcf25538b68812727bf767394de6389a1ebf0827 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sun, 26 Jul 2026 19:14:39 +0000 Subject: [PATCH] add contract hazard declaration, DO collection dates, and booking request currency migrations; implement article body diff component and integrate into contract revision timeline --- ...0000000000-AddContractHazardDeclaration.ts | 4 +- .../2970000000000-AddDoCollectionDates.ts | 4 +- ...2980000000000-AddBookingRequestCurrency.ts | 4 +- .../booking-lifecycle-notifier.service.ts | 18 ++++ .../contracts/booking-request.service.ts | 92 +++++-------------- .../contract-booking.consolidation.spec.ts | 5 +- .../contracts/contract-booking.service.ts | 27 ++++++ .../contracts/contract-document-diff.util.ts | 16 +++- .../contracts/contract-transition.service.ts | 18 +++- .../modules/contracts/contracts.controller.ts | 1 + .../contracts/dto/accept-contract.dto.ts | 24 ++++- .../contracts/NewShipmentRequestPage.tsx | 8 +- .../contracts/contract-booking-action.ts | 49 +++------- packages/types/src/freight/contracts.ts | 9 +- 14 files changed, 156 insertions(+), 123 deletions(-) diff --git a/apps/edr-freight-api/src/migrations/2960000000000-AddContractHazardDeclaration.ts b/apps/edr-freight-api/src/migrations/2960000000000-AddContractHazardDeclaration.ts index bef24c3e4..810f59a07 100644 --- a/apps/edr-freight-api/src/migrations/2960000000000-AddContractHazardDeclaration.ts +++ b/apps/edr-freight-api/src/migrations/2960000000000-AddContractHazardDeclaration.ts @@ -9,10 +9,10 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; * Nullable — non-hazardous contracts leave both null, and contracts created * before this change have no declaration to backfill. */ -export class AddContractHazardDeclaration2920000000000 +export class AddContractHazardDeclaration2960000000000 implements MigrationInterface { - name = 'AddContractHazardDeclaration2920000000000'; + name = 'AddContractHazardDeclaration2960000000000'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( diff --git a/apps/edr-freight-api/src/migrations/2970000000000-AddDoCollectionDates.ts b/apps/edr-freight-api/src/migrations/2970000000000-AddDoCollectionDates.ts index 2a7797d82..90b3a9298 100644 --- a/apps/edr-freight-api/src/migrations/2970000000000-AddDoCollectionDates.ts +++ b/apps/edr-freight-api/src/migrations/2970000000000-AddDoCollectionDates.ts @@ -9,8 +9,8 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; * `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'; +export class AddDoCollectionDates2970000000000 implements MigrationInterface { + name = 'AddDoCollectionDates2970000000000'; public async up(queryRunner: QueryRunner): Promise { for (const table of [ diff --git a/apps/edr-freight-api/src/migrations/2980000000000-AddBookingRequestCurrency.ts b/apps/edr-freight-api/src/migrations/2980000000000-AddBookingRequestCurrency.ts index 8d3fde437..5f92cff68 100644 --- a/apps/edr-freight-api/src/migrations/2980000000000-AddBookingRequestCurrency.ts +++ b/apps/edr-freight-api/src/migrations/2980000000000-AddBookingRequestCurrency.ts @@ -9,8 +9,8 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; * 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'; +export class AddBookingRequestCurrency2980000000000 implements MigrationInterface { + name = 'AddBookingRequestCurrency2980000000000'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts index 4072a462e..6bef20630 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts @@ -224,6 +224,24 @@ export class BookingLifecycleNotifierService { this.inApp(b, 'Operation request accepted', msg); } + /** + * GL Ethiopia created this booking on the customer's behalf. On a customs + * (Path B) contract the customer never books themselves, so without this they + * would have no signal that their shipment now exists and is priced. + */ + createdByGlForCustomer(b: Booking): void { + const total = Number(b.totalAmount ?? 0); + const priced = + total > 0 + ? ` The total is ${total.toLocaleString()} ${b.paymentCurrency}.` + : ''; + const msg = + `Global Logistics has created shipment ${b.reference} under your contract.${priced} ` + + `You can review it in the portal.`; + void this.notifyContact(b, msg, 'CREATED BY GL'); + this.inApp(b, 'Shipment created for you', msg); + } + /** Shipment started → in transit. */ inTransit(b: Booking): void { const msg = `Your shipment for booking ${b.reference} is now in transit.`; 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 8d26799c5..99fed335a 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 @@ -31,34 +31,21 @@ export class BookingRequestService { ) {} /** - * 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. + * Only GENERAL contracts that bundle customs use the request → GL → clearance + * flow. A ONE_TIME customs contract runs its clearance at the contract level + * and GL books it directly, with no customer-facing request step. */ - private assertCustomsContract(contract: Contract): void { - if (!contract.customsClearingEnabled) { + private assertGeneralCustoms(contract: Contract): void { + if ( + contract.contractKind !== 'GENERAL' || + !contract.customsClearingEnabled + ) { throw new BadRequestException( - 'Shipment requests apply only to customs-clearance contracts.', + 'Shipment requests apply only to general 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, @@ -67,35 +54,13 @@ export class BookingRequestService { ): Promise { const contract = await this.contractsService.findById(contractId); await this.contractsService.assertCustomerCanAccessContract(userId, contract); - this.assertCustomsContract(contract); - const isOneTime = contract.contractKind === 'ONE_TIME'; - + this.assertGeneralCustoms(contract); if (contract.status === 'CONTRACT_CLOSED') { throw new ConflictException( 'This contract is completed — the full contracted quantity has been booked.', ); } - 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') { + if (contract.status !== 'CONTRACT_ACTIVE') { throw new ConflictException( 'The contract must be active before requesting a shipment.', ); @@ -129,14 +94,10 @@ export class BookingRequestService { } } } - // 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, - }); - } + await this.contractBookingService.assertRequestWithinCapacity(contract, { + containers: dto.containers, + bulk: dto.bulk, + }); const requestedLines: Freight.RequestedShipmentLines = isContainer ? { @@ -162,17 +123,14 @@ 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. - // 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 booking = await this.contractBookingService.initiateForShipmentRequest( + contract, + { + contractRouteId: dto.contractRouteId, + userId, + paymentCurrency: dto.paymentCurrency, + }, + ); const reference = await this.generateReference(); const request = await this.repo.create({ @@ -181,8 +139,8 @@ export class BookingRequestService { requestedByUserId: userId ?? null, contractRouteId: dto.contractRouteId ?? null, scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null, - status: booking ? 'ACCEPTED' : 'PENDING', - createdBookingId: booking?.id ?? null, + status: 'ACCEPTED', + createdBookingId: booking.id, requestedLines, // Intercity is invoiced in birr whatever the customer picked. paymentCurrency: diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts index bb74f062c..4bc768cc1 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts @@ -57,7 +57,10 @@ describe('ContractBookingService — drawdown consolidation gate', () => { milestoneService as never, {} as never, // workflowService invoiceService as never, - { createdToStaff: jest.fn() } as never, // bookingNotifier + { + createdToStaff: jest.fn(), + createdByGlForCustomer: jest.fn(), + } as never, // bookingNotifier {} as never, // dataSource {} as never, // trainSchedulingService {} as never, // bookingBatchService 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 859c4e391..7e657cc07 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 @@ -933,6 +933,33 @@ export class ContractBookingService { }`, ), ); + + // On a customs contract the customer never books — GL Ethiopia does it for + // them (assertGate enforces that) — so tell them their shipment now exists. + // + // Gated on the contract, NOT on booking.createdByRole: a GENERAL customs + // instance is stamped CUSTOMER when the customer's shipment request opens + // it, yet it is GL who later completes it with cargo and a price. Keying on + // the role would silently skip exactly that case. + // + // Sent from here because this is the single funnel every contract booking + // passes through exactly once (create, complete, and the deferred + // consolidation-pairing replay), and it runs after invoicing so the message + // can quote the priced total. + if (contract.customsClearingEnabled) { + // Never let a notification failure read as a finalize failure — the + // booking is already committed by this point. + try { + const priced = await this.bookingsRepository.findByIdWithFiles(bookingId); + this.bookingNotifier.createdByGlForCustomer(priced ?? booking); + } catch (err) { + this.logger.warn( + `Could not notify the customer that GL created booking ${booking.reference}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } } /** 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 ac2c12075..2c2c4e5de 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 @@ -4,8 +4,9 @@ import type { } from './entities/contract.entity'; /** - * One recorded change between two document snapshots. Granularity is per - * article: a body edit is reported as "the body changed", not as a text diff. + * One recorded change between two document snapshots. A body edit carries the + * text on both sides so the audit trail shows WHAT was rewritten, not merely + * that something was — the UI diffs the two strings for display. */ export type ContractDocumentChange = | { kind: 'ARTICLE_ADDED'; articleId: string; title: string } @@ -16,7 +17,14 @@ export type ContractDocumentChange = title: string; fromTitle: string; } - | { kind: 'ARTICLE_BODY_CHANGED'; articleId: string; title: string } + | { + kind: 'ARTICLE_BODY_CHANGED'; + articleId: string; + title: string; + /** Body before / after the edit. Absent on revisions recorded earlier. */ + fromBody?: string; + toBody?: string; + } | { kind: 'ARTICLE_REORDERED'; articleId: string; @@ -120,6 +128,8 @@ export function diffSnapshots( kind: 'ARTICLE_BODY_CHANGED', articleId: article.id, title: article.title, + fromBody: previous.body, + toBody: article.body, }); } if (previous.order !== article.order) { 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 dfd4ea840..b579acdfb 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 @@ -244,6 +244,7 @@ export class ContractTransitionService { validityDays: number, documentSnapshot?: ContractDocumentSnapshotInput | null, user?: TCurrentUser | null, + window?: { validFrom?: string | null; validUntil?: string | null }, ): Promise { const contract = await this.contractsService.findById(contractId); // The route guard passes on either arm; the contract's freight type decides @@ -260,11 +261,20 @@ export class ContractTransitionService { ); } - await this.assertValidityDaysConfigured(validityDays); + // Staff picked an explicit window in the accept dialog — honour it verbatim + // (any start, any end). Only the legacy days-only payload is still held to + // the admin-configured period list. + const picked = window?.validFrom && window?.validUntil; + if (!picked) await this.assertValidityDaysConfigured(validityDays); - const validFrom = new Date(); - const validUntil = new Date(validFrom); - validUntil.setDate(validUntil.getDate() + validityDays); + const validFrom = picked ? new Date(window!.validFrom!) : new Date(); + const validUntil = picked ? new Date(window!.validUntil!) : new Date(validFrom); + if (!picked) validUntil.setDate(validUntil.getDate() + validityDays); + if (validUntil.getTime() <= validFrom.getTime()) { + throw new BadRequestException( + 'The contract validity end date must be after the start date.', + ); + } await this.instantiateApprovalSteps(contract); 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 f6b9ec1c4..74574ed28 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -360,6 +360,7 @@ export class ContractsController { dto.validityDays, dto.documentSnapshot, user, + { validFrom: dto.validFrom, validUntil: dto.validUntil }, ); } diff --git a/apps/edr-freight-api/src/modules/contracts/dto/accept-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/accept-contract.dto.ts index d3eaa73a3..8b4e12793 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/accept-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/accept-contract.dto.ts @@ -1,6 +1,13 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsInt, IsOptional, Max, Min, ValidateNested } from 'class-validator'; +import { + IsDateString, + IsInt, + IsOptional, + Max, + Min, + ValidateNested, +} from 'class-validator'; import { UpdateContractDocumentDto } from './contract-document.dto'; @@ -18,6 +25,21 @@ export class AcceptContractDto { @Max(3650) validityDays!: number; + /** + * Explicit validity window picked by staff in the accept dialog. When both are + * present they win over `validityDays` (which is then only the derived span) + * and the configured-period check is skipped — staff may enter any range. + */ + @ApiPropertyOptional({ description: 'Validity start (ISO date)' }) + @IsOptional() + @IsDateString() + validFrom?: string; + + @ApiPropertyOptional({ description: 'Validity end (ISO date)' }) + @IsOptional() + @IsDateString() + validUntil?: string; + /** * Optional per-contract document override edited by staff in the accept * dialog. When present its articles are frozen onto THIS contract; when diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx index b4fc045d0..f5be74416 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx @@ -81,11 +81,11 @@ export default function NewShipmentRequestPage() { const isContainer = contract.freightType === "CONTAINER"; const route = contract.routes?.[0]; - // Customs contracts: GL schedules the shipment during clearance — the - // customer only states the quantity (and currency), never picks a date. This - // now covers ONE_TIME customs too, where GL likewise books on their behalf. + // GENERAL customs contracts: GL schedules the shipment during clearance — + // the customer only states the quantity (and billing currency), never a date. const hasCustoms = - contract.serviceType?.includesCustoms ?? contract.customsClearingEnabled; + contract.contractKind === "GENERAL" && + (contract.serviceType?.includesCustoms ?? contract.customsClearingEnabled); const isIntercity = contract.tradeDirection === "DOMESTIC"; // Only the container sizes the contract was scoped for (20ft, 40ft, or both). diff --git a/apps/edr-freight-web/portal/src/pages/contracts/contract-booking-action.ts b/apps/edr-freight-web/portal/src/pages/contracts/contract-booking-action.ts index d5cbd9914..c7374d510 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/contract-booking-action.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/contract-booking-action.ts @@ -15,18 +15,6 @@ export const TERMINAL_BOOKING_STATUSES = [ /** Path A statuses where a customer (no customs) may book against the contract. */ const PATH_A_BOOKABLE = ["FULLY_EXECUTED", "CONTRACT_ACTIVE"]; -/** - * ONE_TIME + customs: statuses where the customer may still submit the shipment - * request GL books from. Mirrors the API source of truth in - * `booking-request.service.ts`. - */ -const ONE_TIME_CUSTOMS_REQUESTABLE = [ - "FULLY_EXECUTED", - "AWAITING_CLEARANCE_DOCUMENTS", - "CLEARANCE_UNDER_REVIEW", - "CLEARANCE_READY_FOR_BOOKING", -]; - /** Split bookings in these statuses released their quantity — no remainder to book. */ const RELEASING_BOOKING_STATUSES = ["CANCELLED", "REJECTED", "EXPIRED"]; @@ -61,31 +49,20 @@ export function getContractBookingAction( contract: Freight.IContract, bookings: Freight.IBooking[], ): ContractBookingAction { - // Customs: the customer never books directly — GL does it for them. The - // shipment request is how they say what to ship and which currency to be - // invoiced in (the contract itself quotes USD only). - if (contract.customsClearingEnabled) { - const requestable = - contract.contractKind === "GENERAL" - ? contract.status === "CONTRACT_ACTIVE" - : // ONE_TIME: both signatures in, GL has not booked yet. Mirrors - // BookingRequestService.ONE_TIME_REQUESTABLE_STATUSES. - ONE_TIME_CUSTOMS_REQUESTABLE.includes(contract.status); - - // One shipment, one open request — the API rejects a second one, so don't - // offer the button while a request is still pending. - const hasOpenRequest = - contract.contractKind === "ONE_TIME" && - bookings.some((b) => b.contractId === contract.id); - - if (requestable && !hasOpenRequest) { - return { - kind: "request", - to: `/contracts/${contract.id}/shipment-requests/new`, - }; - } - return { kind: "none", to: "" }; + // GENERAL + customs: customer submits a shipment request; GL creates the booking. + if ( + contract.customsClearingEnabled && + contract.contractKind === "GENERAL" && + contract.status === "CONTRACT_ACTIVE" + ) { + return { + kind: "request", + to: `/contracts/${contract.id}/shipment-requests/new`, + }; } + + // Other customs (ONE_TIME): booked by GL — no customer action. + if (contract.customsClearingEnabled) return { kind: "none", to: "" }; if (!PATH_A_BOOKABLE.includes(contract.status)) return { kind: "none", to: "" }; const to = `/contracts/${contract.id}/bookings/new`; diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts index 8d7dab7b8..b5a9b6bdb 100644 --- a/packages/types/src/freight/contracts.ts +++ b/packages/types/src/freight/contracts.ts @@ -276,7 +276,14 @@ export type IContractDocumentChange = title: string; fromTitle: string; } - | { kind: 'ARTICLE_BODY_CHANGED'; articleId: string; title: string } + | { + kind: 'ARTICLE_BODY_CHANGED'; + articleId: string; + title: string; + /** Body before / after the edit. Absent on revisions recorded earlier. */ + fromBody?: string; + toBody?: string; + } | { kind: 'ARTICLE_REORDERED'; articleId: string;