Implement intercity document handling and rejection notes for contracts

This commit is contained in:
Marshal
2026-07-21 10:15:18 +00:00
parent 603537a20b
commit ceb32e0a80
16 changed files with 287 additions and 23 deletions

View File

@@ -168,6 +168,17 @@ export class BookingLifecycleNotifierService {
});
}
/** Intercity documents approved → booking waits in the ride-along pool. */
intercityDocumentsApproved(b: Booking): void {
const msg =
`Documents for intercity booking ${b.reference} are approved. ` +
`Operations will assign your shipment to a passing train; payment opens once it is accepted.`;
void this.notifyContact(b, msg, 'DOCUMENTS APPROVED');
this.inApp(b, 'Documents approved', msg, {
type: NotificationType.CLEARANCE_DECISION,
});
}
/** Operations returned the operation request for changes. */
operationChangesRequested(b: Booking, note: string): void {
const msg =

View File

@@ -840,6 +840,22 @@ export class BookingTransitionService {
}
}
// Intercity: there is no shipment-day request step — an approved booking
// goes straight to FULLY_EXECUTED, which is what the intercity ride-along
// pool keys on. Staff then accept it onto a passing train (that accept
// opens the pay window).
if (booking.tradeDirection === "DOMESTIC") {
const now = new Date();
await this.bookingsRepository.update(bookingId, {
status: "FULLY_EXECUTED",
fullyExecutedAt: now,
lockedAt: booking.lockedAt ?? now,
} as never);
const fresh = await this.bookingsService.findById(bookingId);
this.notifier.intercityDocumentsApproved(fresh);
return fresh;
}
await this.bookingsRepository.update(bookingId, {
status: "CLEARANCE_READY",
} as never);

View File

@@ -1,7 +1,10 @@
import {
clearanceSettingCode,
clearanceOutputSettingCode,
clearanceCodesForBooking,
INTERCITY_DOCUMENTS_SETTING_CODE,
} from './clearance.util';
import type { Booking } from './entities/booking.entity';
describe('clearance.util — clearanceSettingCode', () => {
it('resolves import container with/without customs', () => {
@@ -24,9 +27,49 @@ describe('clearance.util — clearanceSettingCode', () => {
);
});
it('returns null for DOMESTIC (no clearance gate)', () => {
expect(clearanceSettingCode('DOMESTIC', 'CONTAINER', true)).toBeNull();
expect(clearanceSettingCode('DOMESTIC', 'BULK', false)).toBeNull();
it('resolves the intercity document set for DOMESTIC regardless of customs/freight', () => {
expect(clearanceSettingCode('DOMESTIC', 'CONTAINER', true)).toBe(
INTERCITY_DOCUMENTS_SETTING_CODE,
);
expect(clearanceSettingCode('DOMESTIC', 'BULK', false)).toBe(
INTERCITY_DOCUMENTS_SETTING_CODE,
);
});
});
describe('clearance.util — clearanceCodesForBooking (intercity)', () => {
const base = {
tradeDirection: 'DOMESTIC',
freightType: 'CONTAINER',
serviceType: null,
customsClearingEnabled: false,
};
it('GENERAL drawdowns and direct bookings carry the per-booking intercity set', () => {
const general = clearanceCodesForBooking({
...base,
contractId: 'c1',
contractKind: 'GENERAL',
} as unknown as Booking);
expect(general.inputCode).toBe(INTERCITY_DOCUMENTS_SETTING_CODE);
expect(general.outputCode).toBeNull();
const direct = clearanceCodesForBooking({
...base,
contractId: null,
contractKind: null,
} as unknown as Booking);
expect(direct.inputCode).toBe(INTERCITY_DOCUMENTS_SETTING_CODE);
});
it('ONE_TIME contract drawdowns skip the per-booking set (contract collected it)', () => {
const drawdown = clearanceCodesForBooking({
...base,
contractId: 'c1',
contractKind: 'ONE_TIME',
} as unknown as Booking);
expect(drawdown.inputCode).toBeNull();
expect(drawdown.outputCode).toBeNull();
});
});

View File

@@ -9,11 +9,19 @@ import { Booking } from './entities/booking.entity';
type Op = 'import' | 'export';
type Freight = 'container' | 'bulk';
/**
* The single (admin-configured) document set intercity shipments upload.
* DOMESTIC has no customs, so one shared set serves contracts and bookings:
* ONE_TIME collects it at contract level, GENERAL per booking — Operations
* reviews either way.
*/
export const INTERCITY_DOCUMENTS_SETTING_CODE = 'intercity_documents';
/** Trade direction → clearance operation. DOMESTIC has no customs clearance. */
function operationFor(tradeDirection: string): Op | null {
if (tradeDirection === 'IMPORT') return 'import';
if (tradeDirection === 'EXPORT') return 'export';
return null; // DOMESTIC / intercity — no clearance gate
return null; // DOMESTIC / intercity — no customs operation
}
function freightFor(freightType: string): Freight {
@@ -26,6 +34,9 @@ export function clearanceSettingCode(
freightType: string,
includesCustoms: boolean,
): string | null {
// Intercity: no customs, but the admin-configured intercity document set is
// still collected and ops-reviewed before the shipment may board a train.
if (tradeDirection === 'DOMESTIC') return INTERCITY_DOCUMENTS_SETTING_CODE;
const op = operationFor(tradeDirection);
if (!op) return null;
const freight = freightFor(freightType);
@@ -66,6 +77,16 @@ export function clearanceCodesForBooking(booking: Booking): {
const includesCustoms =
Boolean(booking.serviceType?.includesCustoms) ||
Boolean(booking.customsClearingEnabled);
// Intercity drawdowns under a ONE_TIME contract already cleared the intercity
// document set on the CONTRACT (post-signature); only GENERAL drawdowns and
// direct (contract-less) bookings carry the per-booking set.
if (
booking.tradeDirection === 'DOMESTIC' &&
booking.contractId &&
booking.contractKind === 'ONE_TIME'
) {
return { inputCode: null, outputCode: null, includesCustoms: false };
}
return {
inputCode: clearanceSettingCode(
booking.tradeDirection,