Merge branch 'dev' into freight/feat/fixes-v1

This commit is contained in:
Nathnael
2026-07-22 07:20:08 +00:00
195 changed files with 9081 additions and 2516 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

@@ -163,11 +163,12 @@ describe('BookingPricingService — domestic corridor', () => {
computeBaseRailLinesWithRates: (
b: Booking,
input: { containers: [] },
) => Promise<{ lineItems: Array<{ amount: number }> }>;
) => Promise<{ lineItems: Array<{ amount: number }>; blocked: string[] }>;
}
).computeBaseRailLinesWithRates(booking, { containers: [] });
expect(result.lineItems).toHaveLength(0);
expect(result.blocked).toHaveLength(1);
});
it('does not price containers off a rate configured for a different leg', async () => {
@@ -197,4 +198,45 @@ describe('BookingPricingService — domestic corridor', () => {
expect(result.lineItems).toHaveLength(0);
});
// A mixed booking where only one container size has a configured rate must
// hard-block, not silently carry the unconfigured size for free.
it('blocks the unconfigured container size and prices the configured one', async () => {
const fortyOnly: Rate = {
...intercityContainerUsd,
id: 'rate-ct-40-only',
containerTypeId: 'ct-40',
} as Rate;
ratesService.findLiveRates.mockResolvedValue([fortyOnly]);
const booking = {
id: 'b-5',
freightType: 'CONTAINER',
tradeDirection: 'DOMESTIC',
paymentCurrency: 'USD',
originYardId: MOJO,
destinationYardId: DIRE,
bookingContainers: [],
} as unknown as Booking;
const result = await (
service as unknown as {
computeBaseRailLinesWithRates: (
b: Booking,
input: {
containers: Array<{ containerTypeId: string; quantity: number }>;
},
) => Promise<{ lineItems: Array<{ code: string }>; blocked: string[] }>;
}
).computeBaseRailLinesWithRates(booking, {
containers: [
{ containerTypeId: 'ct-40', quantity: 2 },
{ containerTypeId: 'ct-20', quantity: 3 },
],
});
expect(result.lineItems).toHaveLength(1);
expect(result.blocked).toHaveLength(1);
expect(result.blocked[0]).toContain('rate is configured');
});
});

View File

@@ -137,8 +137,12 @@ export class BookingPricingService {
const lineItems: PriceLineItemDto[] = [];
let total = 0;
const { lineItems: baseLines, usedRates: baseRates } =
await this.computeBaseRailLinesWithRates(booking, evalInput, frozenRates);
const {
lineItems: baseLines,
usedRates: baseRates,
warnings: baseWarnings,
blocked: baseBlocked,
} = await this.computeBaseRailLinesWithRates(booking, evalInput, frozenRates);
for (const line of baseLines) {
lineItems.push(line);
total += line.amount;
@@ -247,8 +251,8 @@ export class BookingPricingService {
usedRates: [...usedRatesMap.values()],
appliedModifiers: ruleResult.appliedModifiers,
priorityScore: ruleResult.priorityScore,
warnings: ruleResult.warnings,
hardBlocked: ruleResult.hardBlocked,
warnings: [...ruleResult.warnings, ...baseWarnings],
hardBlocked: [...ruleResult.hardBlocked, ...baseBlocked],
overweightLines,
};
}
@@ -454,7 +458,12 @@ export class BookingPricingService {
booking: Booking,
evalInput: BookingEvaluationInput,
frozenRates: Map<string, ContractRateSnapshot> | null = null,
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> {
): Promise<{
lineItems: PriceLineItemDto[];
usedRates: Rate[];
warnings: string[];
blocked: string[];
}> {
const liveRates = await this.ratesService.findLiveRates();
const paymentCurrency = booking.paymentCurrency;
const isEtbBooking = paymentCurrency === 'ETB';
@@ -476,6 +485,8 @@ export class BookingPricingService {
const lines: PriceLineItemDto[] = [];
const usedRatesMap = new Map<string, Rate>();
const warnings: string[] = [];
const blocked: string[] = [];
const wagonCount = await this.resolveWagonCount(booking);
for (const container of evalInput.containers) {
@@ -487,47 +498,66 @@ export class BookingPricingService {
booking.originYardId,
booking.destinationYardId,
);
if (!rate) continue;
usedRatesMap.set(rate.id, rate);
const unitUsd = Number(rate.rateValue);
// 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.
const frozen = await this.frozenRateForContainer(
frozenRates,
container.containerTypeId,
paymentCurrency,
);
const label = await this.containerTypeLabel(container.containerTypeId);
if (!rate && !frozen) {
// Never price this line off another container type's (or another
// route's) rate, and never let an unpriced line through: a booking
// that ships a container type nobody configured a rate for would be
// carried for free. Hard-block instead — the customer drops the line
// or EDR configures the rate.
blocked.push(
`No ${rateType} rate is configured for ${label} on this route — ` +
`the booking cannot be priced. Remove the ${label} line or ask EDR ` +
'to configure its rate for this origin → destination.',
);
continue;
}
const rateUnit = rate?.rateUnit ?? 'PER_CONTAINER';
let amount: number;
let unitAmount: number;
if (frozen) {
unitAmount = Number(frozen.unitPrice);
amount = this.amountForUnit(
rate.rateUnit,
rateUnit,
unitAmount,
container.quantity,
wagonCount,
);
} else {
const usdAmount = this.amountForRate(rate, container.quantity, wagonCount);
const unitUsd = Number(rate!.rateValue);
const usdAmount = this.amountForRate(rate!, container.quantity, wagonCount);
amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd;
}
const label = await this.containerTypeLabel(container.containerTypeId);
if (rate) usedRatesMap.set(rate.id, rate);
lines.push({
code: rateType,
description: `${label} rail freight`,
amount,
unitAmount,
unit: rate.rateUnit,
quantity: this.effectiveUnitQuantity(rate.rateUnit, container.quantity, wagonCount),
unit: rateUnit,
quantity: this.effectiveUnitQuantity(rateUnit, container.quantity, wagonCount),
currency: paymentCurrency,
});
}
if (lines.length === 0) {
if (lines.length === 0 && evalInput.containers.length === 0) {
// Bulk (and any booking with no container lines) still has to price off a
// rate configured for this leg — never one belonging to another route.
// Container bookings never reach this fallback: their lines price per
// container type above or stay unpriced with a warning — falling back to
// a corridor rate of a DIFFERENT container type billed once (qty 1) is
// how a 38-container booking was invoiced 40 USD instead of 1900.
const fallback = liveRates.find(
(r) =>
r.rateType === rateType &&
@@ -570,10 +600,18 @@ export class BookingPricingService {
quantity: this.effectiveUnitQuantity(fallback.rateUnit, quantity, wagonCount),
currency: paymentCurrency,
});
} else if (isBulk) {
// Same rule as container lines: bulk freight with no rate on this leg
// must not proceed unpriced.
blocked.push(
`No ${rateType} rate is configured for this route — the booking ` +
'cannot be priced. Ask EDR to configure the rate for this ' +
'origin → destination.',
);
}
}
return { lineItems: lines, usedRates: [...usedRatesMap.values()] };
return { lineItems: lines, usedRates: [...usedRatesMap.values()], warnings, blocked };
}
/**

View File

@@ -7,6 +7,7 @@ import {
Logger,
Optional,
} from "@nestjs/common";
import { OnEvent } from "@nestjs/event-emitter";
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { eatDay } from '../train-scheduling/batch-window.util';
@@ -349,6 +350,22 @@ export class BookingTransitionService {
return fresh;
}
/**
* Import EDR last-mile: every handover signed + every truck departed ⇒ the
* warehouses module delivered the goods and asks the booking to complete.
* Best-effort — a booking already COMPLETED (or not yet in transit) just logs.
*/
@OnEvent('import.handover.completed')
async onImportHandoverCompleted(payload: { bookingId: string }): Promise<void> {
try {
await this.complete(payload.bookingId);
} catch (err) {
this.logger.log(
`Booking ${payload.bookingId} not auto-completed on handover sign: ${(err as Error).message}`,
);
}
}
async complete(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ["IN_TRANSIT", "ARRIVED"]);
@@ -840,6 +857,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

@@ -1213,9 +1213,9 @@ export class BookingsService {
/**
* Batched version of the findById flag: marks each page item whose booking
* has a generated-but-unsigned SELF_HAUL handover, so list rows (portal
* dashboard) can show "Approve delivery" for exactly the generated→signed
* window. One query for the whole page.
* has a generated-but-unsigned handover (self-haul or EDR last-mile), so list
* rows (portal dashboard) can show "Approve delivery" for exactly the
* generated→signed window. One query for the whole page.
*/
private async attachHandoverFlags(bookings: Booking[]): Promise<void> {
const ids = bookings.map((b) => b.id);
@@ -1224,8 +1224,7 @@ export class BookingsService {
`SELECT DISTINCT booking_id AS "bookingId"
FROM freight.booking_handovers
WHERE booking_id = ANY($1::uuid[])
AND signed_at IS NULL AND deleted_at IS NULL
AND mile_type = 'SELF_HAUL'`,
AND signed_at IS NULL AND deleted_at IS NULL`,
[ids],
);
const pending = new Set(rows.map((r) => r.bookingId));
@@ -1559,14 +1558,12 @@ export class BookingsService {
schedule?.status ?? null;
}
// A generated-but-unsigned SELF_HAUL handover means the customer must approve
// delivery from the portal (booking-based, one per booking). EDR last-mile
// handovers are per delivering truck and signed by the receiver at the door,
// so they never surface the portal "Approve delivery" action.
// A generated-but-unsigned handover means the customer must approve delivery
// from the portal. Self-haul: booking-based, one per booking. EDR last-mile:
// per delivering truck (generated on truck exit), signed one by one.
const [pendingHandover] = await this.dataSource.query(
`SELECT 1 FROM freight.booking_handovers
WHERE booking_id = $1 AND signed_at IS NULL AND deleted_at IS NULL
AND mile_type = 'SELF_HAUL'
LIMIT 1`,
[id],
);

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,