add contract hazard declaration, DO collection dates, and booking request currency migrations; implement article body diff component and integrate into contract revision timeline

This commit is contained in:
Marshal
2026-07-26 19:14:39 +00:00
parent 5fa012cad3
commit bcf25538b6
14 changed files with 156 additions and 123 deletions

View File

@@ -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<void> {
await queryRunner.query(

View File

@@ -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<void> {
for (const table of [

View File

@@ -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<void> {
await queryRunner.query(

View File

@@ -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.`;

View File

@@ -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<BookingRequest> {
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:

View File

@@ -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

View File

@@ -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)
}`,
);
}
}
}
/**

View File

@@ -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) {

View File

@@ -244,6 +244,7 @@ export class ContractTransitionService {
validityDays: number,
documentSnapshot?: ContractDocumentSnapshotInput | null,
user?: TCurrentUser | null,
window?: { validFrom?: string | null; validUntil?: string | null },
): Promise<Contract> {
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);

View File

@@ -360,6 +360,7 @@ export class ContractsController {
dto.validityDays,
dto.documentSnapshot,
user,
{ validFrom: dto.validFrom, validUntil: dto.validUntil },
);
}

View File

@@ -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

View File

@@ -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).

View File

@@ -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`;

View File

@@ -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;