Merge branch 'dev' into freight/nati-2

This commit is contained in:
Nathnael
2026-08-24 08:38:37 +00:00
225 changed files with 19460 additions and 4235 deletions

View File

@@ -47,6 +47,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"POST /api/bookings/:id/clearance/proceed": ["Customer requests operation with a schedule day", "POST", "Booking"],
"POST /api/bookings/:id/clearance/release-order": ["Upload Booking Release Order", "POST", "Booking"],
"POST /api/bookings/:id/clearance/review": ["GL reviews a clearance document (Approve | Query)", "POST", "Booking"],
"POST /api/bookings/:id/clearance/doc-requests": ["GL asks the customer for additional clearance documents", "POST", "Booking"],
"POST /api/bookings/:id/clearance/charges/port-document": ["GL Djibouti uploads the port-charges document", "POST", "Booking"],
"PATCH /api/bookings/:id/clearance/charges/:chargeId/bill": ["GL Ethiopia sets or revises a clearance charge's amount + currency", "PATCH", "Booking"],
"POST /api/bookings/:id/clearance/charges/:chargeId/send": ["GL Ethiopia issues the clearance charge invoice to the customer", "POST", "Booking"],

View File

@@ -1,5 +1,6 @@
import { Freight } from "@edr/types";
import { WAGON_CANCEL_FEE_INVOICE_TYPE } from "../bookings/entities/booking-wagon-cancellation.entity";
import { BillingService } from "./billing.service";
/**
@@ -1033,3 +1034,73 @@ describe("BillingService.document", () => {
expect(render).not.toHaveBeenCalled();
});
});
/**
* The pay-window guard belongs to the freight invoice. A wagon-cancellation fee
* rides source=booking but is raised on an already-PAID booking, so it inherits
* a deadline that has long passed — guarding it would make the fee permanently
* unsettleable.
*/
describe("BillingService.confirmOfflinePayment pay-window guard", () => {
const PAST = new Date(Date.now() - 86_400_000);
function makeService(invoiceType: string) {
const invoice = {
id: "inv-1",
source: Freight.InvoiceSource.Booking,
sourceId: "booking-1",
type: invoiceType,
currency: "ETB",
status: Freight.InvoiceStatus.Issued,
balanceAmount: 500,
};
const recordPayment = jest.fn().mockResolvedValue(invoice);
const dataSource = {
getRepository: () => ({
findOne: async () => ({ id: "booking-1", paymentDeadline: PAST }),
}),
};
const service = new BillingService(
dataSource as never,
{ findById: async () => invoice } as never,
{} as never,
makeEvents() as never,
{} as never,
{} as never,
{} as never,
{ upload: async () => ({ id: "file-1", name: "slip.pdf" }) } as never,
{ get: () => undefined } as never,
{ isEnabled: async () => true } as never,
);
(service as unknown as { recordPayment: unknown }).recordPayment =
recordPayment;
return { service, recordPayment };
}
const slip = { originalname: "slip.pdf" } as never;
it("refuses a freight invoice once the pay window has closed", async () => {
const { service } = makeService("PREPAID");
await expect(
service.confirmOfflinePayment("inv-1", slip, {}),
).rejects.toThrow(/payment window has closed/i);
});
it("settles a wagon-cancellation fee despite the closed window", async () => {
const { service, recordPayment } = makeService(
WAGON_CANCEL_FEE_INVOICE_TYPE,
);
await service.confirmOfflinePayment("inv-1", slip, {});
expect(recordPayment).toHaveBeenCalledWith(
"inv-1",
expect.objectContaining({ amount: 500, method: "BANK_TRANSFER" }),
);
});
it("still requires the bank slip for a cancellation fee", async () => {
const { service } = makeService(WAGON_CANCEL_FEE_INVOICE_TYPE);
await expect(
service.confirmOfflinePayment("inv-1", undefined, {}),
).rejects.toThrow(/slip file is required/i);
});
});

View File

@@ -13,6 +13,8 @@ import { logCtx } from "@edr/api-common";
import { DataSource, EntityManager, In, SelectQueryBuilder } from "typeorm";
import { Booking } from "../bookings/entities/booking.entity";
import { AdditionalCharge } from "../bookings/entities/additional-charge.entity";
import { WAGON_CANCEL_FEE_INVOICE_TYPE } from "../bookings/entities/booking-wagon-cancellation.entity";
// Entity-only import (no module edge): portal reads resolve shipping-line
// payers straight off the table.
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
@@ -737,7 +739,14 @@ export class BillingService {
throw new BadRequestException("The bank payment slip file is required.");
}
if (invoice.source === "booking") {
// The pay window belongs to the freight invoice. A wagon-cancellation fee
// rides source=booking but is raised on an ALREADY-PAID booking, so it
// inherits a deadline that has long passed — guarding it would make the fee
// permanently unsettleable.
if (
invoice.source === "booking" &&
invoice.type !== WAGON_CANCEL_FEE_INVOICE_TYPE
) {
const booking = await this.dataSource.getRepository(Booking).findOne({
where: { id: invoice.sourceId },
select: ["id", "paymentDeadline"],
@@ -2057,6 +2066,14 @@ export class BillingService {
.getRepository(Booking)
.update({ id: invoice.sourceId }, { pnrCode: billReference });
}
// Same reference, for an ad-hoc additional charge — its own column, since
// an AdditionalCharge doesn't own a Booking-scoped `pnrCode` and a booking
// can carry many of these at once.
if (billReference && invoice.source === Freight.InvoiceSource.AdditionalCharge) {
await this.dataSource
.getRepository(AdditionalCharge)
.update({ id: invoice.sourceId }, { paymentReference: billReference });
}
// Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
// billing must not simulate it. Kept for local demos only.

View File

@@ -60,11 +60,7 @@ const context = (over: Partial<EimsMapperContext> = {}): EimsMapperContext => ({
unitDefault: "PCS",
incomeWithholdValue: 0,
transactionWithholdValue: 0,
buyerCountryCode: "231", // test-only, not a confirmed real MoR code
buyerCountryCodes: {},
buyerRegionCodes: { "Addis Ababa": "13" },
buyerWeredaCodes: {},
buyerCityCodes: {},
buyerGeo: { Country: "70", Region: "6", City: "31", Wereda: "190" },
...over,
});
@@ -94,10 +90,9 @@ describe("toEimsInvoice", () => {
const doc = toEimsInvoice(invoice(), seller, context());
expect(doc.BuyerDetails).toEqual({
City: null,
// company.country is "Ethiopia" (the domestic default) — resolves to context's flat
// buyerCountryCode fallback, not null, per resolveCountryCode.
Country: "231",
// Resolved by the registration service before the counter was reserved; the mapper copies.
City: "31",
Country: "70",
Email: "buyer@abc.et",
HouseNumber: "NEW",
IdNumber: null,
@@ -105,11 +100,11 @@ describe("toEimsInvoice", () => {
Tin: "0999930000",
LegalName: "ABC Trading PLC",
Phone: "0912345678",
Region: "13",
Region: "6",
Zone: "SHA",
Kebele: "03",
VatNumber: "123475885858",
Wereda: "574",
Wereda: "190",
});
});
@@ -284,108 +279,39 @@ describe("toEimsInvoice", () => {
});
describe("toEimsInvoice — MoR field constraints", () => {
it("passes a buyer region through when it is already a MoR code", () => {
/**
* Geography is no longer resolved here. `resolveMorGeo` runs in the registration service, ahead
* of the counter reservation, and hands the mapper finished MoR codes — so what these cover is
* that the resolved values reach the right `BuyerDetails` fields untouched. The lookup rules
* themselves (hierarchy, aliases, ambiguity) are covered in `mor-location.resolver.spec.ts`.
*/
it("puts the resolved MoR codes on BuyerDetails, unmodified and as strings", () => {
const doc = toEimsInvoice(invoice(), seller, context());
expect(doc.BuyerDetails.Region).toBe("13");
expect(doc.BuyerDetails.Country).toBe("70");
expect(doc.BuyerDetails.Region).toBe("6");
expect(doc.BuyerDetails.City).toBe("31");
expect(doc.BuyerDetails.Wereda).toBe("190");
for (const field of ["Country", "Region", "City", "Wereda"] as const) {
expect(typeof doc.BuyerDetails[field]).toBe("string");
}
});
it("maps a region name to its code, ignoring case and spacing", () => {
const doc = toEimsInvoice(
invoice({ company: { ...invoice().company!, region: " addis ababa " } }),
seller,
context({ buyerRegionCodes: { "Addis Ababa": "13" } }),
);
expect(doc.BuyerDetails.Region).toBe("13");
});
it("refuses to file a buyer whose region has no mapping", () => {
expect(() =>
toEimsInvoice(
invoice({ company: { ...invoice().company!, region: "Somewhere Else" } }),
seller,
context(),
),
).toThrow(/not a MoR Region code and has no mapping/);
});
it("refuses a buyer with no region at all rather than guessing one", () => {
expect(() =>
toEimsInvoice(
invoice({ company: { ...invoice().company!, region: null } }),
seller,
context(),
),
).toThrow(/buyer Region \(unset\)/);
});
it("passes a buyer wereda through when it is already a MoR code", () => {
it("never emits an Open Admin Data ETxx identifier as a location", () => {
const doc = toEimsInvoice(invoice(), seller, context());
expect(doc.BuyerDetails.Wereda).toBe("574");
for (const field of ["Country", "Region", "City", "Wereda"] as const) {
expect(doc.BuyerDetails[field]).toMatch(/^[0-9]+$/);
}
});
it("maps a wereda name to its code", () => {
it("keeps BuyerDetails.Zone as the buyer's own zone name — MoR takes that one as prose", () => {
const doc = toEimsInvoice(
invoice({ company: { ...invoice().company!, woreda: "Yeka" } }),
invoice({ company: { ...invoice().company!, zone: "Fafen" } }),
seller,
context({ buyerWeredaCodes: { Yeka: "99" } }),
context(),
);
expect(doc.BuyerDetails.Wereda).toBe("99");
});
it("refuses to file a buyer whose wereda has no mapping", () => {
expect(() =>
toEimsInvoice(
invoice({ company: { ...invoice().company!, woreda: "Yeka" } }),
seller,
context({ buyerWeredaCodes: {} }),
),
).toThrow(/buyer Wereda "Yeka".*EIMS_BUYER_WEREDA_CODES/);
});
it("derives City from the buyer's zone via the city code map", () => {
const doc = toEimsInvoice(
invoice({ company: { ...invoice().company!, zone: "Kirkos" } }),
seller,
context({ buyerCityCodes: { Kirkos: "101" } }),
);
expect(doc.BuyerDetails.City).toBe("101");
});
it("leaves City null (not a throw) when the buyer's zone has no city mapping — City is optional", () => {
const doc = toEimsInvoice(
invoice({ company: { ...invoice().company!, zone: "Somewhere Else" } }),
seller,
context({ buyerCityCodes: {} }),
);
expect(doc.BuyerDetails.City).toBeNull();
});
it("maps a buyer country name to its code via the country code map", () => {
const doc = toEimsInvoice(
invoice({ company: { ...invoice().company!, country: "Djibouti" } }),
seller,
context({ buyerCountryCodes: { Djibouti: "071" } }),
);
expect(doc.BuyerDetails.Country).toBe("071");
});
it("falls back to the flat domestic country code only for Ethiopia, not any unmapped country", () => {
const doc = toEimsInvoice(
invoice({ company: { ...invoice().company!, country: "Ethiopia" } }),
seller,
context({ buyerCountryCode: "231", buyerCountryCodes: {} }),
);
expect(doc.BuyerDetails.Country).toBe("231");
});
it("refuses a genuinely foreign buyer country with no mapping — never silently files it as Ethiopia", () => {
expect(() =>
toEimsInvoice(
invoice({ company: { ...invoice().company!, country: "Kenya" } }),
seller,
context({ buyerCountryCode: "231", buyerCountryCodes: {} }),
),
).toThrow(/buyer Country "Kenya".*EIMS_BUYER_COUNTRY_CODES/);
expect(doc.BuyerDetails.Zone).toBe("Fafen");
expect(doc.BuyerDetails.City).toBe("31");
});
it("emits NatureOfSupplies lowercase, whatever case it was configured in", () => {

View File

@@ -15,6 +15,7 @@
* authoritative: they are passed through or overridable rather than validated against a fixed set.
*/
import { MorGeoCodes } from "../../config/mor-location.resolver";
import { round2 } from "./invoice-settlement.util";
/** Only proven-required constant: the 400 SCHEMA ERROR sample rejects a payload without it. */
@@ -235,35 +236,15 @@ export interface EimsMapperContext {
*/
relatedDocument?: string | null;
/**
* Domestic fallback only, applied when `company.country` is empty or "Ethiopia" and not already
* in `buyerCountryCodes` — see that field. Never applied to a genuinely foreign buyer.
*/
buyerCountryCode?: string | null;
/** Country name → MoR code. Format unconfirmed, so looked up by name only, not digit-validated. */
buyerCountryCodes: Record<string, string>;
/**
* Region name → MoR numeric code, for buyers whose stored region is free text.
* The buyer's MoR location codes — `Country`/`Region`/`City`/`Wereda`, already resolved from the
* Ministry's location master by `resolveMorGeo`.
*
* `companies.region` holds names ("Addis Ababa") while MoR validates `BuyerDetails.Region`
* against `^[0-9]{1,3}$`. A stored value that is already a code passes through; anything else
* must be in this map or the mapping **fails locally** — sending a guessed region code onto a
* tax document is worse than refusing to file.
* Resolved by the caller, not here, and deliberately so: geographic resolution can fail (unknown
* or ambiguous address) and that failure must happen **before** an EIMS counter is reserved, so a
* bad company address never burns a sequence number. See `mor-location.resolver.ts` for why the
* lookup has to be hierarchical, and `EimsInvoiceRegistrationService` for where it runs.
*/
buyerRegionCodes: Record<string, string>;
/**
* Wereda name → MoR code, same shape as `buyerRegionCodes`. `companies.woreda` holds names
* ("Yeka") or codes inconsistently; unlike Region, MoR has never named a Wereda regex in an
* error, so this is precautionary rather than confirmed — but the fix is identical either way:
* fail locally on an unmapped name rather than file a guess.
*/
buyerWeredaCodes: Record<string, string>;
/**
* Buyer *zone* name → MoR City code. `Company` has no dedicated city column; Zone is the
* closest match in EDR's own data. Unlike Region/Wereda, City is optional — MoR has already
* accepted a live filing with it null — so an unmapped zone resolves to null, it does not fail
* the mapping.
*/
buyerCityCodes: Record<string, string>;
buyerGeo: MorGeoCodes;
buyerIdType?: string | null;
buyerIdNumber?: string | null;
/** Required when the invoice currency is not ETB. */
@@ -273,14 +254,6 @@ export interface EimsMapperContext {
formatDate?: (issuedAt: Date) => string;
}
/**
* MoR's own constraint on `Region`: one to three digits, confirmed by its 400 SCHEMA ERROR. Reused
* as the pass-through test for `Wereda` too — every Wereda value MoR has actually shown us (seller
* "12"/"13", the collection's "574") fits the same shape, though MoR has not named a Wereda regex
* the way it named Region's.
*/
const LOCATION_CODE = /^[0-9]{1,3}$/;
/**
* The only two values MoR accepts for `NatureOfSupplies`, lowercase.
*
@@ -309,87 +282,6 @@ export const formatEimsDate = (issuedAt: Date): string =>
* an unissued invoice, unresolved line tax, a line/total mismatch, or a non-ETB invoice with no
* exchange rate.
*/
/**
* A buyer's location value (Region, Wereda or City) as a MoR code: passed through when already
* numeric, otherwise looked up by name (case- and space-insensitive).
*
* Region/Wereda are required: an unmapped value throws — sending a guessed code onto a tax
* document is worse than refusing to file. City is optional (`required: false`, City's own
* caller) — MoR has already accepted a live filing with it null, so an unmapped zone resolves to
* null instead of blocking the invoice.
*/
function resolveLocationCode(
field: "Region" | "Wereda" | "City",
value: string | null | undefined,
codes: Record<string, string>,
envVar: string,
invoiceNumber: string,
opts: { required?: boolean } = {},
): string | null {
const raw = (value ?? "").trim();
if (LOCATION_CODE.test(raw)) return raw;
const key = raw.toLowerCase().replace(/\s+/g, " ");
const mapped = Object.entries(codes).find(
([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key,
)?.[1];
if (mapped && LOCATION_CODE.test(mapped)) return mapped;
if (opts.required === false) return null;
throw new Error(
`EIMS mapping: invoice ${invoiceNumber} has buyer ${field} ${raw ? `"${raw}"` : "(unset)"}, ` +
`which is not a MoR ${field} code and has no mapping. Add it to ${envVar}.`,
);
}
/**
* A buyer's `Country` as a MoR code: looked up by name in `codes` first; when unmapped, applies
* `domesticFallback` only if the stored country is empty or "Ethiopia" (the DB column's default).
* A genuinely foreign, unmapped country throws rather than silently filing as Ethiopia — same
* "fail locally, don't guess" rule as `resolveLocationCode`, but never digit-validated: MoR's
* Country code format is unconfirmed, unlike Region/Wereda's proven `^[0-9]{1,3}$`.
*/
function resolveCountryCode(
country: string | null | undefined,
codes: Record<string, string>,
domesticFallback: string | null,
invoiceNumber: string,
): string | null {
const raw = (country ?? "").trim();
const key = raw.toLowerCase().replace(/\s+/g, " ");
const mapped = Object.entries(codes).find(
([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key,
)?.[1];
if (mapped) return mapped;
if ((!raw || key === "ethiopia") && domesticFallback) return domesticFallback;
throw new Error(
`EIMS mapping: invoice ${invoiceNumber} has buyer Country "${raw || "(unset)"}", which has no ` +
"MoR country code mapping. Add it to EIMS_BUYER_COUNTRY_CODES.",
);
}
/**
* Same name-or-code resolution as `resolveLocationCode`, for a caller with no invoice to attach an
* error to and that must never throw — currently only `EimsSellerCacheService`, resolving
* e-Trade's region/zone/woreda *names* for EDR's own seller identity. Pass-through numeric code,
* name lookup, `undefined` on no match — the caller falls back to static config either way.
*/
export function resolveOptionalCode(
value: string | null | undefined,
codes: Record<string, string>,
): string | undefined {
const raw = (value ?? "").trim();
if (LOCATION_CODE.test(raw)) return raw;
const key = raw.toLowerCase().replace(/\s+/g, " ");
const mapped = Object.entries(codes).find(
([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key,
)?.[1];
return mapped && LOCATION_CODE.test(mapped) ? mapped : undefined;
}
export function toEimsInvoice(
invoice: EimsMapperInvoice,
seller: EimsSellerDetails,
@@ -511,16 +403,11 @@ export function toEimsInvoice(
return {
BuyerDetails: {
// No dedicated city column on Company — Zone is the closest match; optional (see
// resolveLocationCode's City comment).
City: resolveLocationCode(
"City",
company.zone,
context.buyerCityCodes,
"EIMS_BUYER_CITY_CODES",
invoice.invoiceNumber,
{ required: false },
),
// Country/Region/City/Wereda are MoR location codes resolved from the Ministry's own
// location master *before* this mapper ran, and before an EIMS counter was reserved — see
// EimsMapperContext.buyerGeo. `Zone` alongside them is the buyer's free-text zone name,
// which MoR takes as prose, not a code.
City: context.buyerGeo.City,
Email: company.email ?? null,
HouseNumber: company.houseNo ?? null,
IdNumber: context.buyerIdNumber ?? null,
@@ -528,29 +415,12 @@ export function toEimsInvoice(
Tin: company.tin,
LegalName: company.name,
Phone: company.phone ?? null,
Region: resolveLocationCode(
"Region",
company.region,
context.buyerRegionCodes,
"EIMS_BUYER_REGION_CODES",
invoice.invoiceNumber,
),
Country: resolveCountryCode(
company.country,
context.buyerCountryCodes,
context.buyerCountryCode ?? null,
invoice.invoiceNumber,
),
Region: context.buyerGeo.Region,
Country: context.buyerGeo.Country,
Zone: company.zone ?? null,
Kebele: company.kebele ?? null,
VatNumber: company.vatNumber ?? null,
Wereda: resolveLocationCode(
"Wereda",
company.woreda,
context.buyerWeredaCodes,
"EIMS_BUYER_WEREDA_CODES",
invoice.invoiceNumber,
),
Wereda: context.buyerGeo.Wereda,
},
DocumentDetails: {
DocumentNumber: context.documentNumber,

View File

@@ -0,0 +1,33 @@
import { adHocLabel } from './clearance.util';
/**
* The customer's typed document name travels to the API inside the multipart
* field code (`custom_<slug>_<n>`) — the only channel a part has — and comes
* back out here for GL's review grid. Mirror of `adHocSlug` in the portal's
* useClearanceFlow.
*/
const adHocSlug = (name: string) =>
name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 60);
const roundTrip = (typed: string) => adHocLabel(`custom_${adHocSlug(typed)}_17877000000000`);
describe('adHocLabel', () => {
it('recovers the name the customer typed', () => {
expect(roundTrip('Special permit')).toBe('Special permit');
expect(roundTrip('Fumigation Certificate')).toBe('Fumigation certificate');
expect(roundTrip('bank slip #2')).toBe('Bank slip 2');
});
it('returns null when there is no name to show, so callers use the filename', () => {
expect(roundTrip('')).toBeNull();
// Legacy uploads keyed `custom_<timestamp>_<n>` carry no name — without the
// digits guard this would surface "1755780000000" as the document label.
expect(adHocLabel('custom_1755780000000_0')).toBeNull();
expect(adHocLabel('commercial_invoice')).toBeNull();
});
});

View File

@@ -0,0 +1,13 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AdditionalCharge } from './entities/additional-charge.entity';
@Injectable()
export class AdditionalChargeRepository extends BaseRepository<AdditionalCharge> {
constructor(@InjectRepository(AdditionalCharge) repository: Repository<AdditionalCharge>) {
super(repository);
}
}

View File

@@ -0,0 +1,314 @@
import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { DataSource, EntityManager } from 'typeorm';
import { ExchangeService } from '@edr/api-common';
import { Freight, NotificationAudience, NotificationType } from '@edr/types';
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
import { Invoice } from '../billing/entities/invoice.entity';
import { FilesService } from '../files/files.service';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { sendCompanyChannels } from '../notifications/notify-company.util';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { BookingsService } from './bookings.service';
import { BookingsRepository } from './bookings.repository';
import { AdditionalChargeRepository } from './additional-charge.repository';
import { AdditionalCharge } from './entities/additional-charge.entity';
import { CreateAdditionalChargeDto } from './dto/additional-charge.dto';
const FILE_RESOURCE = 'additional_charges';
/**
* Ad-hoc extra charges finance raises against a booking, independent of
* `BookingClearanceCharge` (which is capped at one PORT_CHARGES/MISCELLANEOUS
* row per booking). Any number per booking, free-text reason. DRAFT until
* sent; sending issues the payable invoice and notifies the customer
* (in-app + SMS + email). Settles via `additional_charge.invoice.paid`,
* same event-driven pattern as every other invoice source.
*/
@Injectable()
export class AdditionalChargeService {
private readonly logger = new Logger(AdditionalChargeService.name);
constructor(
private readonly dataSource: DataSource,
private readonly repository: AdditionalChargeRepository,
private readonly bookingsRepository: BookingsRepository,
private readonly filesService: FilesService,
private readonly exchangeService: ExchangeService,
private readonly billing: BillingService,
private readonly bookingsService: BookingsService,
private readonly notifications: NotificationsService,
private readonly inbox: NotificationInboxService,
) {}
private async findOwned(bookingId: string, chargeId: string): Promise<AdditionalCharge> {
const charge = await this.repository.findById(chargeId);
if (!charge || charge.bookingId !== bookingId) {
throw new NotFoundException('Additional charge not found');
}
return charge;
}
async list(bookingId: string): Promise<Freight.AdditionalCharge[]> {
const rows = await this.repository.findAll({
where: { bookingId },
order: { createdAt: 'DESC' },
});
return this.toDtoList(rows);
}
async create(
bookingId: string,
dto: CreateAdditionalChargeDto,
staffId: string,
file?: Express.Multer.File,
): Promise<Freight.AdditionalCharge[]> {
const booking = await this.bookingsService.findById(bookingId);
const shouldSend = dto.action === 'send';
const chargeId = await this.dataSource.transaction(async (manager) => {
const repo = manager.getRepository(AdditionalCharge);
let saved = await repo.save(
repo.create({
bookingId,
reason: dto.reason.trim(),
amount: dto.amount.toFixed(2),
currency: dto.currency.trim().toUpperCase(),
dueAt: dto.dueDate ? new Date(dto.dueDate) : null,
status: 'DRAFT',
createdByStaffId: staffId,
}),
);
if (file) {
const record = await this.filesService.upload({
resourceId: saved.id,
resource: FILE_RESOURCE,
code: FILE_RESOURCE,
file,
uploadedByUserId: staffId,
});
await repo.update(saved.id, { fileRecordId: record.id });
}
if (shouldSend) {
saved = await this.issueInvoice(manager, saved.id, booking, staffId);
}
return saved.id;
});
if (shouldSend) await this.notifyCustomerSent(chargeId);
return this.list(bookingId);
}
async send(bookingId: string, chargeId: string, staffId: string): Promise<Freight.AdditionalCharge[]> {
const charge = await this.findOwned(bookingId, chargeId);
if (charge.status !== 'DRAFT') {
throw new ConflictException('Only a draft charge can be sent.');
}
const booking = await this.bookingsService.findById(bookingId);
await this.dataSource.transaction((manager) =>
this.issueInvoice(manager, charge.id, booking, staffId),
);
await this.notifyCustomerSent(charge.id);
return this.list(bookingId);
}
/** Issues the invoice and flips DRAFT → SENT. Notification happens after commit — never inside the transaction. */
private async issueInvoice(
manager: EntityManager,
chargeId: string,
booking: { id: string; companyId?: string | null; companyProfileId?: string | null; reference?: string | null },
staffId: string,
): Promise<AdditionalCharge> {
const repo = manager.getRepository(AdditionalCharge);
const charge = await repo.findOneByOrFail({ id: chargeId });
const invoice = await this.billing.generateInvoice(
{
source: Freight.InvoiceSource.AdditionalCharge,
sourceId: charge.id,
type: 'ADDITIONAL_CHARGE',
companyId: booking.companyId,
companyProfileId: booking.companyProfileId,
currency: charge.currency,
// Unset falls through to BillingService's own DEFAULT_DUE_DAYS (14).
dueAt: charge.dueAt ?? undefined,
lines: [
{
chargeType: 'ADDITIONAL_CHARGE',
description: `${charge.reason}${booking.reference ?? booking.id}`,
amount: Number(charge.amount),
},
],
},
manager,
);
await repo.update(charge.id, {
status: 'SENT',
invoiceId: invoice.id,
sentByStaffId: staffId,
sentAt: new Date(),
});
this.logger.log(
`Additional charge ${charge.id} on booking ${booking.id} sent as invoice ${invoice.invoiceNumber}`,
);
return repo.findOneByOrFail({ id: charge.id });
}
private async notifyCustomerSent(chargeId: string): Promise<void> {
try {
const charge = await this.repository.findById(chargeId);
if (!charge) return;
const booking = await this.bookingsService.findById(charge.bookingId);
if (!booking.companyId) return;
const body = `A new charge of ${charge.amount} ${charge.currency} has been added to booking ${booking.reference ?? charge.bookingId}: ${charge.reason}. Pay via the portal.`;
await this.inbox.notify({
recipients: { companyId: booking.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.INVOICE_ISSUED,
title: 'New charge on your booking',
body,
link: `/bookings/${charge.bookingId}`,
data: {
bookingId: charge.bookingId,
chargeId: charge.id,
amount: Number(charge.amount),
currency: charge.currency,
},
});
await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body);
} catch (err) {
this.logger.warn(`Additional charge sent-notify failed for ${chargeId}: ${(err as Error).message}`);
}
}
async cancel(
bookingId: string,
chargeId: string,
staffId: string,
reason?: string,
): Promise<Freight.AdditionalCharge[]> {
const charge = await this.findOwned(bookingId, chargeId);
if (charge.status !== 'DRAFT' && charge.status !== 'SENT') {
throw new ConflictException('Only a draft or unpaid charge can be cancelled.');
}
if (charge.status === 'SENT' && charge.invoiceId) {
await this.billing.cancelInvoice(charge.invoiceId);
}
await this.repository.update(charge.id, {
status: 'CANCELLED',
cancelledByStaffId: staffId,
cancelledAt: new Date(),
cancelReason: reason ?? null,
});
return this.list(bookingId);
}
/** Gateway and manual settlements both land here (`${source}.invoice.paid`). */
@OnEvent('additional_charge.invoice.paid')
async onChargeInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
const charge = await this.repository.findById(payload.sourceId);
if (!charge || charge.status === 'PAID') return;
await this.repository.update(charge.id, { status: 'PAID', paidAt: new Date() });
try {
const booking = await this.bookingsService.findById(charge.bookingId);
if (!booking.companyId) return;
const body = `Payment received for ${charge.amount} ${charge.currency} on booking ${booking.reference ?? charge.bookingId}: ${charge.reason}.`;
await this.inbox.notify({
recipients: { companyId: booking.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.PAYMENT_RECEIVED,
title: 'Charge payment received',
body,
link: `/bookings/${charge.bookingId}`,
data: { bookingId: charge.bookingId, chargeId: charge.id },
});
await this.inbox.notify({
recipients: { permissionKeys: [FREIGHT_PERMS.additionalCharges.getNotification] },
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.PAYMENT_RECEIVED,
title: 'Additional charge paid',
body,
link: `/bookings/${charge.bookingId}`,
data: { bookingId: charge.bookingId, chargeId: charge.id },
});
} catch (err) {
this.logger.warn(`Additional charge paid-notify failed for ${charge.id}: ${(err as Error).message}`);
}
}
private async toDtoList(rows: AdditionalCharge[]): Promise<Freight.AdditionalCharge[]> {
if (!rows.length) return [];
const filesByCharge = await this.filesService.findByResourceIdsGrouped(
rows.map((r) => r.id),
FILE_RESOURCE,
);
const names = await this.bookingsRepository.resolveStaffNames(
rows.flatMap((r) => [r.createdByStaffId, r.sentByStaffId]),
);
const invoiceIds = rows.map((r) => r.invoiceId).filter((id): id is string => Boolean(id));
const invoices = invoiceIds.length
? await this.dataSource.getRepository(Invoice).find({ where: invoiceIds.map((id) => ({ id })) })
: [];
const invoiceById = new Map(invoices.map((i) => [i.id, i]));
const converted = await Promise.all(rows.map((r) => this.convertAmount(r)));
const convertedById = new Map(rows.map((r, i) => [r.id, converted[i]]));
return rows.map((r) => {
const file = filesByCharge.get(r.id)?.[0];
const fx = convertedById.get(r.id) ?? null;
return {
id: r.id,
bookingId: r.bookingId,
reason: r.reason,
status: r.status,
amount: Number(r.amount),
currency: r.currency,
convertedAmount: fx?.amount ?? null,
convertedCurrency: fx?.currency ?? null,
dueAt: r.dueAt?.toISOString() ?? null,
file: file ? { id: file.id, name: file.name, url: file.url } : null,
invoiceId: r.invoiceId ?? null,
invoiceNumber: r.invoiceId ? (invoiceById.get(r.invoiceId)?.invoiceNumber ?? null) : null,
paymentReference: r.paymentReference ?? null,
createdByName: r.createdByStaffId ? (names.get(r.createdByStaffId) ?? null) : null,
createdAt: r.createdAt.toISOString(),
sentByName: r.sentByStaffId ? (names.get(r.sentByStaffId) ?? null) : null,
sentAt: r.sentAt?.toISOString() ?? null,
paidAt: r.paidAt?.toISOString() ?? null,
cancelledAt: r.cancelledAt?.toISOString() ?? null,
cancelReason: r.cancelReason ?? null,
};
});
}
/**
* Amount converted to the other of ETB/USD, via the existing shared
* `ExchangeService` (CBE rate, falls back to the stored `exchange_settings`
* rate) — same mechanism `booking-wagon-cancellation.service.ts` and
* warehouse fee pricing already use. Null on anything but ETB/USD, or if
* the rate feed is down — this is a display convenience, not the payable
* amount, so a failure here must never break the charge list.
*/
private async convertAmount(
charge: AdditionalCharge,
): Promise<{ amount: number; currency: string } | null> {
if (charge.currency !== 'ETB' && charge.currency !== 'USD') return null;
const target = charge.currency === 'ETB' ? 'USD' : 'ETB';
try {
const amount = await this.exchangeService.convert(Number(charge.amount), charge.currency, target);
return { amount: Math.round(amount * 100) / 100, currency: target };
} catch (err) {
this.logger.warn(`Rate conversion failed for charge ${charge.id}: ${(err as Error).message}`);
return null;
}
}
}

View File

@@ -14,9 +14,11 @@ import { Invoice } from '../billing/entities/invoice.entity';
import { FilesService } from '../files/files.service';
import { BookingsService } from './bookings.service';
import { BookingsRepository } from './bookings.repository';
import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service';
import { Booking } from './entities/booking.entity';
import {
BookingClearanceCharge,
ClearanceChargeStatus,
ClearanceChargeType,
} from './entities/booking-clearance-charge.entity';
import { ClearanceEventService } from './clearance-event.service';
@@ -32,13 +34,22 @@ const CHARGE_LABEL: Record<ClearanceChargeType, string> = {
MISCELLANEOUS: 'Miscellaneous charges',
};
/** Statuses the customer sees — drafts (DOC_UPLOADED / BILLED) stay GL-internal. */
export const CUSTOMER_VISIBLE_CHARGE_STATUSES: ReadonlySet<ClearanceChargeStatus> =
new Set(['SENT', 'REJECTED', 'ACCEPTED', 'PAID']);
/** Once the customer has accepted (invoice issued) or paid, GL cannot touch the charge. */
export const canStaffEditCharge = (status: ClearanceChargeStatus): boolean =>
status !== 'ACCEPTED' && status !== 'PAID';
/**
* Post-finalization clearance charges billed to the customer. Two levels per
* booking: GL Djibouti uploads the port-charges document; GL Ethiopia bills it
* (amount + currency) and sends the invoice; once that invoice is paid GL
* Ethiopia may create and send the miscellaneous charge. ETB invoices are paid
* through the portal gateway, other currencies through Finance's manual
* settlement worklist — both settle via `clearance_charge.invoice.paid`.
* Post-finalization clearance charges billed to the customer: one port charge
* (document from GL Djibouti, priced by GL Ethiopia) and any number of
* miscellaneous charges. GL prices + describes a charge and SENDs it; the
* customer REJECTs with a note (GL revises, re-sends) or ACCEPTs, which issues
* the payable invoice and locks the charge. ETB invoices are paid through the
* portal gateway, other currencies through Finance's manual settlement
* worklist — both settle via `clearance_charge.invoice.paid`.
*/
@Injectable()
export class BookingClearanceChargeService {
@@ -51,6 +62,7 @@ export class BookingClearanceChargeService {
private readonly bookingsService: BookingsService,
private readonly bookingsRepository: BookingsRepository,
private readonly clearanceEvents: ClearanceEventService,
private readonly notifier: BookingLifecycleNotifierService,
) {}
private repo() {
@@ -104,6 +116,11 @@ export class BookingClearanceChargeService {
file: file ? { id: file.id, name: file.name, url: file.url } : null,
amount: c.amount != null ? Number(c.amount) : null,
currency: c.currency ?? null,
description: c.description ?? null,
customerNote: c.customerNote ?? null,
customerDecidedAt: c.customerDecidedAt
? c.customerDecidedAt.toISOString()
: null,
invoiceId: c.invoiceId ?? null,
invoiceNumber: c.invoiceId
? (invoiceById.get(c.invoiceId)?.invoiceNumber ?? null)
@@ -121,6 +138,24 @@ export class BookingClearanceChargeService {
});
}
/** The customer's view: only charges GL has sent them. */
async listForCustomer(bookingId: string): Promise<Freight.ClearanceCharge[]> {
return (await this.list(bookingId)).filter((c) =>
CUSTOMER_VISIBLE_CHARGE_STATUSES.has(c.status),
);
}
private async findCharge(
bookingId: string,
chargeId: string,
): Promise<BookingClearanceCharge> {
const charge = await this.repo().findOne({
where: { id: chargeId, bookingId },
});
if (!charge) throw new NotFoundException('Clearance charge not found');
return charge;
}
/** GL Djibouti uploads (or replaces, until billed) the port-charges document. */
async uploadPortDocument(
bookingId: string,
@@ -180,22 +215,21 @@ export class BookingClearanceChargeService {
}
/**
* GL Ethiopia sets (or, on the customer's request, revises) amount +
* currency. Revising a SENT charge cancels its unpaid invoice; a PAID charge
* is immutable.
* GL Ethiopia sets (or, after a customer rejection, revises) amount +
* currency + description. Allowed until the customer accepts: an ACCEPTED
* charge already carries an invoice and a PAID one is settled.
*/
async billCharge(
bookingId: string,
chargeId: string,
input: { amount: number; currency: string },
input: { amount: number; currency: string; description?: string },
staffId: string,
): Promise<Freight.ClearanceCharge[]> {
const charge = await this.repo().findOne({
where: { id: chargeId, bookingId },
});
if (!charge) throw new NotFoundException('Clearance charge not found');
if (charge.status === 'PAID') {
throw new ConflictException('A paid charge can no longer be changed.');
const charge = await this.findCharge(bookingId, chargeId);
if (!canStaffEditCharge(charge.status)) {
throw new ConflictException(
'The customer has accepted this charge — it can no longer be changed.',
);
}
if (!(input.amount > 0)) {
throw new BadRequestException('Amount must be greater than zero.');
@@ -203,53 +237,117 @@ export class BookingClearanceChargeService {
if (!input.currency?.trim()) {
throw new BadRequestException('Currency is required.');
}
if (charge.status === 'SENT' && charge.invoiceId) {
await this.billing.cancelInvoice(charge.invoiceId);
const description = (input.description ?? charge.description ?? '').trim();
if (charge.type === 'MISCELLANEOUS' && !description) {
throw new BadRequestException('Describe what this charge is for.');
}
const currency = input.currency.trim().toUpperCase();
const revised = charge.status === 'SENT' || charge.status === 'REJECTED';
// Back to draft: the customer's previous decision no longer applies.
await this.repo().update(charge.id, {
amount: input.amount.toFixed(2),
currency: input.currency.trim().toUpperCase(),
currency,
description: description || null,
status: 'BILLED',
invoiceId: null,
customerNote: null,
customerDecidedAt: null,
customerDecidedBy: null,
billedByStaffId: staffId,
billedAt: new Date(),
});
await this.clearanceEvents.record({
bookingId,
action: 'CHARGE_BILLED',
label: `${charge.status === 'SENT' ? 'Revised' : 'Billed'} ${CHARGE_LABEL[
label: `${revised ? 'Revised' : 'Billed'} ${CHARGE_LABEL[
charge.type
].toLowerCase()}: ${input.amount} ${input.currency.trim().toUpperCase()}`,
].toLowerCase()}: ${input.amount} ${currency}${
description ? `${description}` : ''
}`,
actorId: staffId,
metadata: {
chargeType: charge.type,
amount: input.amount,
currency: input.currency.trim().toUpperCase(),
revised: charge.status === 'SENT',
currency,
description: description || null,
revised,
},
});
return this.list(bookingId);
}
/** GL Ethiopia issues the payable invoice to the customer. */
/**
* GL Ethiopia proposes the priced charge to the customer. No invoice yet —
* that is issued when the customer accepts. Re-sending after a rejection
* goes through here too.
*/
async sendCharge(
bookingId: string,
chargeId: string,
staffId?: string,
staffId: string,
): Promise<Freight.ClearanceCharge[]> {
const charge = await this.repo().findOne({
where: { id: chargeId, bookingId },
});
if (!charge) throw new NotFoundException('Clearance charge not found');
if (charge.status !== 'BILLED') {
const charge = await this.findCharge(bookingId, chargeId);
if (charge.status !== 'BILLED' && charge.status !== 'REJECTED') {
throw new ConflictException(
'Set the amount and currency before sending the charge to the customer.',
charge.status === 'DOC_UPLOADED'
? 'Set the amount and currency before sending the charge to the customer.'
: 'This charge has already been sent to the customer.',
);
}
const revised = charge.status === 'REJECTED';
const amount = Number(charge.amount);
const currency = charge.currency ?? 'ETB';
await this.repo().update(charge.id, {
status: 'SENT',
customerNote: null,
customerDecidedAt: null,
customerDecidedBy: null,
});
await this.clearanceEvents.record({
bookingId,
action: 'CHARGE_SENT',
label: `${revised ? 'Re-sent' : 'Sent'} ${CHARGE_LABEL[
charge.type
].toLowerCase()} to the customer for approval: ${amount} ${currency}`,
actorId: staffId ?? null,
metadata: {
chargeType: charge.type,
amount,
currency,
description: charge.description ?? null,
revised,
},
});
const booking = await this.bookingsService.findById(bookingId);
this.notifier.clearanceChargeProposed(booking, {
label: CHARGE_LABEL[charge.type],
amount,
currency,
description: charge.description ?? null,
revised,
});
return this.list(bookingId);
}
/** Customer agrees to the price: the payable invoice is issued and the charge locks. */
async customerAccept(
bookingId: string,
chargeId: string,
userId: string,
): Promise<Freight.ClearanceCharge[]> {
const booking = await this.bookingsService.findById(bookingId);
await this.bookingsService.assertCustomerCanAccessBooking(userId, booking);
const charge = await this.findCharge(bookingId, chargeId);
if (charge.status !== 'SENT' && charge.status !== 'REJECTED') {
throw new ConflictException(
charge.status === 'ACCEPTED' || charge.status === 'PAID'
? 'This charge has already been accepted.'
: 'This charge is not awaiting your decision.',
);
}
const amount = Number(charge.amount);
const currency = charge.currency ?? 'ETB';
const invoice = await this.billing.generateInvoice({
source: Freight.InvoiceSource.ClearanceCharge,
// The charge's own id, NOT the booking id — booking-scoped invoice
@@ -258,105 +356,156 @@ export class BookingClearanceChargeService {
type: charge.type,
companyId: booking.companyId,
companyProfileId: booking.companyProfileId,
currency: charge.currency ?? 'ETB',
currency,
lines: [
{
chargeType: charge.type,
description: `${CHARGE_LABEL[charge.type]}${booking.reference ?? bookingId}`,
amount: Number(charge.amount),
description: `${CHARGE_LABEL[charge.type]}${
booking.reference ?? bookingId
}${charge.description ? `: ${charge.description}` : ''}`,
amount,
},
],
});
await this.repo().update(charge.id, {
status: 'SENT',
status: 'ACCEPTED',
invoiceId: invoice.id,
customerNote: null,
customerDecidedAt: new Date(),
customerDecidedBy: userId,
});
await this.clearanceEvents.record({
bookingId,
action: 'CHARGE_INVOICE_SENT',
label: `Sent ${CHARGE_LABEL[charge.type].toLowerCase()} invoice ${invoice.invoiceNumber} to the customer`,
actorId: staffId ?? null,
action: 'CHARGE_ACCEPTED',
label: `Customer accepted ${CHARGE_LABEL[
charge.type
].toLowerCase()} (${amount} ${currency}) — invoice ${invoice.invoiceNumber} issued`,
actorType: 'CUSTOMER',
actorId: userId,
metadata: {
chargeType: charge.type,
invoiceNumber: invoice.invoiceNumber,
amount: Number(charge.amount),
currency: charge.currency,
amount,
currency,
},
});
this.notifier.clearanceChargeInvoiceIssued(booking, {
label: CHARGE_LABEL[charge.type],
amount,
currency,
invoiceNumber: invoice.invoiceNumber,
});
this.logger.log(
`Clearance charge ${charge.type} on booking ${bookingId} sent as invoice ${invoice.invoiceNumber}`,
`Clearance charge ${charge.type} on booking ${bookingId} accepted; invoice ${invoice.invoiceNumber}`,
);
return this.list(bookingId);
return this.listForCustomer(bookingId);
}
/** Customer declines the price with a reason; GL revises and re-sends. */
async customerReject(
bookingId: string,
chargeId: string,
note: string,
userId: string,
): Promise<Freight.ClearanceCharge[]> {
const booking = await this.bookingsService.findById(bookingId);
await this.bookingsService.assertCustomerCanAccessBooking(userId, booking);
const charge = await this.findCharge(bookingId, chargeId);
if (charge.status !== 'SENT') {
throw new ConflictException(
charge.status === 'ACCEPTED' || charge.status === 'PAID'
? 'This charge has already been accepted.'
: 'This charge is not awaiting your decision.',
);
}
if (!note?.trim()) {
throw new BadRequestException('Say why you are rejecting this charge.');
}
await this.repo().update(charge.id, {
status: 'REJECTED',
customerNote: note.trim(),
customerDecidedAt: new Date(),
customerDecidedBy: userId,
});
await this.clearanceEvents.record({
bookingId,
action: 'CHARGE_REJECTED',
label: `Customer rejected ${CHARGE_LABEL[charge.type].toLowerCase()}: ${note.trim()}`,
actorType: 'CUSTOMER',
actorId: userId,
metadata: { chargeType: charge.type, note: note.trim() },
});
this.notifier.clearanceChargeRejectedToStaff(booking, {
label: CHARGE_LABEL[charge.type],
note: note.trim(),
});
return this.listForCustomer(bookingId);
}
/**
* GL Ethiopia creates the miscellaneous charge whole (document + amount +
* currency). Second payment level: allowed only once the port charge is paid.
* GL Ethiopia creates a miscellaneous charge whole (document + amount +
* currency + what it is for). Lands as a BILLED draft; GL sends it next.
*/
async createMiscellaneous(
bookingId: string,
file: Express.Multer.File,
input: { amount: number; currency: string },
input: { amount: number; currency: string; description?: string },
staffId: string,
): Promise<Freight.ClearanceCharge[]> {
const booking = await this.bookingsService.findById(bookingId);
this.assertClearanceFinalized(booking);
const port = await this.repo().findOne({
where: { bookingId, type: 'PORT_CHARGES' },
});
if (port?.status !== 'PAID') {
throw new ConflictException(
'Miscellaneous charges open after the port charge is paid.',
);
}
const existing = await this.repo().findOne({
where: { bookingId, type: 'MISCELLANEOUS' },
});
if (existing) {
throw new ConflictException(
'This booking already has a miscellaneous charge — revise it instead.',
);
}
// No ordering and no cap: a miscellaneous charge may be raised before,
// after or alongside the port charge, and a booking may carry several.
if (!(input.amount > 0)) {
throw new BadRequestException('Amount must be greater than zero.');
}
if (!input.currency?.trim()) {
throw new BadRequestException('Currency is required.');
}
const description = input.description?.trim() ?? '';
if (!description) {
throw new BadRequestException('Describe what this charge is for.');
}
const record = await this.filesService.upsertByCode(
{
resourceId: bookingId,
resource: 'bookings',
code: CHARGE_FILE_CODE.MISCELLANEOUS,
file,
},
{ userId: staffId },
);
await this.repo().save(
// Save the row first so its id can key the document. A booking may carry
// several miscellaneous charges, and `upsertByCode` retires whatever sits
// under the same code — a shared code would silently delete the previous
// charge's document.
const charge = await this.repo().save(
this.repo().create({
bookingId,
type: 'MISCELLANEOUS',
status: 'BILLED',
fileRecordId: record.id,
amount: input.amount.toFixed(2),
currency: input.currency.trim().toUpperCase(),
description,
uploadedByStaffId: staffId,
uploadedAt: new Date(),
billedByStaffId: staffId,
billedAt: new Date(),
}),
);
const record = await this.filesService.upsertByCode(
{
resourceId: bookingId,
resource: 'bookings',
code: `${CHARGE_FILE_CODE.MISCELLANEOUS}_${charge.id}`,
file,
},
{ userId: staffId },
);
await this.repo().update(charge.id, { fileRecordId: record.id });
await this.clearanceEvents.record({
bookingId,
action: 'CHARGE_MISC_CREATED',
label: `Created miscellaneous charge: ${input.amount} ${input.currency.trim().toUpperCase()}`,
label: `Created miscellaneous charge: ${input.amount} ${input.currency.trim().toUpperCase()}${description}`,
actorId: staffId,
metadata: {
amount: input.amount,
currency: input.currency.trim().toUpperCase(),
description,
fileName: file.originalname,
},
});

View File

@@ -0,0 +1,22 @@
import {
CUSTOMER_VISIBLE_CHARGE_STATUSES,
canStaffEditCharge,
} from './booking-clearance-charge.service';
import { CLEARANCE_CHARGE_STATUSES } from './entities/booking-clearance-charge.entity';
describe('clearance charge status guards', () => {
it('locks the charge once the customer has accepted or paid', () => {
expect(canStaffEditCharge('ACCEPTED')).toBe(false);
expect(canStaffEditCharge('PAID')).toBe(false);
for (const s of ['DOC_UPLOADED', 'BILLED', 'SENT', 'REJECTED'] as const) {
expect(canStaffEditCharge(s)).toBe(true);
}
});
it('hides GL drafts from the customer and shows everything sent', () => {
const visible = CLEARANCE_CHARGE_STATUSES.filter((s) =>
CUSTOMER_VISIBLE_CHARGE_STATUSES.has(s),
);
expect(visible).toEqual(['SENT', 'REJECTED', 'ACCEPTED', 'PAID']);
});
});

View File

@@ -202,6 +202,17 @@ export class BookingLifecycleNotifierService {
}
/** A clearance document was queried and needs the customer to re-upload. */
/** GL asked the customer for additional clearance document(s). */
additionalDocsRequested(b: Booking, note: string): void {
const msg =
`Additional document(s) requested on booking ${b.reference}: ` +
`${note} Please upload them from the portal.`;
void this.notifyContact(b, msg, 'ADDITIONAL DOCUMENTS REQUESTED');
this.inApp(b, 'Additional documents requested', msg, {
type: NotificationType.DOCUMENT_ACTION,
});
}
documentQueried(b: Booking, fileKey: string, note: string): void {
const msg =
`A clearance document on booking ${b.reference} needs attention: "${fileKey}". ` +
@@ -410,6 +421,55 @@ export class BookingLifecycleNotifierService {
});
}
// ── Clearance charges (port + miscellaneous) ───────────────────────────────
/** GL proposed (or re-proposed) a clearance charge — the customer accepts or rejects it in the portal. */
clearanceChargeProposed(
b: Booking,
c: {
label: string;
amount: number;
currency: string;
description: string | null;
revised: boolean;
},
): void {
const msg =
`${c.revised ? 'Revised ' + c.label.toLowerCase() : c.label} of ${c.amount} ${c.currency}` +
`${c.description ? ` (${c.description})` : ''} on booking ${b.reference} ` +
`await your approval. Please accept or reject them in the portal.`;
void this.notifyContact(b, msg, c.revised ? 'CLEARANCE CHARGE REVISED' : 'CLEARANCE CHARGE SENT');
this.inApp(b, c.revised ? `${c.label} revised` : `${c.label} need your approval`, msg, {
type: NotificationType.INVOICE_ISSUED,
});
}
/** The customer accepted a clearance charge — its invoice is now payable. */
clearanceChargeInvoiceIssued(
b: Booking,
c: { label: string; amount: number; currency: string; invoiceNumber: string },
): void {
const msg =
`Invoice ${c.invoiceNumber} for ${c.label.toLowerCase()} (${c.amount} ${c.currency}) ` +
`on booking ${b.reference} is ready. Please pay it from the portal.`;
void this.notifyContact(b, msg, 'CLEARANCE CHARGE INVOICE');
this.inApp(b, `${c.label} invoice issued`, msg, {
type: NotificationType.INVOICE_ISSUED,
});
}
/** The customer rejected a clearance charge — GL Ethiopia revises and re-sends. */
clearanceChargeRejectedToStaff(b: Booking, c: { label: string; note: string }): void {
const msg =
`The customer rejected the ${c.label.toLowerCase()} on booking ${this.ref(b)}: ` +
`"${c.note}". Revise and re-send from the clearance page.`;
this.inAppStaff(b, `${c.label} rejected — ${this.ref(b)}`, msg, {
recipients: CLEARANCE_DESK,
type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/clearance/${b.id}`,
});
}
/** GL confirmed the final-invoice payment slip. */
finalInvoicePaid(b: Booking): void {
const msg = `Your final invoice payment for booking ${b.reference} has been confirmed. Thank you.`;

View File

@@ -0,0 +1,107 @@
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { Freight } from '@edr/types';
/** Invoice statuses a customer can still settle (mirrors the portal's PAYABLE_STATUSES). */
const PAYABLE_INVOICE_STATUSES = ['ISSUED', 'PENDING', 'PARTIALLY_PAID', 'OVERDUE'];
/** Booking statuses at which the freight invoice is actually due (mirrors BookingsService). */
const FREIGHT_PAYABLE_BOOKING_STATUSES = [
'FULLY_EXECUTED',
'SELECTED_FOR_BATCH',
'AWAITING_PAYMENT',
];
/**
* One row per outstanding item. `invoices.status` / `bookings.status` are
* Postgres enums, hence the ::text casts. `amount` is NULL for items that only need the
* customer's review (a proposed clearance charge, a draft final invoice) so
* they count but do not inflate "amount due".
*/
const SQL = `
-- Central invoices on the booking: freight (only while the booking is in a
-- payable status), wagon-cancellation fee, GL final invoice (+ its DRAFT,
-- which waits for the customer's approval).
SELECT i.source_id AS "bookingId", i.currency,
CASE WHEN i.status::text = 'DRAFT' THEN NULL ELSE i.balance_amount END AS amount
FROM freight.invoices i
JOIN freight.bookings b ON b.id::text = i.source_id AND b.deleted_at IS NULL
WHERE i.company_id = $1 AND i.deleted_at IS NULL AND i.source = 'booking'
AND (
(i.status::text = ANY($2::text[]) AND i.balance_amount > 0
AND (i.type IN ('WAGON_CANCEL_FEE', 'GL_FINAL') OR b.status::text = ANY($3::text[])))
OR (i.type = 'GL_FINAL' AND i.status::text = 'DRAFT')
)
UNION ALL
-- Accepted clearance charges whose invoice is still unpaid.
SELECT c.booking_id::text, i.currency, i.balance_amount
FROM freight.invoices i
JOIN freight.booking_clearance_charge c ON c.id::text = i.source_id AND c.deleted_at IS NULL
WHERE i.company_id = $1 AND i.deleted_at IS NULL AND i.source = 'clearance_charge'
AND i.status::text = ANY($2::text[]) AND i.balance_amount > 0
UNION ALL
-- Clearance charges waiting for the customer to accept or reject the price.
SELECT c.booking_id::text, c.currency, NULL::numeric
FROM freight.booking_clearance_charge c
JOIN freight.bookings b ON b.id = c.booking_id AND b.deleted_at IS NULL
WHERE b.company_id = $1 AND c.deleted_at IS NULL AND c.status = 'SENT'
UNION ALL
-- Duty / tax advised by customs, payment slip not uploaded yet.
SELECT m.booking_id::text, m.metadata->>'dutyCurrency',
NULLIF(m.metadata->>'dutyAmount', '')::numeric
FROM freight.clearance_milestones m
JOIN freight.bookings b ON b.id = m.booking_id AND b.deleted_at IS NULL
WHERE b.company_id = $1 AND m.deleted_at IS NULL AND m.status = 'COMPLETED'
AND (
(m.milestone_code = 'DUTY_TAXES_ADVISED' AND NOT EXISTS (
SELECT 1 FROM freight.clearance_milestones p
WHERE p.booking_id = m.booking_id AND p.milestone_code = 'DUTY_TAX_PAID'
AND p.status = 'COMPLETED' AND p.deleted_at IS NULL))
OR
(m.milestone_code = 'SECOND_DUTY_ADVISED' AND NOT EXISTS (
SELECT 1 FROM freight.clearance_milestones p
WHERE p.booking_id = m.booking_id AND p.milestone_code = 'SECOND_DUTY_PAID'
AND p.status = 'COMPLETED' AND p.deleted_at IS NULL))
)
`;
/**
* Everything a customer still has to act on, per booking, in one query. Drives
* the "Pay" badge on the home and booking-list rows; the booking's Payments tab
* composes the same items client-side from the per-booking endpoints.
*/
@Injectable()
export class BookingPayablesService {
constructor(private readonly dataSource: DataSource) {}
async summarizeForCompany(
companyId: string,
): Promise<Freight.BookingPayableSummary[]> {
const rows: Array<{
bookingId: string;
currency: string | null;
amount: string | null;
}> = await this.dataSource.query(SQL, [
companyId,
PAYABLE_INVOICE_STATUSES,
FREIGHT_PAYABLE_BOOKING_STATUSES,
]);
const byBooking = new Map<string, Freight.BookingPayableSummary>();
for (const r of rows) {
const s = byBooking.get(r.bookingId) ?? {
bookingId: r.bookingId,
count: 0,
totals: [],
};
s.count += 1;
const amount = Number(r.amount ?? 0);
if (r.currency && amount > 0) {
const t = s.totals.find((x) => x.currency === r.currency);
if (t) t.amount += amount;
else s.totals.push({ currency: r.currency, amount });
}
byBooking.set(r.bookingId, s);
}
return [...byBooking.values()];
}
}

View File

@@ -57,6 +57,7 @@ describe('BookingPricingService — domestic corridor', () => {
exchangeService as never,
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
{} as never,
{ findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never,
);
});
@@ -333,6 +334,7 @@ describe('BookingPricingService — customs clearance fee billed on the booking
: [],
}),
} as never,
{ findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never,
);
const containerBooking = (overrides: Record<string, unknown> = {}) =>
@@ -388,6 +390,30 @@ describe('BookingPricingService — customs clearance fee billed on the booking
expect(line!.amount).toBe(200);
});
it('prices an Ethiopian-customs-only service off ETHIOPIAN_CUSTOMS_CLEARANCE, not the full fee', async () => {
const ethiopianFee = {
...containerFee20,
id: 'rate-et-20',
rateType: 'ETHIOPIAN_CUSTOMS_CLEARANCE',
trigger: 'ETHIOPIAN_CUSTOMS_CLEARANCE',
rateValue: 40,
} as Rate;
// No serviceType relation on the booking (like the GL/portal shipment
// preview) — the flag must be resolved from serviceTypeId.
const service = makeService({ liveRates: [containerFee20, ethiopianFee] });
(service as unknown as { serviceTypesService: { findById: jest.Mock } }).serviceTypesService = {
findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: true }),
};
const result = await service.computePriceForBooking(
containerBooking({ serviceTypeId: 'st-et', serviceType: undefined }),
);
const line = result.lineItems.find((l) => l.code === 'ETHIOPIAN_CUSTOMS_CLEARANCE_20FT');
expect(line).toBeDefined();
expect(line!.amount).toBe(160);
expect(result.lineItems.some((l) => l.code === 'CUSTOMS_CLEARANCE_20FT')).toBe(false);
});
it('hard-blocks a container type with no fee configured (never free clearance)', async () => {
const service = makeService({ liveRates: [bulkFeePerTon] });
const result = await service.computePriceForBooking(containerBooking());
@@ -553,6 +579,7 @@ describe('BookingPricingService — bulk base freight units', () => {
wagonTypes: wagonCapacity !== undefined ? [{ capacityTons: wagonCapacity }] : [],
}),
} as never,
{ findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never,
);
// 12 machines, not 12 tonnes — a PER_ITEM commodity records its count here.
@@ -683,6 +710,7 @@ describe('BookingPricingService — PER_WAGON container freight', () => {
{ getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never,
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
{ findById: jest.fn() } as never,
{ findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never,
);
const booking = (

View File

@@ -3,6 +3,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import { RatesService } from '../rule-engine/services/rates.service';
import { ServiceTypesService } from '../rule-engine/services/service-types.service';
import { Rate } from '../rule-engine/entities/rate.entity';
import { isBulkQuantityUnit } from '../rule-engine/entities/rate-unit.util';
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
@@ -84,6 +85,7 @@ export class BookingPricingService {
private readonly exchangeService: ExchangeService,
private readonly containerValidationService: ContainerValidationService,
private readonly cargoTypesService: CargoTypesService,
private readonly serviceTypesService: ServiceTypesService,
) {}
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
@@ -1060,9 +1062,27 @@ export class BookingPricingService {
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
const convert = (usd: number): number => (isEtb ? round2(usd * usdToEtb) : usd);
// An Ethiopian-side-only customs service prices off its own rate; the
// contract froze its snapshots under the matching code prefix. Resolved by
// id when the relation isn't loaded — the GL / portal shipment previews
// price a transient booking object, and a missing relation must not
// silently quote the standard fee the created booking is then billed
// differently for.
const serviceType =
booking.serviceType ??
(booking.serviceTypeId
? await this.serviceTypesService.findById(booking.serviceTypeId).catch(() => null)
: null);
const customsType = serviceType?.includesEthiopianCustomsOnly
? 'ETHIOPIAN_CUSTOMS_CLEARANCE'
: 'CUSTOMS_CLEARANCE';
const customsLabel =
customsType === 'ETHIOPIAN_CUSTOMS_CLEARANCE'
? 'Ethiopian customs clearance service'
: 'Customs clearance service';
const onLeg = liveRates.filter(
(r) =>
r.rateType === 'CUSTOMS_CLEARANCE' &&
r.rateType === customsType &&
r.currency === 'USD' &&
r.tradeDirection === booking.tradeDirection &&
r.originYardId === booking.originYardId &&
@@ -1070,20 +1090,20 @@ export class BookingPricingService {
);
const missingRateMessage = (scope: string): string =>
`No customs clearance service fee is configured for ${scope} on this ` +
'origin → destination. Ask EDR to configure the CUSTOMS_CLEARANCE rate for this route.';
`origin → destination. Ask EDR to configure the ${customsType} rate for this route.`;
if (booking.freightType === 'CONTAINER') {
// Legacy short-circuit: an old contract froze one flat fee — bill it once.
const hasPerSizeSnapshot =
frozenRates?.has('CUSTOMS_CLEARANCE_20FT') ||
frozenRates?.has('CUSTOMS_CLEARANCE_40FT');
const legacyFlat = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency, usdToEtb);
frozenRates?.has(`${customsType}_20FT`) ||
frozenRates?.has(`${customsType}_40FT`);
const legacyFlat = this.frozenRateByCode(frozenRates, customsType, currency, usdToEtb);
if (legacyFlat && !hasPerSizeSnapshot) {
const amount = Number(legacyFlat.unitPrice);
if (amount > 0) {
lineItems.push({
code: 'CUSTOMS_CLEARANCE',
description: 'Customs clearance service',
code: customsType,
description: customsLabel,
amount,
unitAmount: amount,
unit: 'FLAT',
@@ -1106,7 +1126,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, usdToEtb)
? this.frozenRateByCode(frozenRates, `${customsType}_${sizeFt}FT`, currency, usdToEtb)
: null;
const live = onLeg.find((r) => r.containerTypeId === bc.containerTypeId);
if (!frozen && !live) {
@@ -1124,8 +1144,8 @@ export class BookingPricingService {
const amount = unit === 'FLAT' ? unitAmount : unitAmount * billedQty;
if (!(amount > 0)) continue;
lineItems.push({
code: sizeFt ? `CUSTOMS_CLEARANCE_${sizeFt}FT` : 'CUSTOMS_CLEARANCE',
description: `Customs clearance service${sizeFt ? ` (${sizeFt}ft)` : ''}`,
code: sizeFt ? `${customsType}_${sizeFt}FT` : customsType,
description: `${customsLabel}${sizeFt ? ` (${sizeFt}ft)` : ''}`,
amount,
unitAmount,
unit,
@@ -1141,7 +1161,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, usdToEtb);
const frozen = this.frozenRateByCode(frozenRates, customsType, currency, usdToEtb);
const live =
(booking.cargoTypeId
? onLeg.find(
@@ -1172,8 +1192,8 @@ export class BookingPricingService {
const amount = unit === 'FLAT' ? unitAmount : unitAmount * billedQty;
if (amount > 0) {
lineItems.push({
code: 'CUSTOMS_CLEARANCE',
description: 'Customs clearance service (bulk)',
code: customsType,
description: `${customsLabel} (bulk)`,
amount,
unitAmount,
unit,

View File

@@ -63,7 +63,7 @@ describe('BookingTransitionService — paired staff decisions', () => {
expect(result.partner.id).toBe('b-2');
});
it('cancels both halves with the same reason', async () => {
it('cancels via cancel() once — its pair cascade settles the partner', async () => {
const { service } = makeService(paired);
const cancel = jest
.spyOn(service, 'cancel')
@@ -73,21 +73,23 @@ describe('BookingTransitionService — paired staff decisions', () => {
reason: 'customer withdrew',
});
expect(cancel).toHaveBeenNthCalledWith(1, 'b-1', 'customer withdrew');
expect(cancel).toHaveBeenNthCalledWith(2, 'b-2', 'customer withdrew');
expect(cancel).toHaveBeenCalledTimes(1);
expect(cancel).toHaveBeenCalledWith('b-1', 'customer withdrew');
});
it('propagates a failure on the second half so neither is committed', async () => {
const { service, dataSource } = makeService(paired);
jest
.spyOn(service, 'cancel')
.spyOn(service, 'acceptIntake')
.mockImplementationOnce(async (id) => ({ id }) as Booking)
.mockImplementationOnce(async () => {
throw new Error('partner is already in transit');
});
await expect(
service.applyPairedDecision('b-1', 'cancel', 'staff-1', { reason: 'x' }),
service.applyPairedDecision('b-1', 'accept', 'staff-1', {
validityDays: 30,
}),
).rejects.toThrow('partner is already in transit');
// Both halves ran inside one transaction, so the throw rolls the first back.

View File

@@ -28,6 +28,7 @@ import { ContainerValidationService } from './container-validation.service';
import { BookingsRepository } from './bookings.repository';
import { assertBookingStatus } from './booking-status.util';
import {
adHocLabel,
clearanceCodesForBooking,
clearanceDocumentsOpen,
} from './clearance.util';
@@ -90,28 +91,10 @@ export class BookingTransitionService {
/** Reject submit when the booking's 20ft containers can't be balanced onto wagons. */
private async assert20ftPairable(booking: Booking): Promise<void> {
// Parity gate. 20ft ride two per wagon, so an odd total leaves one container
// that cannot be placed. Consolidation (pairing it with another customer's
// odd booking) is built end to end but switched off for now, so an odd total
// is rejected here rather than parked for a partner.
// containerSize is not always populated (some rows carry only the container
// type), so fall back to the type's sizeFt rather than silently skipping
// those lines and letting an odd booking through.
const ft20Quantity = (booking.bookingContainers ?? [])
.filter((bc) =>
bc.containerSize
? bc.containerSize.includes("20")
: Number(bc.containerType?.sizeFt) === 20,
)
.reduce((sum, bc) => sum + Number(bc.quantity || 0), 0);
if (ft20Quantity % 2 === 1) {
throw new BadRequestException(
`20ft containers travel two per wagon, so they must be booked in even ` +
`numbers. This booking has ${ft20Quantity} — add one more or remove ` +
`one (book ${ft20Quantity + 1} or ${ft20Quantity - 1}).`,
);
}
// Odd 20ft totals are not rejected here: runConsolidationOnSubmit (called
// right after this gate) auto-pairs the odd leftover with another
// customer's odd booking or parks the booking as PENDING_CONSOLIDATION.
// Only the weight-pairing rule hard-blocks.
const violations =
await this.containerValidationService.validate20ftPairing(booking);
if (violations.length) {
@@ -455,11 +438,44 @@ export class BookingTransitionService {
async cancelHold(bookingId: string, reason?: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ["SELECTED_FOR_BATCH"]);
if (booking.consolidationPartnerId) {
throw new BadRequestException(
"This booking shares a consolidated wagon with another booking — " +
"contact support to cancel it.",
// Consolidated pair: the shared wagon dies with this hold. An unpaid
// partner's hold is released with it (both cancel, no fee); a PAID partner
// cannot board alone, so the partnerLapsed listener cancels it too, with
// the cancellation fee — this unpaid canceller owes nothing (fees only
// apply to paid bookings).
const partnerId = booking.consolidationPartnerId;
if (partnerId) {
const partner = await this.bookingsService.findById(partnerId);
const partnerPaid =
partner.paymentStatus === "PAID" || partner.status === "PAID";
await this.bookingsRepository.clearConsolidationPair(
booking.id,
partnerId,
);
if (partnerPaid) {
this.events.emit("booking.consolidation.partnerLapsed", {
paidBookingId: partnerId,
});
} else if (!["CANCELLED", "EXPIRED"].includes(partner.status)) {
const partnerReason = "Cancelled with its consolidation partner";
await this.bookingsRepository.createReviewNote(
partnerId,
partnerReason,
"REJECTION",
);
if (partner.status === "SELECTED_FOR_BATCH") {
await this.bookingBatchService.cancelReservation(partnerId);
} else {
await this.invoiceService.expireOpenInvoices(partnerId);
await this.bookingsRepository.update(partnerId, {
status: "CANCELLED",
} as never);
}
this.notifier.cancelled(
await this.bookingsService.findById(partnerId),
partnerReason,
);
}
}
await this.bookingsRepository.createReviewNote(
bookingId,
@@ -513,6 +529,17 @@ export class BookingTransitionService {
);
}
// cancel() carries its own pair cascade (it settles the partner too), so
// running it twice would trip on the already-cancelled partner.
if (decision === "cancel") {
const own = await this.cancel(
bookingId,
options.reason ?? "Cancelled with its consolidation partner",
);
const other = await this.bookingsService.findById(partnerId);
return { booking: own, partner: other };
}
const runOne = async (id: string): Promise<Booking> => {
switch (decision) {
case "accept":
@@ -524,11 +551,6 @@ export class BookingTransitionService {
);
}
return this.acceptIntake(id, actorId, Number(options.validityDays));
case "cancel":
return this.cancel(
id,
options.reason ?? "Cancelled with its consolidation partner",
);
case "operationAccept":
return this.reviewOperationRequest(id, "ACCEPT", actorId, {
note: options.note,
@@ -565,8 +587,54 @@ export class BookingTransitionService {
"PENDING_APPROVAL",
"CONTRACT_READY",
"OPERATION_REQUEST_PENDING",
// A booking parked waiting for a consolidation partner can be walked
// away from — nothing is reserved yet.
"PENDING_CONSOLIDATION",
]);
// Consolidated pair: a shared wagon never ships half-full, so cancelling
// one half settles the other too. Neither paid → both cancel, no fee. A
// PAID partner cannot board alone, so the partnerLapsed listener cancels
// it too, with the cancellation fee — the unpaid canceller owes nothing
// (fees only apply to paid bookings). A PAID booking itself never comes
// through here (status gate above) — it cancels via wagon cancellation,
// where the fee machinery lives.
const partnerId = booking.consolidationPartnerId;
if (partnerId) {
const partner = await this.bookingsService.findById(partnerId);
const partnerPaid =
partner.paymentStatus === "PAID" || partner.status === "PAID";
await this.bookingsRepository.clearConsolidationPair(
booking.id,
partnerId,
);
if (partnerPaid) {
this.events.emit("booking.consolidation.partnerLapsed", {
paidBookingId: partnerId,
});
} else if (!["CANCELLED", "EXPIRED"].includes(partner.status)) {
const partnerReason = "Cancelled with its consolidation partner";
await this.bookingsRepository.createReviewNote(
partnerId,
partnerReason,
"REJECTION",
);
await this.invoiceService.expireOpenInvoices(partnerId);
if (partner.status === "SELECTED_FOR_BATCH") {
// Reserved hold: release the wagons through the batch engine.
await this.bookingBatchService.cancelReservation(partnerId);
} else {
await this.bookingsRepository.update(partnerId, {
status: "CANCELLED",
} as never);
}
this.notifier.cancelled(
await this.bookingsService.findById(partnerId),
partnerReason,
);
}
}
await this.bookingsRepository.createReviewNote(
bookingId,
reason,
@@ -643,6 +711,12 @@ export class BookingTransitionService {
}>;
allApproved: boolean;
documentsOpen: boolean;
docRequests: Array<{
id: string;
note: string;
byName: string | null;
at: string;
}>;
phase?: string | null;
milestones?: unknown[];
nextAction?: unknown;
@@ -674,9 +748,14 @@ export class BookingTransitionService {
bookingId,
"CHANGES_REQUESTED",
);
const docRequestNotes = await this.bookingsRepository.findReviewNotes(
bookingId,
"ADDITIONAL_DOC_REQUEST",
);
const reviewerNames = await this.bookingsRepository.resolveStaffNames([
...reviews.map((r) => r.reviewedByStaffId),
...queryNotes.map((n) => n.authorId),
...docRequestNotes.map((n) => n.authorId),
]);
const documents: Awaited<
@@ -731,7 +810,9 @@ export class BookingTransitionService {
const review = reviewByKey.get(`custom:${f.code}`) ?? null;
documents.push({
fileKey: f.code,
label: f.name,
// What the customer called it, falling back to the filename for rows
// uploaded before the name was carried through.
label: f.title || adHocLabel(f.code) || f.name,
required: false,
uploadedBy: "customer",
settingCode: "custom",
@@ -763,9 +844,51 @@ export class BookingTransitionService {
documents,
allApproved,
documentsOpen: clearanceDocumentsOpen(booking),
docRequests: docRequestNotes.map((n) => ({
id: n.id,
note: n.note,
byName: n.authorId ? (reviewerNames.get(n.authorId) ?? null) : null,
at: n.createdAt.toISOString(),
})),
};
}
/**
* GL asks the customer for additional clearance document(s). Stored as a
* review-note thread shown on both the GL clearance page and the customer's
* portal; the customer answers with an ad-hoc upload. Allowed for as long as
* documents are open (until the shipment is paid).
*/
async requestAdditionalDocuments(
bookingId: string,
note: string,
staffId: string,
): Promise<void> {
const booking = await this.bookingsService.findById(bookingId);
if (!clearanceDocumentsOpen(booking)) {
throw new ConflictException(
`Clearance documents are closed for this booking (status "${booking.status}").`,
);
}
if (!note?.trim()) {
throw new BadRequestException("Describe the document(s) you need.");
}
await this.bookingsRepository.createReviewNote(
bookingId,
note.trim(),
"ADDITIONAL_DOC_REQUEST",
staffId,
);
await this.clearanceEvents.record({
bookingId,
action: "ADDITIONAL_DOCS_REQUESTED",
label: "Requested additional document(s) from the customer",
actorId: staffId,
metadata: { note: note.trim() },
});
this.notifier.additionalDocsRequested(booking, note.trim());
}
/**
* True when every REQUIRED field of the booking's customer-input clearance set
* has an APPROVED review row. The 100% gate before clearance can be finalized.
@@ -837,6 +960,10 @@ export class BookingTransitionService {
resource: "bookings",
code: file.fieldname,
file,
// Ad-hoc uploads carry the name the customer typed (fieldname
// `custom_<label>_<n>`); it is what GL sees in the review grid instead
// of a raw filename like "scan_003.pdf".
title: adHocLabel(file.fieldname),
});
// Ad-hoc docs (custom_*) are not part of the required gate; still tracked.
const settingCode = file.fieldname.startsWith("custom_")

View File

@@ -7,6 +7,7 @@ import {
Logger,
NotFoundException,
} from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { ExchangeService } from '@edr/api-common';
import { Freight, NotificationAudience, NotificationType } from '@edr/types';
import { DataSource, EntityManager, In, IsNull } from 'typeorm';
@@ -35,6 +36,7 @@ import {
import { BookingsRepository } from './bookings.repository';
import {
RebookCancelledWagonsDto,
RebookContainerLineDto,
RequestWagonCancellationDto,
} from './dto/wagon-cancellation.dto';
import { Booking } from './entities/booking.entity';
@@ -44,8 +46,11 @@ import {
BookingWagonCancellation,
CancelledQuantities,
CancelledUnitSnapshot,
WAGON_CANCEL_FEE_INVOICE_TYPE,
} from './entities/booking-wagon-cancellation.entity';
export { WAGON_CANCEL_FEE_INVOICE_TYPE };
/**
* rates.rate_type of the cancellation fee — an existing rate-engine type
* (trigger CANCELLATION, never auto-applied to booking pricing). Staff
@@ -58,8 +63,6 @@ export const WAGON_CANCELLATION_FEE_RATE_TYPE = 'CANCELLATION_FEE';
/** `booking_container.container_size` is stored as "20ft"/"40ft" — `Number()` on it is NaN. */
const sizeFtOf = (size: string | number | null | undefined): number =>
parseInt(String(size ?? ''), 10);
/** invoices.type of the fee invoice — the settlement branch key in BookingInvoiceService. */
export const WAGON_CANCEL_FEE_INVOICE_TYPE = 'WAGON_CANCEL_FEE';
const round2 = (n: number): number => Math.round(n * 100) / 100;
const round3 = (n: number): number => Math.round(n * 1000) / 1000;
@@ -140,7 +143,29 @@ export class BookingWagonCancellationService {
creditAmount: number;
}> {
const booking = await this.loadCancellableBooking(bookingId);
const cut = await this.resolveRequestedCut(booking, dto);
// Empty dto = the whole booking ("Cancel booking" button).
const cut = this.isEmptyCut(dto)
? await this.resolveFullCut(booking)
: await this.resolveRequestedCut(booking, dto);
// Consolidated booking: preview the same rules the request enforces — a
// full cut breaks the pair (canceller fee = ceil of its fractional
// wagons); a partial cut must spare the shared wagon.
if (booking.consolidationPartnerId) {
const full = await this.resolveFullCut(booking);
if (cut.wagons >= full.wagons) {
const feeWagons = Math.ceil(cut.wagons);
const fee = await this.priceFee(booking, { ...cut, wagons: feeWagons });
return {
wagons: cut.wagons,
weightTons: cut.weightTons,
feePerWagon: fee.perWagon,
feeAmount: fee.amount,
feeCurrency: fee.currency,
creditAmount: this.creditFor(booking, Number(booking.wagonsRequired ?? 0)),
};
}
this.assertCutSparesSharedWagon(cut);
}
const fee = await this.priceFee(booking, cut);
return {
wagons: cut.wagons,
@@ -158,6 +183,24 @@ export class BookingWagonCancellationService {
userId?: string,
): Promise<BookingWagonCancellation> {
const booking = await this.loadCancellableBooking(bookingId);
// Consolidated booking: the shared wagon itself is untouchable — its other
// half belongs to the partner. The customer may still cancel
// - the WHOLE booking (breaks the pair: both cancel, ceil/floor fees), or
// - a PARTIAL cut of their own full wagons — an EVEN number of 20ft
// containers, so the odd one stays on the shared wagon and the pair
// survives untouched.
if (booking.consolidationPartnerId) {
if (this.isEmptyCut(dto)) {
return this.cancelConsolidatedPair(booking, dto.reason ?? null, userId);
}
const full = await this.resolveFullCut(booking);
const cut = await this.resolveRequestedCut(booking, dto);
if (cut.wagons >= full.wagons) {
return this.cancelConsolidatedPair(booking, dto.reason ?? null, userId);
}
this.assertCutSparesSharedWagon(cut);
// fall through: a pair-safe partial cut rides the normal partial flow.
}
const open = await this.repo.findOpenForBooking(bookingId);
if (open) {
throw new ConflictException(
@@ -165,7 +208,10 @@ export class BookingWagonCancellationService {
);
}
const cut = await this.resolveRequestedCut(booking, dto);
// Empty dto = the whole booking ("Cancel booking" button).
const cut = this.isEmptyCut(dto)
? await this.resolveFullCut(booking)
: await this.resolveRequestedCut(booking, dto);
const fee = await this.priceFee(booking, cut);
const feeAmount = fee.amount;
const creditAmount = this.creditFor(booking, cut.wagons);
@@ -280,6 +326,267 @@ export class BookingWagonCancellationService {
return (await this.repo.update(row.id, { status: 'WITHDRAWN' }))!;
}
// ── Consolidated-pair cancellation ──────────────────────────────────────────
/**
* Cancel BOTH halves of a consolidated pair — a shared wagon never ships
* half-full, so a paired booking always cancels whole, together with its
* partner.
*
* Fee split (the canceller's leftover 20ft claims the shared wagon):
* canceller pays ceil(its wagons), the partner floor(its wagons) — e.g.
* 11 + 13 × 20ft = 12 wagons → canceller 7, partner 5, total 12. A PAID side
* keeps its full freight as a rebooking credit (rebooked by GL through the
* normal rebook endpoint once its fee settles); an UNPAID partner is
* cancelled with no fee and no credit.
*/
private async cancelConsolidatedPair(
booking: Booking,
reason: string | null,
userId?: string,
): Promise<BookingWagonCancellation> {
const partnerId = booking.consolidationPartnerId!;
const partner = await this.bookingsRepository.findById(partnerId);
if (!partner) {
throw new NotFoundException(`Partner booking ${partnerId} not found.`);
}
const partnerPaid =
partner.paymentStatus === 'PAID' || partner.status === 'PAID';
// Break the link first — every write below treats each side singly.
await this.bookingsRepository.clearConsolidationPair(booking.id, partnerId);
const row = await this.openConsolidationBreak(
booking,
'ceil',
this.creditFor(booking, Number(booking.wagonsRequired ?? 0)),
reason ?? 'Consolidated pair cancelled',
userId,
);
if (partnerPaid) {
await this.openConsolidationBreak(
partner,
'floor',
this.creditFor(partner, Number(partner.wagonsRequired ?? 0)),
`Cancelled with its consolidation partner ${booking.reference}`,
userId,
);
} else {
// Unpaid partner: no fee — just make sure no payable invoice stays open.
await this.billing
.expirePayable(Freight.InvoiceSource.Booking, partner.id, 'PREPAID')
.catch(() => undefined);
}
for (const b of [booking, partner]) {
await this.dataSource.getRepository(Booking).update(b.id, {
status: 'CANCELLED',
trainScheduleId: null,
requestedTrainScheduleId: null,
});
await this.detachFromSchedule(b);
}
this.notifyCustomer(
booking,
'Consolidated booking cancelled',
`${booking.reference} shared a wagon with another booking, so both are cancelled. Your paid freight is kept as credit — pay the cancellation fee to rebook.`,
);
this.notifyCustomer(
partner,
'Consolidated booking cancelled',
partnerPaid
? `${partner.reference} shared a wagon with a booking that was cancelled, so it is cancelled too. Your paid freight is kept as credit — pay the cancellation fee to rebook.`
: `${partner.reference} shared a wagon with a booking that was cancelled, so it is cancelled too. Nothing was paid — no fee applies.`,
);
this.notifyStaff(
booking,
'Consolidated pair cancelled',
`${booking.reference} + ${partner.reference}: shared-wagon pair cancelled; cancellation fee invoice(s) issued.`,
);
return row;
}
/**
* Open one side's ledger row for a consolidation break: a FULL cut whose fee
* is priced on the ceil/floor split of the cut's own FRACTIONAL wagons —
* never booking.wagonsRequired, which the contract flow persists already
* ceiled (3 × 20ft is stored as 2, not 1.5, and floor(2) would over-charge
* the partner). E.g. 1 + 3 × 20ft: canceller ceil(0.5) = 1 wagon, partner
* floor(1.5) = 1 wagon — 2 wagons total, matching the pair's real space.
* feeWagons 0 (the floor side of a lone 20ft) skips the fee entirely — the
* row goes straight to CREDIT_AVAILABLE.
*/
private async openConsolidationBreak(
booking: Booking,
mode: 'ceil' | 'floor',
creditAmount: number,
reason: string,
userId?: string,
): Promise<BookingWagonCancellation> {
const open = await this.repo.findOpenForBooking(booking.id);
if (open) {
throw new ConflictException(
`Booking ${booking.reference} already has a cancellation awaiting its fee. Pay or withdraw it first.`,
);
}
const cut = await this.resolveFullCut(booking);
const feeWagons =
mode === 'ceil' ? Math.ceil(cut.wagons) : Math.floor(cut.wagons);
// The pair is dead the moment it breaks — the wagons leave the schedule
// with the cancel itself, so T2 must not release them again.
const quantities = { ...cut.quantities, releasedAtRequest: true };
if (feeWagons <= 0) {
return this.repo.create({
bookingId: booking.id,
wagonsCancelled: cut.wagons,
weightTons: cut.weightTons,
cancelledQuantities: quantities,
creditAmount,
feeAmount: 0,
feeCurrency: booking.paymentCurrency ?? 'ETB',
status: 'CREDIT_AVAILABLE',
feePaidAt: new Date(),
reason,
requestedByUserId: userId ?? null,
});
}
const fee = await this.priceFee(booking, { ...cut, wagons: feeWagons });
const row = await this.repo.create({
bookingId: booking.id,
wagonsCancelled: cut.wagons,
weightTons: cut.weightTons,
cancelledQuantities: quantities,
creditAmount,
feeRateId: fee.rates[0].id,
feeAmount: fee.amount,
feeCurrency: fee.currency,
status: 'FEE_PENDING',
reason,
requestedByUserId: userId ?? null,
});
const invoice = await this.billing.generateInvoice({
source: Freight.InvoiceSource.Booking,
sourceId: booking.id,
type: WAGON_CANCEL_FEE_INVOICE_TYPE,
companyId: booking.companyId,
companyProfileId: booking.companyProfileId,
currency: fee.currency,
lines: [
{
chargeType: 'CANCELLATION_FEE',
description: `Consolidation cancellation fee — ${feeWagons} wagon(s) of booking ${booking.reference}`,
quantity: feeWagons,
unitRate: fee.perWagon,
amount: fee.amount,
currency: fee.currency,
metadata: { wagonCancellationId: row.id },
},
],
totalAmount: fee.amount,
status: Freight.InvoiceStatus.Issued,
});
return (await this.repo.update(row.id, { feeInvoiceId: invoice.id })) ?? row;
}
/** No cut named at all — the "Cancel booking" button cancelling everything. */
private isEmptyCut(dto: RequestWagonCancellationDto): boolean {
return (
!dto.containers?.length && !dto.wagonAllocationIds?.length && !dto.wagons
);
}
/**
* A partial cut on a consolidated booking must leave the shared wagon whole:
* the odd 20ft riding it stays, so the cut's 20ft count must be EVEN (whole
* own wagons only). An odd cut — including picking the shared wagon itself in
* the Wagons tab (it contributes exactly one 20ft) — is rejected.
*/
private assertCutSparesSharedWagon(cut: RequestedCut): void {
const ft20Cut = Object.entries(cut.quantities.bySize ?? {})
.filter(([size]) => sizeFtOf(size) === 20)
.reduce((sum, [, qty]) => sum + qty, 0);
if (ft20Cut % 2 === 1) {
throw new BadRequestException(
'This booking shares a wagon with another booking — the shared wagon cannot be cancelled on its own. Cancel an even number of 20ft containers (your own whole wagons), or cancel the whole booking to end the consolidation.',
);
}
}
/** The whole booking as a cut — everything it still carries. */
private async resolveFullCut(booking: Booking): Promise<RequestedCut> {
if (booking.freightType === 'CONTAINER') {
const lines = await this.dataSource.getRepository(BookingContainer).find({
where: { bookingId: booking.id },
});
const bySize = new Map<string, number>();
for (const line of lines) {
const size = line.containerSize ?? '';
bySize.set(size, (bySize.get(size) ?? 0) + Number(line.quantity ?? 0));
}
const containers = [...bySize.entries()]
.filter(([, quantity]) => quantity > 0)
.map(([containerSize, quantity]) => ({ containerSize, quantity }));
return this.resolveRequestedCut(booking, {
containers,
} as RequestWagonCancellationDto);
}
return this.resolveRequestedCut(booking, {
wagons: Number(booking.wagonsRequired ?? 0),
} as RequestWagonCancellationDto);
}
/**
* A consolidation pair broke with only one side PAID: the unpaid half
* expired/cancelled fee-free (cancellation fees only ever apply to a paid
* booking), and the PAID half cannot board either — its odd 20ft has no
* partner for the shared wagon. So the PAID booking is cancelled too, owing
* the cancellation fee on ceil of its own fractional wagons (shared wagon
* included); its paid freight is kept as rebooking credit. Once the fee
* settles, GL staff rebook it through a normal new booking, where its odd
* 20ft goes through consolidation pairing again.
*/
@OnEvent('booking.consolidation.partnerLapsed')
async onConsolidationPartnerLapsed(payload: {
paidBookingId: string;
}): Promise<void> {
try {
const booking = await this.bookingsRepository.findById(
payload.paidBookingId,
);
if (!booking) return;
if (['CANCELLED', 'EXPIRED', 'COMPLETED'].includes(booking.status)) return;
if (await this.repo.findOpenForBooking(booking.id)) return; // already charged
const row = await this.openConsolidationBreak(
booking,
'ceil',
this.creditFor(booking, Number(booking.wagonsRequired ?? 0)),
'Consolidation partner lapsed unpaid — paired booking cancelled, cancellation fee applies',
);
await this.dataSource.getRepository(Booking).update(booking.id, {
status: 'CANCELLED',
trainScheduleId: null,
requestedTrainScheduleId: null,
});
await this.detachFromSchedule(booking);
this.notifyCustomer(
booking,
'Consolidated booking cancelled',
`${booking.reference} shared a wagon with a booking that was never paid, so it cannot board and is cancelled. A cancellation fee for ${Math.ceil(Number(row.wagonsCancelled))} wagon(s) has been invoiced; your paid freight is kept as credit — settle the fee and EDR staff will rebook you.`,
);
this.notifyStaff(
booking,
'Consolidation partner lapsed — paid booking cancelled',
`${booking.reference}: its consolidation partner lapsed unpaid, so the paid booking is cancelled with a cancellation fee invoice. Rebook it from its credit once the fee settles (it must pair up again).`,
);
} catch (err) {
this.logger.error(
`Consolidation-lapse cancellation failed for paid booking ${payload.paidBookingId}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
// ── T2: fee settled ─────────────────────────────────────────────────────────
/**
@@ -405,8 +712,8 @@ export class BookingWagonCancellationService {
booking,
whole ? 'Booking cancelled — credit available' : 'Wagon cancellation confirmed',
whole
? `All wagons of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook any day while your contract is valid.`
: `${row.wagonsCancelled} wagon(s) of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook any day while your contract is valid.`,
? `All wagons of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook it on any coming train day.`
: `${row.wagonsCancelled} wagon(s) of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook it on any coming train day.`,
);
}
this.logger.log(
@@ -454,22 +761,19 @@ export class BookingWagonCancellationService {
`This credit cannot be rebooked (status is ${row.status}).`,
);
}
// A consolidation-lapse row on an UNPAID booking carries no credit — the
// customer never paid freight, so there is nothing to redeem. Book fresh.
if (Number(row.creditAmount) <= 0) {
throw new BadRequestException(
'This cancellation has no rebooking credit — the booking was never paid. Create a new booking instead.',
);
}
const source = await this.bookingsRepository.findById(row.bookingId);
if (!source) throw new NotFoundException(`Booking ${row.bookingId} not found.`);
if (!source.contractId) {
throw new BadRequestException('The original booking has no contract to rebook under.');
}
// Friendly pre-check; createUnderContract re-asserts inside its own guards.
if (
source.contractValidUntil &&
new Date(source.contractValidUntil).getTime() < Date.now()
) {
throw new BadRequestException(
'Contract validity has expired — ask EDR staff to extend the contract before rebooking.',
);
}
const createDto = this.buildRebookDto(row, dto.scheduledDate);
const createDto = this.buildRebookDto(row, dto.scheduledDate, dto.containers);
// Same currency as the source booking — the credit is in it.
createDto.paymentCurrency = source.paymentCurrency ?? undefined;
const created = await this.contractBooking.createUnderContract(
@@ -479,6 +783,9 @@ export class BookingWagonCancellationService {
// System actor: carries the create-booking key so the GL gate passes on
// Path B (customs-clearance) contracts; harmless on Path A.
{ permissions: [{ key: FREIGHT_PERMS.contracts.createBooking }] },
// The freight was paid while the contract was live — the credit stays
// redeemable even after the contract's validity lapses.
{ allowExpiredContract: true },
);
const newBookingId = created.booking.id;
@@ -1162,11 +1469,25 @@ export class BookingWagonCancellationService {
private buildRebookDto(
row: BookingWagonCancellation,
scheduledDate: string,
overrides?: RebookContainerLineDto[],
): CreateBookingUnderContractDto {
const dto: CreateBookingUnderContractDto = { scheduledDate };
const q = row.cancelledQuantities;
if (q.bySize && Object.keys(q.bySize).length) {
// Unit overrides may rename containers, change seals and VGM — but the
// cancelled sizes and quantities are the contract of the credit: a size
// not on the credit, or a wrong unit count, is rejected.
const overrideBySize = new Map(
(overrides ?? []).map((o) => [o.containerSize, o.units]),
);
for (const size of overrideBySize.keys()) {
if (!(size in q.bySize)) {
throw new BadRequestException(
`The credit has no ${size} containers — sizes and quantities must match the cancelled booking.`,
);
}
}
const units = q.units ?? [];
dto.containers = Object.entries(q.bySize).map(([size, quantity]) => {
const sized = units.filter((u) => u.containerSize === size);
@@ -1175,13 +1496,23 @@ export class BookingWagonCancellationService {
`Credit is missing unit snapshots for size ${size} (${sized.length}/${quantity}) — contact EDR support.`,
);
}
const replacement = overrideBySize.get(size);
if (replacement && replacement.length !== quantity) {
throw new BadRequestException(
`The credit covers exactly ${quantity} × ${size} — you entered ${replacement.length}. Quantities cannot change on a rebook.`,
);
}
return {
containerSize: size,
quantity,
units: sized.map((u) => ({
containerNumber: u.containerNumber,
sealNumber: u.sealNumber ?? undefined,
vgmTons: u.vgmTons,
// Hazardous/reefer flags always ride from the snapshot (the cargo is
// the same cargo); number/seal/VGM come from the override when given.
units: sized.map((u, i) => ({
containerNumber: replacement?.[i]?.containerNumber ?? u.containerNumber,
sealNumber: replacement
? (replacement[i]?.sealNumber ?? undefined)
: (u.sealNumber ?? undefined),
vgmTons: replacement?.[i]?.vgmTons ?? u.vgmTons,
isHazardous: u.isHazardous,
isReefer: u.isReefer,
})),

View File

@@ -38,7 +38,11 @@ import { BookingsService } from './bookings.service';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
import { BookingDocumentReview } from './entities/booking-document-review.entity';
import { BookingClearanceCharge } from './entities/booking-clearance-charge.entity';
import { AdditionalCharge } from './entities/additional-charge.entity';
import { AdditionalChargeRepository } from './additional-charge.repository';
import { AdditionalChargeService } from './additional-charge.service';
import { BookingClearanceChargeService } from './booking-clearance-charge.service';
import { BookingPayablesService } from './booking-payables.service';
import { BookingClearanceEvent } from './entities/booking-clearance-event.entity';
import { ClearanceEventService } from './clearance-event.service';
import { BookingContainer } from './entities/booking-container.entity';
@@ -82,6 +86,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
ConsolidationApproval,
BookingClearanceCharge,
BookingClearanceEvent,
AdditionalCharge,
]),
BillingModule,
DocumentsModule,
@@ -118,7 +123,10 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
BookingContractService,
BookingInvoiceService,
BookingClearanceChargeService,
BookingPayablesService,
ClearanceEventService,
AdditionalChargeRepository,
AdditionalChargeService,
ContractTemplateResolver,
ContractViewModelBuilder,
ContractPricingScheduleBuilder,

View File

@@ -596,6 +596,21 @@ export class BookingsRepository extends BaseRepository<Booking> {
} as never);
}
/**
* Terminal un-pair: break the consolidation link only, touching neither
* status. Used when one half of a pair is cancelled/expired — the caller
* decides each side's fate ({@link unpairConsolidation} instead re-parks
* BOTH sides to PENDING_CONSOLIDATION, which is wrong for a dying booking).
*/
async clearConsolidationPair(bookingId: string, partnerId: string): Promise<void> {
await this.repository.update(bookingId, {
consolidationPartnerId: null,
} as never);
await this.repository.update(partnerId, {
consolidationPartnerId: null,
} as never);
}
/** Un-pair a consolidation. */
async unpairConsolidation(bookingId: string, partnerId: string): Promise<void> {
await this.repository.update(bookingId, {

View File

@@ -52,6 +52,7 @@ import {
FreightType,
} from './entities/booking.entity';
import { Booking } from './entities/booking.entity';
import { BookingWagonCancellation } from './entities/booking-wagon-cancellation.entity';
import { BookingContainerAllocation } from './entities/booking-container-allocation.entity';
import { FileRecord } from '../files/entities/file.entity';
import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto';
@@ -427,7 +428,8 @@ export class BookingsService {
'containerNumber', ci.container_number,
'sealNumber', ci.seal_number,
'positionOnWagon', ci.position_on_wagon,
'grossWeightTons', ci.gross_weight_tons
'grossWeightTons', ci.gross_weight_tons,
'sizeFt', cit.size_ft
) ORDER BY ci.position_on_wagon, ci.container_number
) FILTER (WHERE ci.id IS NOT NULL),
'[]'
@@ -443,6 +445,7 @@ export class BookingsService {
LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id
LEFT JOIN freight.wagon_allocation_container_items ci
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
LEFT JOIN freight.container_types cit ON cit.id = ci.container_type_id
LEFT JOIN freight.wagon_allocation_bulk_loads bl
ON bl.wagon_booking_allocation_id = a.id AND bl.deleted_at IS NULL
WHERE a.booking_id = $1 AND a.deleted_at IS NULL
@@ -2260,16 +2263,25 @@ export class BookingsService {
return this.findById(booking.id);
}
/** Upload documents for a DRAFT booking. */
/**
* Upload documents for a DRAFT booking — or for a booking created by
* rebooking a wagon-cancellation credit, whose paperwork may have changed
* with the new containers (old documents stay; new ones ride alongside).
*/
async uploadDocuments(
id: string,
files: Express.Multer.File[],
): Promise<Booking> {
const booking = await this.findById(id);
if (booking.status !== 'DRAFT') {
throw new BadRequestException(
'Documents can only be uploaded for DRAFT bookings',
);
const rebooked = await this.dataSource
.getRepository(BookingWagonCancellation)
.findOne({ where: { rebookedBookingId: id } });
if (!rebooked) {
throw new BadRequestException(
'Documents can only be uploaded for DRAFT bookings',
);
}
}
await this.filesService.uploadMany(id, 'bookings', files);
return this.findById(id);

View File

@@ -146,3 +146,20 @@ export function clearanceDocumentsOpen(booking: Booking): boolean {
if (booking.paymentStatus === 'PAID') return false;
return true;
}
/**
* The label the customer typed for an ad-hoc clearance document, recovered from
* its file code. The portal encodes it as `custom_<slug>_<n>`; a plain
* `custom_<n>` (older uploads, or an unnamed row) yields null so callers fall
* back to the filename.
*/
export function adHocLabel(fileKey: string): string | null {
const m = /^custom_(.+)_\d+$/.exec(fileKey);
if (!m) return null;
// Legacy keys are `custom_<timestamp>_<n>`, which this regex reads as a label
// of digits. Those carry no name — reject them so the caller falls back to
// the filename instead of showing "1755780000000".
if (/^\d+$/.test(m[1])) return null;
const label = m[1].replace(/-/g, ' ').trim();
return label ? label.charAt(0).toUpperCase() + label.slice(1) : null;
}

View File

@@ -1,9 +1,9 @@
import {
ConsolidationApprovalService,
CONSOLIDATION_APPROVAL_PENDING,
} from './consolidation-approval.service';
import { ConsolidationApprovalStatus } from './entities/consolidation-approval.entity';
import { Booking } from './entities/booking.entity';
} from "./consolidation-approval.service";
import { ConsolidationApprovalStatus } from "./entities/consolidation-approval.entity";
import { Booking } from "./entities/booking.entity";
/**
* The shared-wagon approval gate. Two customers' cargo on one wagon is a
@@ -14,37 +14,55 @@ import { Booking } from './entities/booking.entity';
* decision on one side of a shared wagon is meaningless without the other), and
* a decided pairing cannot be decided twice.
*/
describe('ConsolidationApprovalService', () => {
describe("ConsolidationApprovalService", () => {
const PENDING = {
id: 'ap-1',
bookingId: 'b-1',
partnerBookingId: 'b-2',
id: "ap-1",
bookingId: "b-1",
partnerBookingId: "b-2",
status: ConsolidationApprovalStatus.Pending,
requestedBy: 'gl-user',
requestedBy: "gl-user",
};
function makeService(overrides: {
approvals?: Partial<Record<string, jest.Mock>>;
bookingsRepository?: Partial<Record<string, jest.Mock>>;
} = {}) {
function makeService(
overrides: {
approvals?: Partial<Record<string, jest.Mock>>;
bookingsRepository?: Partial<Record<string, jest.Mock>>;
bookingsService?: Partial<Record<string, jest.Mock>>;
/** Contract rows the id→reference lookup should return. */
contracts?: { id: string; reference: string }[];
/** Yard ids the caller is scoped to; null = unrestricted. */
yardScope?: string[] | null;
} = {},
) {
const approvals = {
findPendingForBooking: jest.fn().mockResolvedValue(null),
findById: jest.fn().mockResolvedValue(PENDING),
create: jest.fn().mockResolvedValue({ id: 'ap-1' }),
create: jest.fn().mockResolvedValue({ id: "ap-1" }),
decide: jest.fn().mockResolvedValue(true),
findQueue: jest.fn().mockResolvedValue([]),
findQueuePage: jest.fn().mockResolvedValue({ items: [], total: 0 }),
countByStatus: jest
.fn()
.mockResolvedValue({ PENDING: 2, APPROVED: 4, REJECTED: 1 }),
findAllForBooking: jest.fn().mockResolvedValue([]),
...overrides.approvals,
};
const bookingsRepository = {
update: jest.fn().mockResolvedValue(undefined),
createReviewNote: jest.fn().mockResolvedValue(undefined),
resolveStaffNames: jest.fn().mockResolvedValue(new Map()),
...overrides.bookingsRepository,
};
const bookingsService = {
findById: jest.fn(async (id: string) =>
({ id, reference: `BK-${id}` }) as Booking,
findById: jest.fn(
async (id: string) =>
({
id,
reference: `BK-${id}`,
originYardId: "mojo",
destinationYardId: "djibouti",
}) as Booking,
),
...overrides.bookingsService,
};
const notifier = {
consolidationApprovalRequestedToStaff: jest.fn(),
@@ -52,8 +70,19 @@ describe('ConsolidationApprovalService', () => {
consolidationRejectedToStaff: jest.fn(),
operationRequestedToStaff: jest.fn(),
};
const contractRepo = {
find: jest
.fn()
.mockResolvedValue(overrides.contracts ?? []),
};
const dataSource = {
transaction: jest.fn(async (cb: () => Promise<unknown>) => cb()),
getRepository: jest.fn(() => contractRepo),
};
const yardScope = {
getScopedYardIds: jest
.fn()
.mockResolvedValue(overrides.yardScope ?? null),
};
const service = new ConsolidationApprovalService(
@@ -62,27 +91,35 @@ describe('ConsolidationApprovalService', () => {
bookingsService as never,
notifier as never,
dataSource as never,
yardScope as never,
);
return { service, approvals, bookingsRepository, notifier };
return {
service,
approvals,
bookingsRepository,
notifier,
yardScope,
contractRepo,
};
}
it('holds BOTH halves at the gate when a pairing is created', async () => {
it("holds BOTH halves at the gate when a pairing is created", async () => {
const { service, approvals, bookingsRepository, notifier } = makeService();
await service.requestApproval('b-1', 'b-2', 'gl-user');
await service.requestApproval("b-1", "b-2", "gl-user");
expect(approvals.create).toHaveBeenCalledWith(
expect.objectContaining({
bookingId: 'b-1',
partnerBookingId: 'b-2',
requestedBy: 'gl-user',
bookingId: "b-1",
partnerBookingId: "b-2",
requestedBy: "gl-user",
}),
);
// Neither half may sit in the operations queue while the wagon is unreviewed.
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
expect(bookingsRepository.update).toHaveBeenCalledWith("b-1", {
status: CONSOLIDATION_APPROVAL_PENDING,
});
expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', {
expect(bookingsRepository.update).toHaveBeenCalledWith("b-2", {
status: CONSOLIDATION_APPROVAL_PENDING,
});
expect(
@@ -90,94 +127,102 @@ describe('ConsolidationApprovalService', () => {
).toHaveBeenCalledTimes(1);
});
it('does not open a second review for a pairing already pending', async () => {
it("does not open a second review for a pairing already pending", async () => {
const { service, approvals } = makeService({
approvals: {
findPendingForBooking: jest.fn().mockResolvedValue(PENDING),
},
});
const result = await service.requestApproval('b-1', 'b-2', 'gl-user');
const result = await service.requestApproval("b-1", "b-2", "gl-user");
expect(result).toBe(PENDING);
expect(approvals.create).not.toHaveBeenCalled();
});
it('releases BOTH halves to Operations on approval, logging who decided', async () => {
it("releases BOTH halves to Operations on approval, logging who decided", async () => {
const { service, approvals, bookingsRepository, notifier } = makeService();
await service.approve('ap-1', 'approver-1', 'looks fine');
await service.approve("ap-1", "approver-1", "looks fine");
expect(approvals.decide).toHaveBeenCalledWith(
'ap-1',
"ap-1",
ConsolidationApprovalStatus.Approved,
'approver-1',
'looks fine',
"approver-1",
"looks fine",
[
ConsolidationApprovalStatus.Pending,
ConsolidationApprovalStatus.Rejected,
],
);
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
status: 'OPERATION_REQUEST_PENDING',
expect(bookingsRepository.update).toHaveBeenCalledWith("b-1", {
status: "OPERATION_REQUEST_PENDING",
});
expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', {
status: 'OPERATION_REQUEST_PENDING',
expect(bookingsRepository.update).toHaveBeenCalledWith("b-2", {
status: "OPERATION_REQUEST_PENDING",
});
// Operations only learns about the pair now — the gate is what kept it out.
expect(notifier.operationRequestedToStaff).toHaveBeenCalledTimes(2);
});
it('sends BOTH halves back to GL on rejection, with the reason on each', async () => {
it("sends BOTH halves back to GL on rejection, with the reason on each", async () => {
const { service, approvals, bookingsRepository } = makeService();
await service.reject('ap-1', 'approver-1', 'partner cargo is wrong');
await service.reject("ap-1", "approver-1", "partner cargo is wrong");
expect(approvals.decide).toHaveBeenCalledWith(
'ap-1',
"ap-1",
ConsolidationApprovalStatus.Rejected,
'approver-1',
'partner cargo is wrong',
"approver-1",
"partner cargo is wrong",
);
expect(bookingsRepository.createReviewNote).toHaveBeenCalledWith(
'b-1',
'partner cargo is wrong',
'CHANGES_REQUESTED',
"b-1",
"partner cargo is wrong",
"CHANGES_REQUESTED",
);
expect(bookingsRepository.createReviewNote).toHaveBeenCalledWith(
'b-2',
'partner cargo is wrong',
'CHANGES_REQUESTED',
"b-2",
"partner cargo is wrong",
"CHANGES_REQUESTED",
);
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
status: 'OPERATION_CHANGES_REQUESTED',
expect(bookingsRepository.update).toHaveBeenCalledWith("b-1", {
status: "OPERATION_CHANGES_REQUESTED",
});
expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', {
status: 'OPERATION_CHANGES_REQUESTED',
expect(bookingsRepository.update).toHaveBeenCalledWith("b-2", {
status: "OPERATION_CHANGES_REQUESTED",
});
});
it('lets the requester approve their own pairing', async () => {
it("lets the requester approve their own pairing", async () => {
// No maker-checker separation: the permission alone decides who may approve,
// and the audit trail still records requester and approver separately.
const { service, approvals } = makeService();
await service.approve('ap-1', 'gl-user');
await service.approve("ap-1", "gl-user");
expect(approvals.decide).toHaveBeenCalledWith(
'ap-1',
"ap-1",
ConsolidationApprovalStatus.Approved,
'gl-user',
"gl-user",
undefined,
[
ConsolidationApprovalStatus.Pending,
ConsolidationApprovalStatus.Rejected,
],
);
});
it('requires a reason to reject', async () => {
it("requires a reason to reject", async () => {
const { service, approvals } = makeService();
await expect(service.reject('ap-1', 'approver-1', ' ')).rejects.toThrow(
await expect(service.reject("ap-1", "approver-1", " ")).rejects.toThrow(
/reason is required/i,
);
expect(approvals.decide).not.toHaveBeenCalled();
});
it('refuses a pairing that was already decided', async () => {
it("refuses a pairing that was already decided", async () => {
const { service, bookingsRepository } = makeService({
approvals: {
findById: jest.fn().mockResolvedValue({
@@ -187,21 +232,250 @@ describe('ConsolidationApprovalService', () => {
},
});
await expect(service.approve('ap-1', 'approver-1')).rejects.toThrow(
await expect(service.approve("ap-1", "approver-1")).rejects.toThrow(
/already approved/i,
);
expect(bookingsRepository.update).not.toHaveBeenCalled();
});
it('loses cleanly when another approver decides the same pairing first', async () => {
it("loses cleanly when another approver decides the same pairing first", async () => {
// decide() writes only against a still-PENDING row, so the loser of the race
// affects nothing and must not move the bookings.
const { service } = makeService({
approvals: { decide: jest.fn().mockResolvedValue(false) },
});
await expect(service.approve('ap-1', 'approver-1')).rejects.toThrow(
await expect(service.approve("ap-1", "approver-1")).rejects.toThrow(
/already decided by someone else/i,
);
});
it("approves a pairing that was rejected earlier, releasing both halves", async () => {
// A rejection is not final: the reviewer may change their mind, or GL may
// argue the case. Only an already-approved pairing is closed.
const { service, bookingsRepository } = makeService({
approvals: {
findById: jest.fn().mockResolvedValue({
...PENDING,
status: ConsolidationApprovalStatus.Rejected,
decidedBy: "approver-1",
}),
},
});
await service.approve("ap-1", "approver-2", "resolved with GL");
expect(bookingsRepository.update).toHaveBeenCalledWith("b-1", {
status: "OPERATION_REQUEST_PENDING",
});
expect(bookingsRepository.update).toHaveBeenCalledWith("b-2", {
status: "OPERATION_REQUEST_PENDING",
});
});
it("refuses to reject a pairing that was already rejected", async () => {
const { service, bookingsRepository } = makeService({
approvals: {
findById: jest.fn().mockResolvedValue({
...PENDING,
status: ConsolidationApprovalStatus.Rejected,
}),
},
});
await expect(
service.reject("ap-1", "approver-1", "still wrong"),
).rejects.toThrow(/already rejected/i);
expect(bookingsRepository.update).not.toHaveBeenCalled();
});
it("names the requester and the decider on every queue row", async () => {
// The stored ids mean nothing to a reviewer reading the history.
const { service } = makeService({
approvals: {
findQueuePage: jest.fn().mockResolvedValue({
items: [
{
...PENDING,
status: ConsolidationApprovalStatus.Approved,
decidedBy: "approver-1",
},
],
total: 1,
}),
},
bookingsRepository: {
resolveStaffNames: jest.fn().mockResolvedValue(
new Map([
["gl-user", "Selam GL"],
["approver-1", "Abebe Approver"],
]),
),
},
});
const { items, meta, counts } = await service.queue({ pageSize: 10 });
expect(items[0].requestedByName).toBe("Selam GL");
expect(items[0].decidedByName).toBe("Abebe Approver");
// Badges count the whole queue, not the page that happened to load.
expect(counts.APPROVED).toBe(4);
expect(meta).toMatchObject({
page: 1,
pageSize: 10,
total: 1,
totalPages: 1,
});
});
it("pages the queue in SQL and reports the page meta", async () => {
// The page must be cut in the query, not sliced out of a full fetch —
// otherwise ordering only holds within whatever page loaded.
const findQueuePage = jest.fn().mockResolvedValue({ items: [], total: 25 });
const { service } = makeService({ approvals: { findQueuePage } });
const { meta } = await service.queue({
status: ConsolidationApprovalStatus.Rejected,
page: 2,
pageSize: 10,
});
expect(findQueuePage).toHaveBeenCalledWith({
status: ConsolidationApprovalStatus.Rejected,
page: 2,
pageSize: 10,
});
expect(meta).toMatchObject({
page: 2,
totalPages: 3,
hasNextPage: true,
hasPreviousPage: true,
});
});
it("narrows the queue and the badges to the caller's yards", async () => {
// A Mojo + Adama desk sees both yards' pairings, and nothing else. The
// badges must be narrowed too, or they promise rows the caller cannot open.
const findQueuePage = jest.fn().mockResolvedValue({ items: [], total: 0 });
const countByStatus = jest
.fn()
.mockResolvedValue({ PENDING: 1, APPROVED: 0, REJECTED: 0 });
const { service } = makeService({
approvals: { findQueuePage, countByStatus },
yardScope: ["mojo", "adama"],
});
await service.queue({ user: { id: "u-1" }, page: 1, pageSize: 10 });
expect(findQueuePage).toHaveBeenCalledWith(
expect.objectContaining({ yardIds: ["mojo", "adama"] }),
);
expect(countByStatus).toHaveBeenCalledWith(["mojo", "adama"]);
});
it("leaves the queue unnarrowed for an unrestricted caller", async () => {
// Super admin, `yards:view_all`, or a desk with no yard mapping at all —
// the mapping narrows access, it never grants it.
const findQueuePage = jest.fn().mockResolvedValue({ items: [], total: 0 });
const { service } = makeService({
approvals: { findQueuePage },
yardScope: null,
});
await service.queue({ user: { id: "u-1" } });
expect(findQueuePage).toHaveBeenCalledWith(
expect.objectContaining({ yardIds: undefined }),
);
});
it("refuses to decide a pairing outside the caller's yards", async () => {
// Hiding the row is not enough — the id is guessable from a shared link,
// and deciding moves two other yards' bookings.
const { service, bookingsRepository } = makeService({
yardScope: ["adama"],
});
await expect(
service.approve("ap-1", "approver-1", undefined, { id: "u-1" }),
).rejects.toThrow(/outside your assigned yards/i);
expect(bookingsRepository.update).not.toHaveBeenCalled();
});
it("allows a decision when only the PARTNER half touches the caller's yard", async () => {
// The pair is one decision, so seeing one side is seeing the pairing.
const { service, bookingsRepository } = makeService({
yardScope: ["dire-dawa"],
bookingsService: {
findById: jest.fn(async (id: string) =>
id === "b-2"
? ({
id,
reference: "BK-b-2",
originYardId: "djibouti",
destinationYardId: "dire-dawa",
} as Booking)
: ({
id,
reference: "BK-b-1",
originYardId: "mojo",
destinationYardId: "djibouti",
} as Booking),
),
},
});
await service.approve("ap-1", "approver-1", undefined, { id: "u-1" });
expect(bookingsRepository.update).toHaveBeenCalledWith("b-1", {
status: "OPERATION_REQUEST_PENDING",
});
});
it("attaches each half's contract reference for the queue link", async () => {
// Booking has no contract relation (contractbooking split), so the
// references are batch-loaded by id — one query for the whole page.
const { service, contractRepo } = makeService({
approvals: {
findQueuePage: jest.fn().mockResolvedValue({
items: [
{
...PENDING,
booking: { id: "b-1", contractId: "c-1" },
partnerBooking: { id: "b-2", contractId: "c-2" },
},
],
total: 1,
}),
},
contracts: [
{ id: "c-1", reference: "CT-001" },
{ id: "c-2", reference: "CT-002" },
],
});
const { items } = await service.queue();
expect(items[0].contractReference).toBe("CT-001");
expect(items[0].partnerContractReference).toBe("CT-002");
expect(contractRepo.find).toHaveBeenCalledTimes(1);
});
it("leaves the contract reference null when a half has no contract", async () => {
const { service, contractRepo } = makeService({
approvals: {
findQueuePage: jest.fn().mockResolvedValue({
items: [{ ...PENDING, booking: { id: "b-1" }, partnerBooking: null }],
total: 1,
}),
},
});
const { items } = await service.queue();
expect(items[0].contractReference).toBeNull();
expect(items[0].partnerContractReference).toBeNull();
// Nothing to look up — no query at all.
expect(contractRepo.find).not.toHaveBeenCalled();
});
});

View File

@@ -1,13 +1,14 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Inject,
Injectable,
Logger,
NotFoundException,
forwardRef,
} from "@nestjs/common";
import { DataSource } from "typeorm";
import { DataSource, In } from "typeorm";
import { Booking } from "./entities/booking.entity";
import {
@@ -18,6 +19,8 @@ import { ConsolidationApprovalsRepository } from "./consolidation-approvals.repo
import { BookingsRepository } from "./bookings.repository";
import { BookingsService } from "./bookings.service";
import { BookingLifecycleNotifierService } from "./booking-lifecycle-notifier.service";
import { YardScopeService } from "../rule-engine/services/yard-scope.service";
import { Contract } from "../contracts/entities/contract.entity";
/** Where a rejected pair goes back to, so GL can fix and resubmit. */
const REJECTED_STATUS = "OPERATION_CHANGES_REQUESTED";
@@ -25,6 +28,15 @@ const REJECTED_STATUS = "OPERATION_CHANGES_REQUESTED";
/** The gate's own holding status — neither half reaches Operations from here. */
export const CONSOLIDATION_APPROVAL_PENDING = "CONSOLIDATION_APPROVAL_PENDING";
/** An approval row with the requester's and decider's names resolved. */
export type ConsolidationApprovalView = ConsolidationApproval & {
requestedByName: string | null;
decidedByName: string | null;
/** Contract the booking half was created under — reviewers work by contract. */
contractReference: string | null;
partnerContractReference: string | null;
};
/**
* The shared-wagon approval gate.
*
@@ -53,6 +65,7 @@ export class ConsolidationApprovalService {
private readonly bookingsService: BookingsService,
private readonly notifier: BookingLifecycleNotifierService,
private readonly dataSource: DataSource,
private readonly yardScope: YardScopeService,
) {}
/**
@@ -116,8 +129,16 @@ export class ConsolidationApprovalService {
approvalId: string,
decidedBy: string,
note?: string,
user?: unknown,
): Promise<{ booking: Booking; partner: Booking }> {
const approval = await this.loadPending(approvalId);
// A pairing that was rejected can still be approved later — the reviewer
// changed their mind, or GL argued the case. Only an already-approved one
// is final, since both halves have moved on to Operations by then.
const approval = await this.loadDecidable(approvalId, [
ConsolidationApprovalStatus.Pending,
ConsolidationApprovalStatus.Rejected,
]);
await this.assertInScope(approval, user);
await this.dataSource.transaction(async () => {
const claimed = await this.approvals.decide(
@@ -125,6 +146,10 @@ export class ConsolidationApprovalService {
ConsolidationApprovalStatus.Approved,
decidedBy,
note,
[
ConsolidationApprovalStatus.Pending,
ConsolidationApprovalStatus.Rejected,
],
);
// Lost the race to another approver deciding the same pairing.
if (!claimed) {
@@ -162,13 +187,17 @@ export class ConsolidationApprovalService {
approvalId: string,
decidedBy: string,
reason: string,
user?: unknown,
): Promise<{ booking: Booking; partner: Booking }> {
if (!reason?.trim()) {
throw new BadRequestException(
"A reason is required to reject a consolidation.",
);
}
const approval = await this.loadPending(approvalId);
const approval = await this.loadDecidable(approvalId, [
ConsolidationApprovalStatus.Pending,
]);
await this.assertInScope(approval, user);
await this.dataSource.transaction(async () => {
const claimed = await this.approvals.decide(
@@ -212,9 +241,120 @@ export class ConsolidationApprovalService {
return { booking, partner };
}
/** Pending pairings awaiting a decision, oldest first. */
queue(): Promise<ConsolidationApproval[]> {
return this.approvals.findQueue();
/**
* One page of the review queue, or of its history: pending pairings first,
* then the decided ones, each carrying the display name of whoever requested
* and whoever decided it — the stored ids tell a reviewer nothing.
*
* `user` narrows the whole thing to the caller's yards: a Mojo desk sees the
* pairings that start or end at Mojo, a desk mapped to Mojo AND Adama sees
* both yards' pairings. The counts behind the tabs are narrowed the same way,
* so a badge never promises rows the caller cannot open.
*/
async queue(options?: {
status?: ConsolidationApprovalStatus;
page?: number;
pageSize?: number;
/** The `/auth/me` caller. Omit only for internal, unscoped reads. */
user?: unknown;
}): Promise<{
items: ConsolidationApprovalView[];
total: number;
/** Counts per status within the caller's scope — the tab badges. */
counts: Record<ConsolidationApprovalStatus, number>;
meta: {
page: number;
pageSize: number;
total: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
};
}> {
const page = Math.max(1, options?.page ?? 1);
const pageSize = Math.min(100, Math.max(1, options?.pageSize ?? 10));
const yardIds = await this.scopedYardIds(options?.user);
const { items: rows, total } = await this.approvals.findQueuePage({
status: options?.status,
yardIds,
page,
pageSize,
});
const counts = await this.approvals.countByStatus(yardIds);
const names = await this.bookingsRepository.resolveStaffNames(
rows.flatMap((r) => [r.requestedBy, r.decidedBy]),
);
const contractRefs = await this.contractReferences(rows);
const refOf = (contractId?: string | null) =>
contractId ? (contractRefs.get(contractId) ?? null) : null;
const items = rows.map((row) => ({
...row,
requestedByName: row.requestedBy
? (names.get(row.requestedBy) ?? null)
: null,
decidedByName: row.decidedBy ? (names.get(row.decidedBy) ?? null) : null,
contractReference: refOf(row.booking?.contractId),
partnerContractReference: refOf(row.partnerBooking?.contractId),
}));
const totalPages = Math.ceil(total / pageSize);
return {
items,
total,
counts,
meta: {
page,
pageSize,
total,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
};
}
/**
* Contract id → reference for the bookings on this page.
*
* Booking has no contract relation (contractbooking split), so the
* references are batch-loaded by id rather than joined — one query per page,
* not one per row.
*/
private async contractReferences(
rows: ConsolidationApproval[],
): Promise<Map<string, string>> {
const ids = [
...new Set(
rows
.flatMap((r) => [r.booking?.contractId, r.partnerBooking?.contractId])
.filter((id): id is string => !!id),
),
];
if (!ids.length) return new Map();
const contracts = await this.dataSource.getRepository(Contract).find({
where: { id: In(ids) },
select: { id: true, reference: true },
});
return new Map(contracts.map((c) => [c.id, c.reference]));
}
/**
* Yard ids the caller may see, or undefined for unrestricted.
*
* Scope comes from the desk they are logged in as: `yard_positions` maps a
* position to its yards, so a Mojo CEO resolves to [Mojo]. A super admin, a
* `yards:view_all` holder, and a desk with NO yard mapping all resolve to
* unrestricted — the mapping narrows access, it never grants it.
*
* Called with no user only from internal paths, which are unscoped.
*/
private async scopedYardIds(user: unknown): Promise<string[] | undefined> {
if (!user) return undefined;
const scope = await this.yardScope.getScopedYardIds(user as never);
return scope ?? undefined;
}
/** Full decision history for one booking — who decided what, and when. */
@@ -227,12 +367,46 @@ export class ConsolidationApprovalService {
return this.approvals.findPendingForBooking(bookingId);
}
private async loadPending(approvalId: string): Promise<ConsolidationApproval> {
/**
* Refuse a decision on a pairing outside the caller's yards.
*
* Hiding the row from the list is not enough on its own: the id is guessable
* from a shared link, and deciding a pairing moves two other yards' bookings.
* Same rule as the list — either half's origin or destination is enough.
*/
private async assertInScope(
approval: ConsolidationApproval,
user: unknown,
): Promise<void> {
const yardIds = await this.scopedYardIds(user);
if (!yardIds) return;
const booking = await this.bookingsService.findById(approval.bookingId);
const partner = await this.bookingsService.findById(
approval.partnerBookingId,
);
const touches = (b: Booking | null | undefined) =>
!!b &&
(yardIds.includes(b.originYardId) ||
yardIds.includes(b.destinationYardId));
if (!touches(booking) && !touches(partner)) {
throw new ForbiddenException(
"This shared wagon is outside your assigned yards.",
);
}
}
/** Load a row and refuse it unless it is in one of the decidable states. */
private async loadDecidable(
approvalId: string,
allowed: ConsolidationApprovalStatus[],
): Promise<ConsolidationApproval> {
const approval = await this.approvals.findById(approvalId);
if (!approval) {
throw new NotFoundException(`Approval ${approvalId} not found`);
}
if (approval.status !== ConsolidationApprovalStatus.Pending) {
if (!allowed.includes(approval.status)) {
throw new ConflictException(
`This consolidation was already ${approval.status.toLowerCase()}.`,
);

View File

@@ -1,11 +1,41 @@
import { Injectable } from "@nestjs/common";
import { DataSource, In, Repository } from "typeorm";
import { DataSource, In, Repository, SelectQueryBuilder } from "typeorm";
import {
ConsolidationApproval,
ConsolidationApprovalStatus,
} from "./entities/consolidation-approval.entity";
/**
* Narrow a queue query to the caller's yards.
*
* A shared wagon is visible when EITHER half of it starts or ends at one of
* those yards — the pairing is one decision, so seeing one side is seeing the
* pairing. Yards the train merely passes through do not count: only the two
* bookings' own endpoints do.
*
* `undefined` means unrestricted and adds no predicate. An EMPTY array means
* scoped-to-nothing and must match no rows — `IN ()` is not valid SQL, so it
* gets an explicit false instead of being skipped.
*/
function applyYardScope(
qb: SelectQueryBuilder<ConsolidationApproval>,
yardIds: string[] | undefined,
): void {
if (!yardIds) return;
if (!yardIds.length) {
qb.andWhere("1 = 0");
return;
}
qb.andWhere(
`(booking.originYardId IN (:...yardIds)
OR booking.destinationYardId IN (:...yardIds)
OR partnerBooking.originYardId IN (:...yardIds)
OR partnerBooking.destinationYardId IN (:...yardIds))`,
{ yardIds },
);
}
/**
* Persistence for the shared-wagon approval gate. Rows are never deleted —
* decided rows are the audit trail of who approved which pairing and when.
@@ -48,16 +78,91 @@ export class ConsolidationApprovalsRepository {
return this.repository.findOne({ where: { id } });
}
/** Pending requests for the review queue, oldest first (FIFO). */
findQueue(): Promise<ConsolidationApproval[]> {
return this.repository.find({
where: { status: ConsolidationApprovalStatus.Pending },
relations: {
booking: { company: true },
partnerBooking: { company: true },
},
order: { requestedAt: "ASC" },
});
/**
* One page of review-queue rows, with both bookings loaded.
*
* Pending rows are work still to do, so they come oldest first (FIFO) and
* ahead of everything else. Decided rows are history, so they come
* newest-decision-first. Ordering is done in SQL, not after the fact — a page
* sorted in memory would only be sorted within itself.
*
* `yardIds` narrows to the caller's yards (see YardScopeService); pass
* undefined for an unrestricted caller. The narrowing is a WHERE, not a
* post-filter, so the page and the total both count only visible rows.
*/
async findQueuePage(options: {
status?: ConsolidationApprovalStatus;
yardIds?: string[];
page: number;
pageSize: number;
}): Promise<{ items: ConsolidationApproval[]; total: number }> {
const { status, yardIds, page, pageSize } = options;
const qb = this.repository
.createQueryBuilder("approval")
.leftJoinAndSelect("approval.booking", "booking")
.leftJoinAndSelect("booking.company", "company")
.leftJoinAndSelect("approval.partnerBooking", "partnerBooking")
.leftJoinAndSelect("partnerBooking.company", "partnerCompany");
if (status) {
qb.andWhere("approval.status = :status", { status });
} else {
qb.addOrderBy(
`CASE WHEN approval.status = '${ConsolidationApprovalStatus.Pending}' THEN 0 ELSE 1 END`,
"ASC",
);
}
applyYardScope(qb, yardIds);
// Pending has no decidedAt, decided rows all do — one pair of keys orders
// both groups correctly whichever tab asked.
const [items, total] = await qb
.addOrderBy("approval.decidedAt", "DESC", "NULLS FIRST")
.addOrderBy("approval.requestedAt", "ASC")
.skip((page - 1) * pageSize)
.take(pageSize)
.getManyAndCount();
return { items, total };
}
/**
* Row count per status, for the tab badges — those must show the whole
* queue, not just the page currently loaded. Narrowed by the same yard scope
* as the list, so a badge never promises rows the caller cannot open.
*/
async countByStatus(
yardIds?: string[],
): Promise<Record<ConsolidationApprovalStatus, number>> {
const qb = this.repository
.createQueryBuilder("approval")
.select("approval.status", "status")
.addSelect("COUNT(*)", "count")
.groupBy("approval.status");
// The scope predicate reads both bookings, so it needs them joined even
// though the count itself selects no columns from them.
if (yardIds) {
qb.leftJoin("approval.booking", "booking").leftJoin(
"approval.partnerBooking",
"partnerBooking",
);
}
applyYardScope(qb, yardIds);
const rows = await qb.getRawMany<{
status: ConsolidationApprovalStatus;
count: string;
}>();
const counts = {
[ConsolidationApprovalStatus.Pending]: 0,
[ConsolidationApprovalStatus.Approved]: 0,
[ConsolidationApprovalStatus.Rejected]: 0,
};
for (const row of rows) counts[row.status] = Number(row.count);
return counts;
}
create(input: {
@@ -89,9 +194,11 @@ export class ConsolidationApprovalsRepository {
| ConsolidationApprovalStatus.Rejected,
decidedBy: string | null,
decisionNote?: string | null,
/** Statuses the row may be claimed FROM. Defaults to pending-only. */
from: ConsolidationApprovalStatus[] = [ConsolidationApprovalStatus.Pending],
): Promise<boolean> {
const result = await this.repository.update(
{ id, status: ConsolidationApprovalStatus.Pending },
{ id, status: In(from) },
{
status,
decidedBy,
@@ -109,7 +216,10 @@ export class ConsolidationApprovalsRepository {
if (bookingIds.length === 0) return Promise.resolve([]);
return this.repository.find({
where: [
{ bookingId: In(bookingIds), status: ConsolidationApprovalStatus.Pending },
{
bookingId: In(bookingIds),
status: ConsolidationApprovalStatus.Pending,
},
{
partnerBookingId: In(bookingIds),
status: ConsolidationApprovalStatus.Pending,

View File

@@ -0,0 +1,49 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsDateString,
IsIn,
IsNumber,
IsOptional,
IsPositive,
IsString,
Length,
} from 'class-validator';
export class CreateAdditionalChargeDto {
@ApiProperty({ example: 'Re-weighing fee at Mojo dry port' })
@IsString()
@Length(1, 2000)
reason!: string;
@ApiProperty({ example: 4500 })
@Type(() => Number)
@IsNumber()
@IsPositive()
amount!: number;
@ApiProperty({ example: 'ETB' })
@IsString()
@Length(3, 8)
currency!: string;
/** 'send' issues the invoice + notifies the customer immediately; omit/'draft' just saves it. */
@ApiPropertyOptional({ enum: ['draft', 'send'], default: 'draft' })
@IsOptional()
@IsIn(['draft', 'send'])
action?: 'draft' | 'send';
/** Payment due date; omit to fall back to the invoice's own default term (14 days) on send. */
@ApiPropertyOptional({ example: '2026-09-01' })
@IsOptional()
@IsDateString()
dueDate?: string;
}
export class CancelAdditionalChargeDto {
@ApiPropertyOptional({ example: 'Raised in error' })
@IsOptional()
@IsString()
@Length(1, 2000)
reason?: string;
}

View File

@@ -1,6 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsNumber, IsPositive, IsString, Length } from 'class-validator';
import {
IsNumber,
IsOptional,
IsPositive,
IsString,
Length,
MaxLength,
} from 'class-validator';
export class BillClearanceChargeDto {
@ApiProperty({ example: 12500.5 })
@@ -13,4 +20,18 @@ export class BillClearanceChargeDto {
@IsString()
@Length(3, 8)
currency!: string;
/** What the price is for. Required for miscellaneous charges (checked in the service). */
@ApiPropertyOptional({ example: 'Container cleaning and weighbridge fee' })
@IsOptional()
@IsString()
@MaxLength(1000)
description?: string;
}
export class RejectClearanceChargeDto {
@ApiProperty({ example: 'The weighbridge fee was already paid at the port.' })
@IsString()
@Length(1, 1000)
note!: string;
}

View File

@@ -67,10 +67,60 @@ export class RequestWagonCancellationDto {
reason?: string;
}
export class RebookUnitDto {
@ApiProperty({ description: 'Container number for the rebooked unit' })
@IsString()
@MaxLength(64)
containerNumber!: string;
@ApiPropertyOptional({ description: 'Seal number' })
@IsOptional()
@IsString()
@MaxLength(64)
sealNumber?: string;
@ApiPropertyOptional({ description: 'VGM (tons) of the unit' })
@IsOptional()
@IsNumber()
@Min(0)
vgmTons?: number;
}
export class RebookContainerLineDto {
@ApiProperty({ description: 'Container size as stored on the credit, e.g. "20ft"' })
@IsString()
containerSize!: string;
@ApiProperty({
description:
'The rebooked units for this size — count MUST equal the cancelled quantity',
type: [RebookUnitDto],
})
@IsArray()
@ArrayNotEmpty()
@ValidateNested({ each: true })
@Type(() => RebookUnitDto)
units!: RebookUnitDto[];
}
export class RebookCancelledWagonsDto {
@ApiProperty({ description: 'Shipment day the credit is rebooked onto (ISO date)' })
@IsDateString()
scheduledDate!: string;
@ApiPropertyOptional({
description:
'Optional unit overrides: container number / seal / VGM may change, but ' +
'sizes and quantities must match the cancelled booking exactly. Sizes ' +
'omitted here keep their original units.',
type: [RebookContainerLineDto],
})
@IsOptional()
@IsArray()
@ArrayNotEmpty()
@ValidateNested({ each: true })
@Type(() => RebookContainerLineDto)
containers?: RebookContainerLineDto[];
}
export class FilterWagonCancellationsDto {

View File

@@ -0,0 +1,78 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Booking } from './booking.entity';
export const ADDITIONAL_CHARGE_STATUSES = [
'DRAFT',
'SENT',
'PAID',
'CANCELLED',
] as const;
export type AdditionalChargeStatus = (typeof ADDITIONAL_CHARGE_STATUSES)[number];
/**
* An ad-hoc extra charge finance raises against a booking — free-text reason,
* any number per booking (unlike `BookingClearanceCharge`, which caps at one
* per type). DRAFT until finance sends it; sending issues the payable invoice
* and notifies the customer (in-app + SMS + email). PAID via the standard
* `additional_charge.invoice.paid` settlement event.
*/
@Entity({ schema: 'freight', name: 'additional_charge' })
@Index(['bookingId'])
export class AdditionalCharge extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@ManyToOne(() => Booking, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
@Column({ name: 'reason', type: 'text' })
reason!: string;
@Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' })
status!: AdditionalChargeStatus;
@Column({ name: 'amount', type: 'numeric', precision: 14, scale: 2 })
amount!: string;
@Column({ name: 'currency', type: 'varchar', length: 8 })
currency!: string;
/** Optional payment due date; unset falls back to the invoice's own default term on send. */
@Column({ name: 'due_at', type: 'timestamptz', nullable: true })
dueAt?: Date | null;
/** The supporting attachment (FileRecord), if any. */
@Column({ name: 'file_record_id', type: 'uuid', nullable: true })
fileRecordId?: string | null;
/** The payable invoice issued for this charge (null until SENT). */
@Column({ name: 'invoice_id', type: 'uuid', nullable: true })
invoiceId?: string | null;
/** CBE bill reference / PNR the customer pays against, once issued. */
@Column({ name: 'payment_reference', type: 'varchar', length: 64, nullable: true })
paymentReference?: string | null;
@Column({ name: 'created_by_staff_id', type: 'uuid', nullable: true })
createdByStaffId?: string | null;
@Column({ name: 'sent_by_staff_id', type: 'uuid', nullable: true })
sentByStaffId?: string | null;
@Column({ name: 'sent_at', type: 'timestamptz', nullable: true })
sentAt?: Date | null;
@Column({ name: 'paid_at', type: 'timestamptz', nullable: true })
paidAt?: Date | null;
@Column({ name: 'cancelled_by_staff_id', type: 'uuid', nullable: true })
cancelledByStaffId?: string | null;
@Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true })
cancelledAt?: Date | null;
@Column({ name: 'cancel_reason', type: 'text', nullable: true })
cancelReason?: string | null;
}

View File

@@ -9,20 +9,24 @@ export const CLEARANCE_CHARGE_STATUSES = [
'DOC_UPLOADED',
'BILLED',
'SENT',
'REJECTED',
'ACCEPTED',
'PAID',
] as const;
export type ClearanceChargeStatus = (typeof CLEARANCE_CHARGE_STATUSES)[number];
/**
* Post-finalization clearance charge billed to the customer — at most one
* PORT_CHARGES and one MISCELLANEOUS row per booking. GL Djibouti uploads the
* port-charges document (DOC_UPLOADED); GL Ethiopia sets amount + currency
* (BILLED) and issues the invoice (SENT); the billing `clearance_charge.invoice.paid`
* event marks it PAID. MISCELLANEOUS is created whole by GL Ethiopia and only
* after the port charge is paid.
* Clearance charge billed to the customer. One PORT_CHARGES row per booking
* (enforced by a partial unique index) and any number of MISCELLANEOUS rows.
* GL Djibouti uploads the port-charges document (DOC_UPLOADED); GL Ethiopia
* sets amount + currency + description (BILLED) and proposes it to the
* customer (SENT). The customer either REJECTS with a note (GL revises and
* re-sends) or ACCEPTS, which issues the invoice and locks the charge; the
* billing `clearance_charge.invoice.paid` event marks it PAID. The two levels
* are independent — either may be raised first.
*/
@Entity({ schema: 'freight', name: 'booking_clearance_charge' })
@Index(['bookingId', 'type'], { unique: true })
@Index(['bookingId'])
export class BookingClearanceCharge extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@@ -47,6 +51,20 @@ export class BookingClearanceCharge extends BaseEntity {
@Column({ name: 'currency', type: 'varchar', length: 8, nullable: true })
currency?: string | null;
/** What the price is for, written by GL. */
@Column({ name: 'description', type: 'text', nullable: true })
description?: string | null;
/** Customer's reason when REJECTED; cleared when GL revises. */
@Column({ name: 'customer_note', type: 'text', nullable: true })
customerNote?: string | null;
@Column({ name: 'customer_decided_at', type: 'timestamptz', nullable: true })
customerDecidedAt?: Date | null;
@Column({ name: 'customer_decided_by', type: 'uuid', nullable: true })
customerDecidedBy?: string | null;
/** The payable invoice issued for this charge (null until SENT). */
@Column({ name: 'invoice_id', type: 'uuid', nullable: true })
invoiceId?: string | null;

View File

@@ -11,6 +11,12 @@ export const REVIEW_NOTE_TYPES = [
* (price/files). One row per round — the draft/change-request loop can repeat.
*/
'DRAFT_DECL_CHANGE_REQUEST',
/**
* GL asked the customer for additional clearance document(s). Shown as a
* thread on both the GL clearance page and the customer's portal — the
* customer answers by uploading an ad-hoc document.
*/
'ADDITIONAL_DOC_REQUEST',
] as const;
export type ReviewNoteType = (typeof REVIEW_NOTE_TYPES)[number];

View File

@@ -5,6 +5,9 @@ import { Invoice } from '../../billing/entities/invoice.entity';
import { Rate } from '../../rule-engine/entities/rate.entity';
import { Booking } from './booking.entity';
/** `invoices.type` of the wagon-cancellation fee invoice — the settlement branch key in BookingInvoiceService. */
export const WAGON_CANCEL_FEE_INVOICE_TYPE = 'WAGON_CANCEL_FEE';
export const WAGON_CANCELLATION_STATUSES = [
// Requested; fee invoice open; wagons still allocated to the customer.
'FEE_PENDING',

View File

@@ -23,7 +23,7 @@ import {
type RiskAssignmentRecord,
} from './entities/clearance-milestone.entity';
import { Booking } from '../bookings/entities/booking.entity';
import { clearanceCodesForBooking } from '../bookings/clearance.util';
import { adHocLabel, clearanceCodesForBooking } from '../bookings/clearance.util';
import { assertDoCollectionDates } from './contract-clearance.util';
import { ClearanceWorkflowService } from './clearance-workflow.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
@@ -66,6 +66,12 @@ export interface BookingClearanceView {
}>;
allApproved: boolean;
documentsOpen: boolean;
docRequests: Array<{
id: string;
note: string;
byName: string | null;
at: string;
}>;
phase?: string | null;
milestones?: Array<{
id: string;
@@ -206,9 +212,14 @@ export class BookingClearanceService {
bookingId,
'CHANGES_REQUESTED',
);
const docRequestNotes = await this.bookingsRepository.findReviewNotes(
bookingId,
'ADDITIONAL_DOC_REQUEST',
);
const reviewerNames = await this.bookingsRepository.resolveStaffNames([
...reviews.map((r) => r.reviewedByStaffId),
...queryNotes.map((n) => n.authorId),
...docRequestNotes.map((n) => n.authorId),
]);
const documents: BookingClearanceView['documents'] = [];
@@ -257,7 +268,9 @@ export class BookingClearanceService {
const review = reviewByKey.get(`custom:${f.code}`) ?? null;
documents.push({
fileKey: f.code,
label: f.name,
// What the customer called it, falling back to the filename for rows
// uploaded before the name was carried through.
label: f.title || adHocLabel(f.code) || f.name,
required: false,
uploadedBy: 'customer',
settingCode: 'custom',
@@ -334,7 +347,8 @@ export class BookingClearanceService {
} catch {
train = null;
}
const finalInvoice = await this.glOperationsService.finalInvoiceSummary(bookingId);
// Removed from the clearance flow — see gl-operations.service.
const finalInvoice: ClearanceFinalInvoiceSummary | null = null;
const bookingMilestone = (code: string) =>
milestones.find((m) => m.milestoneCode === code);
const gatepass = await this.glOperationsService.gatepassForBooking(bookingId);
@@ -357,6 +371,12 @@ export class BookingClearanceService {
documents,
allApproved,
documentsOpen: clearanceDocumentsOpen(booking),
docRequests: docRequestNotes.map((n) => ({
id: n.id,
note: n.note,
byName: n.authorId ? (reviewerNames.get(n.authorId) ?? null) : null,
at: n.createdAt.toISOString(),
})),
phase,
milestones: milestones.map((m) => ({
id: m.id,
@@ -765,6 +785,50 @@ export class BookingClearanceService {
return updated;
}
/**
* GL Ethiopia skips the draft-declaration round entirely: the customer is
* not sent an estimate, staff file the real customs declaration directly.
* Duty & tax passes with it by default — there is no draft price to advise
* from. Advising duty later still works and overrides the skip (a skipped
* milestone is completed normally by adviseDuty).
*/
async skipDraftDeclaration(bookingId: string, userId?: string): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Draft declaration applies only to import bookings.');
}
await this.milestoneService.ensureBookingMilestones(bookingId, 'IMPORT');
const milestones = await this.workflowService.listMilestonesForBooking(bookingId);
const uploaded = milestones.find((m) => m.milestoneCode === 'DRAFT_DECLARATION_UPLOADED');
if (uploaded?.status === 'COMPLETED') {
throw new BadRequestException(
'A draft declaration was already sent to the customer — it can no longer be skipped.',
);
}
await this.workflowService.assertPriorCompleteForBooking(
bookingId,
'IMPORT',
'DRAFT_DECLARATION_UPLOADED',
);
await this.workflowService.skipMilestonesForBooking(bookingId, [
'DRAFT_DECLARATION_UPLOADED',
'DRAFT_DECLARATION_ACCEPTED',
]);
await this.workflowService.onDutySkippedForBooking(bookingId);
await this.bookingsRepository.update(bookingId, {
dutyRequired: false,
clearanceCurrentPhase: ContractDocPhase.GlEtOutput,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'DRAFT_DECLARATION_SKIPPED',
label:
'Skipped the draft declaration — filing the customs declaration directly (duty & tax passed by default)',
actorId: userId ?? null,
});
return this.bookingsService.findById(bookingId);
}
/**
* The customer accepts the draft declaration — GL Ethiopia may now file the
* real customs declaration.

View File

@@ -140,6 +140,14 @@ export class ContractBookingService {
dto: CreateBookingUnderContractDto,
user?: { id?: string } | null,
actorPermissions?: unknown,
opts?: {
/**
* Wagon-cancellation credit rebook only: the freight was paid while the
* contract was live, so redeeming the credit is allowed even after the
* contract's validity lapsed. Never set for a genuinely new booking.
*/
allowExpiredContract?: boolean;
},
): Promise<CreateBookingUnderContractResult> {
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
@@ -180,8 +188,13 @@ export class ContractBookingService {
actorPermissions != null &&
hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking);
await this.assertNotExpired(contract);
const createdByRole = await this.assertGate(contract, isGlActor);
if (!opts?.allowExpiredContract) await this.assertNotExpired(contract);
const createdByRole = await this.assertGate(
contract,
isGlActor,
false,
opts?.allowExpiredContract,
);
// ONE_TIME: a single shipment at a time. The slot frees only if the prior
// booking reached a terminal state (e.g. payment expired without shipping),
@@ -1203,6 +1216,7 @@ export class ContractBookingService {
contract: Contract,
isGlActor: boolean,
isInitiate = false,
allowExpired = false,
): Promise<string> {
// Suspended contracts are frozen for everyone, GL included — say so instead
// of letting the executed-status check below give a misleading reason.
@@ -1225,7 +1239,10 @@ export class ContractBookingService {
}
// No contract clearance cycle exists on either kind now — clearance runs
// on the booking, so an executed/active contract is the only gate here.
if (!['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status)) {
if (
!['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status) &&
!(allowExpired && contract.status === 'EXPIRED')
) {
throw new BadRequestException(
'Contract must be fully executed before booking a shipment.',
);
@@ -1234,7 +1251,10 @@ export class ContractBookingService {
}
// Path A — customer (or staff) once the contract is executed.
if (!['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status)) {
if (
!['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status) &&
!(allowExpired && contract.status === 'EXPIRED')
) {
throw new BadRequestException(
'Contract must be fully executed before booking a shipment.',
);
@@ -2458,22 +2478,12 @@ export class ContractBookingService {
private async assert20ftPairableAtCreate(
dto: CreateBookingUnderContractDto,
): Promise<void> {
// Parity gate. 20ft containers ride two per wagon, so an odd total leaves
// one container that cannot be placed. Consolidation (pairing it with
// another customer's odd booking) is built end to end but switched off for
// now, so an odd total is rejected outright — server-side, because the
// frontend block alone is not a guarantee.
const ft20Quantity = (dto.containers ?? [])
.filter((line) => (line.containerSize ?? '').includes('20'))
.reduce((sum, line) => sum + Number(line.quantity || 0), 0);
if (ft20Quantity % 2 === 1) {
throw new BadRequestException(
`20ft containers travel two per wagon, so they must be booked in even ` +
`numbers. This booking has ${ft20Quantity} — add one more or remove ` +
`one (book ${ft20Quantity + 1} or ${ft20Quantity - 1}).`,
);
}
// Odd 20ft totals are no longer rejected here: the wagon consolidation gate
// that runs right after (consolidateDrawdown / needsConsolidationFromBooking,
// same machinery the plain booking flow already uses live) auto-pairs an odd
// total with another customer's odd booking or parks it as
// PENDING_CONSOLIDATION until one appears. This assert now only checks that
// any 20ft containers actually present can be weight-paired on a wagon.
const twentyFtUnits = (dto.containers ?? [])
.filter((line) => (line.containerSize ?? '').includes('20'))
.flatMap((line, lineIdx) =>

View File

@@ -330,7 +330,9 @@ export class ContractClearanceService {
let train: ClearanceTrainState | null = null;
let bookingMilestones: ClearanceMilestone[] = [];
let finalInvoice: ClearanceFinalInvoiceSummary | null = null;
// Removed from the clearance flow — see gl-operations.service. Kept in the
// payload (always null) so existing consumers keep type-checking.
const finalInvoice: ClearanceFinalInvoiceSummary | null = null;
if (cycle?.bookingId) {
try {
train = await this.glOperationsService.trainState(cycle.bookingId);
@@ -340,7 +342,6 @@ export class ContractClearanceService {
bookingMilestones = await this.workflowService.listMilestonesForBooking(
cycle.bookingId,
);
finalInvoice = await this.glOperationsService.finalInvoiceSummary(cycle.bookingId);
}
const bookingMilestone = (code: string) =>
bookingMilestones.find((m) => m.milestoneCode === code);

View File

@@ -0,0 +1,65 @@
import { ContractBookingService } from './contract-booking.service';
/**
* Wagon-cancellation credit rebook must work after the contract lapses (the
* freight was paid while it was live), while every other create path stays
* blocked. assertGate is the status gate createUnderContract runs; this pins
* the EXPIRED carve-out to the allowExpired flag.
*/
describe('ContractBookingService.assertGate expired-contract rebook carve-out', () => {
// assertGate only reads contract fields — no constructor deps needed.
const service = Object.create(
ContractBookingService.prototype,
) as ContractBookingService;
const gate = (
contract: Record<string, unknown>,
allowExpired: boolean,
): Promise<string> =>
(
service as unknown as {
assertGate: (
c: unknown,
gl: boolean,
init: boolean,
allowExpired: boolean,
) => Promise<string>;
}
).assertGate(contract, true, false, allowExpired);
it('refuses an EXPIRED contract on the normal create path', async () => {
await expect(
gate({ status: 'EXPIRED', contractKind: 'GENERAL' }, false),
).rejects.toThrow(/fully executed/i);
});
it('lets a credit rebook through on an EXPIRED contract (Path A)', async () => {
await expect(
gate({ status: 'EXPIRED', contractKind: 'GENERAL' }, true),
).resolves.toBe('STAFF');
});
it('lets a credit rebook through on an EXPIRED customs contract (Path B)', async () => {
await expect(
gate(
{
status: 'EXPIRED',
contractKind: 'GENERAL',
customsClearingEnabled: true,
},
true,
),
).resolves.toBe('GL_ET');
});
it('still refuses a SUSPENDED contract even for a rebook', async () => {
await expect(
gate({ status: 'SUSPENDED', contractKind: 'GENERAL' }, true),
).rejects.toThrow(/suspended/i);
});
it('does not open the gate for other non-executed statuses', async () => {
await expect(
gate({ status: 'DRAFT', contractKind: 'GENERAL' }, true),
).rejects.toThrow(/fully executed/i);
});
});

View File

@@ -376,12 +376,21 @@ export class ContractPricingService {
// own container-type rate), bulk contracts freeze the route's bulk fee.
// A customs contract may not proceed without the fee(s) configured.
if (contract.customsClearingEnabled) {
// An Ethiopian-side-only customs service prices off its own rate; the
// snapshot codes carry the same prefix so booking pricing finds them.
const customsType = contract.serviceType?.includesEthiopianCustomsOnly
? 'ETHIOPIAN_CUSTOMS_CLEARANCE'
: 'CUSTOMS_CLEARANCE';
const customsLabel =
customsType === 'ETHIOPIAN_CUSTOMS_CLEARANCE'
? 'Ethiopian customs clearance service'
: 'Customs clearance service';
// Strict, no route-less fallback.
// ponytail: multi-route contracts bill the first lane's fee; per-lane fees need per-route snapshots.
const onLeg = route
? liveRates.filter(
(r) =>
r.rateType === 'CUSTOMS_CLEARANCE' &&
r.rateType === customsType &&
r.currency === 'USD' &&
r.tradeDirection === contract.tradeDirection &&
r.originYardId === route.originYardId &&
@@ -406,14 +415,14 @@ export class ContractPricingService {
);
if (!rate || Number(rate.rateValue) <= 0) {
throw new UnprocessableEntityException(
`No customs clearance service fee is configured for ${size} containers on this direction and route. Ask the rates team to set a live CUSTOMS_CLEARANCE rate for this container type and origin → destination.`,
`No customs clearance service fee is configured for ${size} containers on this direction and route. Ask the rates team to set a live ${customsType} rate for this container type and origin → destination.`,
);
}
lineItems.push({
// Distinct code per size so the frozen snapshots don't collide —
// booking pricing looks each size up by CUSTOMS_CLEARANCE_<FT>FT.
code: `CUSTOMS_CLEARANCE_${sizeFt}FT`,
label: `Customs clearance service (${size})`,
// booking pricing looks each size up by <customsType>_<FT>FT.
code: `${customsType}_${sizeFt}FT`,
label: `${customsLabel} (${size})`,
unit: toContractUnit(rate.rateUnit),
unitPrice: convert(Number(rate.rateValue)),
containerSize: size,
@@ -432,12 +441,12 @@ export class ContractPricingService {
: undefined) ?? onLeg.find((r) => !r.containerTypeId && !r.cargoTypeId);
if (!rate || Number(rate.rateValue) <= 0) {
throw new UnprocessableEntityException(
'No bulk customs clearance service fee is configured for this cargo type on this direction and route. Ask the rates team to set a live bulk CUSTOMS_CLEARANCE rate for this commodity and origin → destination.',
`No bulk customs clearance service fee is configured for this cargo type on this direction and route. Ask the rates team to set a live bulk ${customsType} rate for this commodity and origin → destination.`,
);
}
lineItems.push({
code: 'CUSTOMS_CLEARANCE',
label: `Customs clearance service (${scope?.cargoType?.cargoTypeName ?? 'bulk'})`,
code: customsType,
label: `${customsLabel} (${scope?.cargoType?.cargoTypeName ?? 'bulk'})`,
unit: toContractUnit(rate.rateUnit),
unitPrice: convert(Number(rate.rateValue)),
cargoTypeCode: scope?.cargoType?.code ?? null,

View File

@@ -124,11 +124,14 @@ export class ContractsService {
return `CTR-${year}-${String(seq + 1).padStart(5, '0')}`;
}
/** The service type a contract is sold under (null when the id is unknown). */
private resolveServiceType(serviceTypeId: string): Promise<ServiceType | null> {
return this.dataSource.getRepository(ServiceType).findOne({ where: { id: serviceTypeId } });
}
/** Whether a service type bundles customs clearance. */
private async resolveIncludesCustoms(serviceTypeId: string): Promise<boolean> {
const serviceType = await this.dataSource
.getRepository(ServiceType)
.findOne({ where: { id: serviceTypeId } });
const serviceType = await this.resolveServiceType(serviceTypeId);
return serviceType?.includesCustoms ?? false;
}
@@ -324,7 +327,8 @@ export class ContractsService {
}
// Customs clearing is owned by the service type, not the customer.
const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId);
const serviceType = await this.resolveServiceType(dto.serviceTypeId);
const includesCustoms = serviceType?.includesCustoms ?? false;
// Intercity never crosses a border, so a customs-including service type is
// a contradiction — the wizard hides them, the API enforces it.
if (dto.tradeDirection === 'DOMESTIC' && includesCustoms) {
@@ -344,6 +348,8 @@ export class ContractsService {
freightType: dto.freightType,
paymentCurrency: 'USD',
customsClearingEnabled: includesCustoms,
// Decides which customs fee the probe looks up (Ethiopian-only vs full).
serviceType,
isHazardous: dto.isHazardous ?? false,
isReefer: dto.isReefer ?? false,
equipmentReturn: dto.equipmentReturn ?? null,

View File

@@ -779,7 +779,15 @@ export class GlOperationsService {
};
}
/** Final-invoice state joined with its document + slip files, for clearance views. */
/**
* Final-invoice state joined with its document + slip files.
*
* RETIRED from the clearance flow: the post-offload GL Djibouti invoice is no
* longer part of the export process, is not rendered on either desk or the
* portal, and never gated anything downstream. The endpoints and this reader
* stay so already-issued invoices remain resolvable; nothing calls it from a
* clearance view any more.
*/
async finalInvoiceSummary(
bookingId: string,
): Promise<Freight.ClearanceFinalInvoiceSummary | null> {

View File

@@ -36,9 +36,12 @@ const invoiceRow = (over: Partial<Invoice> = {}): Invoice =>
tin: "0999930000",
vatNumber: "123475885858",
phone: "0912345678",
region: "13",
zone: "SHA",
woreda: "574",
// A real MoR address — Ethiopia / SOMALI / FAAFAN ZONE / JIJIGA, which the Ministry master
// codes as 70 / 6 / 31 / 190. Spelled the way EDR and e-Trade actually store it, so the
// alias layer is exercised end to end rather than only in the resolver's own spec.
region: "Somali",
zone: "Fafen",
woreda: "Jigjiga",
kebele: "03",
houseNo: "NEW",
country: "Ethiopia",

View File

@@ -7,6 +7,7 @@ import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialE
import { EimsConfig } from "../../config/eims.config";
import { Invoice } from "../billing/entities/invoice.entity";
import { MorGeoCodes, resolveMorGeo } from "../../config/mor-location.resolver";
import { EimsDocumentType, EimsMapperLine, toEimsInvoice } from "../billing/eims-invoice.mapper";
import { sendCompanyChannels } from "../notifications/notify-company.util";
import { NotificationsService } from "../notifications/notifications.service";
@@ -32,6 +33,8 @@ interface BulkReservation {
invoice: Invoice & { lines: EimsMapperLine[] };
documentType: EimsDocumentType;
relatedDocument: string | null;
/** Resolved before this reservation existed — see the `prepared` pass in `bulkRegister`. */
buyerGeo: MorGeoCodes;
invoiceCounter: number;
documentNumber: string;
previousIrn: string;
@@ -123,7 +126,16 @@ export class EimsBulkRegistrationService {
}
relatedDocument = invoice.relatedInvoice.eimsIrn;
}
return { invoice, documentType, relatedDocument };
// Same rule as the single-invoice path: buyer geography is resolved from the MoR location
// master before reserveBulk touches a counter, so one bad company address fails the whole
// batch locally instead of burning a block of EIMS sequence numbers.
const buyerGeo = resolveMorGeo({
country: invoice.company?.country,
region: invoice.company?.region,
zone: invoice.company?.zone,
woreda: invoice.company?.woreda,
});
return { invoice, documentType, relatedDocument, buyerGeo };
});
if (prepared.length === 0) {
@@ -141,6 +153,7 @@ export class EimsBulkRegistrationService {
r.invoice,
this.sellerCache.getSellerDetails(cfg),
buildEimsContext(cfg, {
buyerGeo: r.buyerGeo,
documentNumber: r.documentNumber,
invoiceCounter: r.invoiceCounter,
previousIrn: r.previousIrn,
@@ -269,7 +282,12 @@ export class EimsBulkRegistrationService {
/** TX1. Reserve a contiguous block of N counters, one per invoice, in the given order. */
private async reserveBulk(
prepared: Array<{ invoice: Invoice & { lines: EimsMapperLine[] }; documentType: EimsDocumentType; relatedDocument: string | null }>,
prepared: Array<{
invoice: Invoice & { lines: EimsMapperLine[] };
documentType: EimsDocumentType;
relatedDocument: string | null;
buyerGeo: MorGeoCodes;
}>,
systemNumber: string,
placeholder: string,
): Promise<BulkReservation[]> {
@@ -302,7 +320,7 @@ export class EimsBulkRegistrationService {
// Locked in the caller's given order — stable, avoids two concurrent bulk calls deadlocking
// on the opposite lock order.
for (const { invoice, documentType, relatedDocument } of prepared) {
for (const { invoice, documentType, relatedDocument, buyerGeo } of prepared) {
const locked = await this.lockInvoice(manager, invoice.id);
const thisCounter = counter++;
const thisDocNumber = String(docNumber++);
@@ -322,6 +340,7 @@ export class EimsBulkRegistrationService {
invoice: Object.assign(locked, { lines: invoice.lines }),
documentType,
relatedDocument,
buyerGeo,
invoiceCounter: thisCounter,
documentNumber: thisDocNumber,
previousIrn: thisPreviousIrn,

View File

@@ -66,7 +66,13 @@ describe("assertEimsInvoiceConfig — charge-type overrides", () => {
});
describe("buildEimsContext — taxForLine", () => {
const input = { documentNumber: "24", invoiceCounter: 7, previousIrn: "", session: SESSION };
const input = {
buyerGeo: { Country: "70", Region: "6", City: "31", Wereda: "190" },
documentNumber: "24",
invoiceCounter: 7,
previousIrn: "",
session: SESSION,
};
const line = (chargeType: string) => ({ chargeType, quantity: 1, unitRate: 100, amount: 100 });
it("uses the per-chargeType override when one is configured", () => {

View File

@@ -1,5 +1,6 @@
import { BadRequestException } from "@nestjs/common";
import { EimsConfig } from "../../config/eims.config";
import { MorGeoCodes } from "../../config/mor-location.resolver";
import { EimsSessionContext } from "./eims-auth.service";
import {
EimsMapperContext,
@@ -149,6 +150,12 @@ export function buildEimsSeller(config: EimsConfig): EimsSellerDetails {
}
export interface EimsContextInput {
/**
* The buyer's MoR location codes, resolved from the Ministry location master by
* `resolveMorGeo` **before** the caller reserved an EIMS counter — see
* `EimsMapperContext.buyerGeo`.
*/
buyerGeo: MorGeoCodes;
/** `DocumentDetails.DocumentNumber`. The caller decides its source. */
documentNumber: string;
invoiceCounter: number;
@@ -205,11 +212,7 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E
unitDefault: invoice.unitDefault,
incomeWithholdValue: invoice.incomeWithholdValue!,
transactionWithholdValue: invoice.transactionWithholdValue!,
buyerCountryCode: invoice.buyerCountryCode,
buyerCountryCodes: invoice.buyerCountryCodes,
buyerRegionCodes: invoice.buyerRegionCodes,
buyerWeredaCodes: invoice.buyerWeredaCodes,
buyerCityCodes: invoice.buyerCityCodes,
buyerGeo: input.buyerGeo,
// TEMPORARY — see EimsInvoiceConfig.buyerIdType.
buyerIdType: invoice.buyerIdType,
buyerIdNumber: invoice.buyerIdNumber,

View File

@@ -13,6 +13,7 @@ import { EimsClientService } from "./eims-client.service";
import { EimsApiException, EimsConfigException } from "./eims.errors";
import { buildEimsSeller } from "./eims-invoice-context";
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
import { ETradeService } from "../companies/services/etrade.service";
import { EimsSellerCacheService } from "./eims-seller-cache.service";
import { EimsSystemState } from "./entities/eims-system-state.entity";
import { EimsInvoiceStatus } from "./eims-registration.types";
@@ -58,9 +59,12 @@ const invoiceRow = (over: Partial<Invoice> = {}): Invoice =>
vatNumber: "123475885858",
phone: "0912345678",
email: "buyer@abc.et",
region: "13",
zone: "SHA",
woreda: "574",
// A real MoR address — Ethiopia / SOMALI / FAAFAN ZONE / JIJIGA, which the Ministry master
// codes as 70 / 6 / 31 / 190. Spelled the way EDR and e-Trade actually store it, so the
// alias layer is exercised end to end rather than only in the resolver's own spec.
region: "Somali",
zone: "Fafen",
woreda: "Jigjiga",
kebele: "03",
houseNo: "NEW",
country: "Ethiopia",
@@ -502,21 +506,23 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
});
});
it("a mapper failure after reservation (e.g. unmapped buyer country) also releases the reservation", async () => {
it("a mapper failure after reservation still releases the reservation", async () => {
// Regression: toEimsInvoice/buildEimsContext used to sit outside the try/catch that calls
// settleFailure — a throw here left the reservation permanently orphaned (a real live incident:
// 500 on register, then every subsequent attempt 409'd "already in flight" until manually
// resolved). This never reaches postSigned at all — the mapper throws before submit() is called.
const db = new FakeDb([
invoiceRow({ company: { ...invoiceRow().company, country: "France" } as never }),
]);
//
// The trigger used to be an unmapped buyer country. That can no longer get this far: geography
// is resolved before the reservation now (see the test below). A line/total mismatch is a
// mapper-only failure that still reaches this point.
const db = new FakeDb([invoiceRow({ totalAmount: 999999 })]);
const postSigned = jest.fn();
// The mapper throws a plain Error (it's a pure function, not a NestJS layer) — that's the
// point: settleFailure must treat *any* non-EimsApiException as pre-wire, not just its own
// known exception types.
await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toThrow(
/no MoR country code mapping/,
/lines sum to/,
);
expect(postSigned).not.toHaveBeenCalled();
@@ -532,6 +538,69 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
});
});
it("an unmappable buyer address fails before a counter is ever reserved", async () => {
// The whole point of resolving geography ahead of reserve(): a company-record problem is a
// local data problem, and it must not cost an EIMS sequence number. Nothing about the invoice
// or the system state may change.
const db = new FakeDb([
invoiceRow({ company: { ...invoiceRow().company, woreda: "Nowhere" } as never }),
]);
const before = { ...db.state };
const postSigned = jest.fn();
await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toThrow(
/no MoR LOCALITY_DESC match/,
);
expect(postSigned).not.toHaveBeenCalled();
expect(db.state).toMatchObject({
nextInvoiceCounter: before.nextInvoiceCounter,
nextDocumentNumber: before.nextDocumentNumber,
inFlightInvoiceId: null,
});
expect(db.invoices.get(INVOICE_ID)).toMatchObject({
eimsStatus: EimsInvoiceStatus.NotSubmitted,
eimsInvoiceCounter: null,
});
});
it("files the buyer's MoR codes, resolved from the location master with no e-Trade call", async () => {
const db = new FakeDb([invoiceRow()]);
const postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: { irn: "IRN-1" } });
// The real seller cache, wired to an e-Trade mock that must never be reached: registration
// reads the company row EDR already stored, so filing stays deterministic and independent of
// e-Trade's availability. `refresh()` is deliberately not called — the cache stays empty and
// the seller falls back to static config, exactly as it does on a cold process.
const cfg = config();
const resolveCompanyData = jest.fn();
const sellerCache = new EimsSellerCacheService(
{ resolveCompanyData, extractRegistrationData: jest.fn() } as unknown as ETradeService,
{ get: () => cfg } as unknown as ConfigService,
);
const service = new EimsInvoiceRegistrationService(
db.asDataSource(),
{ get: () => cfg } as unknown as ConfigService,
{ postSigned, postBearer: jest.fn() } as unknown as EimsClientService,
{ getSessionContext: jest.fn().mockResolvedValue(SESSION) } as unknown as EimsAuthService,
{ notify: jest.fn().mockResolvedValue(undefined) } as unknown as NotificationInboxService,
{ directSend: jest.fn().mockResolvedValue(undefined) } as unknown as NotificationsService,
sellerCache,
);
await service.registerInvoiceWithEims(INVOICE_ID);
const [, body] = postSigned.mock.calls[0];
expect(body.BuyerDetails).toMatchObject({
Country: "70",
Region: "6",
City: "31",
Wereda: "190",
});
expect(resolveCompanyData).not.toHaveBeenCalled();
});
it("treats a success response with no IRN as a failed registration", async () => {
const db = new FakeDb([invoiceRow()]);
const postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: { irn: "" } });

View File

@@ -29,6 +29,7 @@ import { EimsClientService } from "./eims-client.service";
import { EimsApiException, EimsConfigException } from "./eims.errors";
import { EimsSellerCacheService } from "./eims-seller-cache.service";
import { EimsSystemState } from "./entities/eims-system-state.entity";
import { resolveMorGeo } from "../../config/mor-location.resolver";
import { assertEimsInvoiceConfig, buildEimsContext } from "./eims-invoice-context";
import {
EimsInvoiceError,
@@ -116,6 +117,17 @@ export class EimsInvoiceRegistrationService {
relatedDocument = invoice.relatedInvoice.eimsIrn;
}
// Buyer geography is resolved from the MoR location master *here*, ahead of the reservation:
// an unknown or ambiguous company address is a local data problem, and failing it after
// reserving would consume an EIMS sequence number for an invoice that was never filable. It
// needs no network access, so there is no reason for it to sit behind the login either.
const buyerGeo = resolveMorGeo({
country: invoice.company?.country,
region: invoice.company?.region,
zone: invoice.company?.zone,
woreda: invoice.company?.woreda,
});
// Authenticate before reserving: the source system comes from the token, and the state row is
// keyed by it. A login failure here costs nothing — no counter has been consumed yet.
const session = await this.auth.getSessionContext();
@@ -135,6 +147,7 @@ export class EimsInvoiceRegistrationService {
invoice,
this.sellerCache.getSellerDetails(cfg),
buildEimsContext(cfg, {
buyerGeo,
// Allocated from the system state, not our invoiceNumber: MoR validates DocumentNumber
// against ^(0|[1-9][0-9]{0,8})$, which "INV-20260807-00006" can never satisfy.
documentNumber: reservation.documentNumber,

View File

@@ -5,11 +5,13 @@ import { ETradeService } from "../companies/services/etrade.service";
import { eimsConfig, eimsInvoiceConfig } from "./eims-test-fixtures";
import { EimsSellerCacheService } from "./eims-seller-cache.service";
// A real MoR address (PARISH_NO 13 / CITY_NO 78 / LOCALITY_NO 1100) — the resolver now works off
// the Ministry's own hierarchy, so a made-up address would simply not resolve.
const registrationData = (over: Record<string, unknown> = {}) => ({
companyName: "Ethio-Djibouti Railway PLC (eTrade)",
region: "Addis Ababa",
zone: "Bole",
woreda: "Yeka",
woreda: "Woreda 1",
mobilePhone: "0911000000",
regularPhone: "",
...over,
@@ -24,16 +26,10 @@ const build = (cfg: EimsConfig = eimsConfig()) => {
return { service, resolveCompanyData, extractRegistrationData, cfg };
};
const CODES = {
buyerRegionCodes: { "Addis Ababa": "13" },
buyerWeredaCodes: { Yeka: "99" },
buyerCityCodes: { Bole: "101" },
};
describe("EimsSellerCacheService.getSellerDetails", () => {
it("static config wins over a conflicting e-Trade value", async () => {
const cfg = eimsConfig({
invoice: eimsInvoiceConfig({ sellerLegalName: "Ethio-Djibouti Railway S.C.", ...CODES }),
invoice: eimsInvoiceConfig({ sellerLegalName: "Ethio-Djibouti Railway S.C." }),
});
const { service, resolveCompanyData } = build(cfg);
resolveCompanyData.mockResolvedValue({
@@ -56,7 +52,6 @@ describe("EimsSellerCacheService.getSellerDetails", () => {
sellerRegion: "",
sellerWereda: "",
sellerCity: null,
...CODES,
}),
});
const { service, resolveCompanyData } = build(cfg);
@@ -67,13 +62,32 @@ describe("EimsSellerCacheService.getSellerDetails", () => {
expect(seller.LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)");
expect(seller.Region).toBe("13");
expect(seller.Wereda).toBe("99");
expect(seller.City).toBe("78");
expect(seller.Wereda).toBe("1100");
});
it("leaves the static seller values alone when MoR does not list the e-Trade address", async () => {
// e-Trade's free text does not always correspond to a MoR row (here "Yeka" is a MoR *City*
// under ADDIS ABABA, not a locality under BOLE). That must degrade to the static config, which
// MoR has already cleared under rule 7017 — never throw, and never file a guessed code.
const cfg = eimsConfig({
invoice: eimsInvoiceConfig({ sellerRegion: "1", sellerWereda: "13", sellerCity: "101" }),
});
const { service, resolveCompanyData, extractRegistrationData } = build(cfg);
extractRegistrationData.mockReturnValue(registrationData({ woreda: "Yeka" }));
resolveCompanyData.mockResolvedValue({ companyInfo: {}, businessInfo: {} });
await expect(service.refresh()).resolves.toBeUndefined();
const seller = service.getSellerDetails(cfg);
expect(seller.Region).toBe("1");
expect(seller.Wereda).toBe("13");
expect(seller.City).toBe("101");
});
it("VatNumber and Email are always the static value, never touched by e-Trade", async () => {
const cfg = eimsConfig({
invoice: eimsInvoiceConfig({ sellerVatNumber: "0000000000", sellerEmail: "finance@example.et", ...CODES }),
invoice: eimsInvoiceConfig({ sellerVatNumber: "0000000000", sellerEmail: "finance@example.et" }),
});
const { service, resolveCompanyData } = build(cfg);
resolveCompanyData.mockResolvedValue({ companyInfo: {}, businessInfo: {} });
@@ -108,7 +122,7 @@ describe("EimsSellerCacheService.getSellerDetails", () => {
describe("EimsSellerCacheService.refresh", () => {
it("keeps the previous snapshot when a refresh fails", async () => {
const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "", ...CODES }) });
const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "" }) });
const { service, resolveCompanyData } = build(cfg);
resolveCompanyData.mockResolvedValueOnce({ companyInfo: {}, businessInfo: {} });
await service.refresh();
@@ -123,7 +137,7 @@ describe("EimsSellerCacheService.refresh", () => {
it("keeps the previous snapshot on timeout, without waiting for the slow request", async () => {
jest.useFakeTimers();
try {
const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "", ...CODES }) });
const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "" }) });
const { service, resolveCompanyData } = build(cfg);
resolveCompanyData.mockResolvedValueOnce({ companyInfo: {}, businessInfo: {} });
await service.refresh();

View File

@@ -3,7 +3,8 @@ import { ConfigService } from "@nestjs/config";
import { EimsConfig } from "../../config/eims.config";
import { ETradeService } from "../companies/services/etrade.service";
import { EimsSellerDetails, resolveOptionalCode } from "../billing/eims-invoice.mapper";
import { tryResolveMorGeo } from "../../config/mor-location.resolver";
import { EimsSellerDetails } from "../billing/eims-invoice.mapper";
import { buildEimsSeller } from "./eims-invoice-context";
const has = (value: string | null | undefined): value is string => Boolean(value && value.trim());
@@ -104,17 +105,24 @@ export class EimsSellerCacheService implements OnModuleInit {
);
if (!businessInfo) return; // no licence on file yet — keep the previous snapshot
const data = this.etrade.extractRegistrationData(businessInfo, companyInfo);
const codes = cfg.invoice;
// e-Trade returns region/zone/woreda as names ("Addis Ababa", "Bole") — resolved through the
// same MoR location master the buyer side uses, since the geography is objective, not
// buyer-specific. `tryResolveMorGeo` never throws: an address MoR does not list simply
// leaves these fields to getSellerDetails' static-config fallback, which is authoritative
// anyway (see the class comment — MoR has already cleared the static seller values under
// rule 7017, so nothing here may override one). e-Trade carries no country field; the
// resolver reads a blank country as domestic, which is correct for EDR's own registration.
const geo = tryResolveMorGeo({
region: data.region,
zone: data.zone,
woreda: data.woreda,
});
this.cached = {
LegalName: data.companyName || undefined,
Phone: data.mobilePhone || data.regularPhone || undefined,
// e-Trade returns region/zone/woreda as names ("Addis Ababa", "Bole") — resolved via the
// same buyer code maps, since the geography is objective, not buyer-specific, despite the
// env var's "BUYER_" prefix. Never throws: an unmapped name just leaves that field to
// getSellerDetails' static-config fallback.
Region: resolveOptionalCode(data.region, codes.buyerRegionCodes),
Wereda: resolveOptionalCode(data.woreda, codes.buyerWeredaCodes),
City: resolveOptionalCode(data.zone, codes.buyerCityCodes),
Region: geo?.Region,
Wereda: geo?.Wereda,
City: geo?.City,
};
} catch (err) {
this.logger.warn(

View File

@@ -32,11 +32,6 @@ export const eimsInvoiceConfig = (over: Partial<EimsInvoiceConfig> = {}): EimsIn
paymentMode: "CASH",
paymentTerm: "IMMIDIATE",
unitDefault: "PCS",
buyerCountryCode: null,
buyerCountryCodes: { Ethiopia: "231" }, // test-only, not a confirmed real MoR code
buyerRegionCodes: { "Addis Ababa": "13" },
buyerWeredaCodes: { Yeka: "99" }, // test-only, not a real MoR code
buyerCityCodes: { Kirkos: "101" }, // test-only, not a confirmed real MoR code
taxCodeByChargeType: {},
taxRateByChargeType: {},
exciseByChargeType: {},

View File

@@ -1,8 +1,13 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common';
import { Body, Controller, Get, NotFoundException, Param, ParseUUIDPipe, Post, Query, Res } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { BookingStaff } from '../../common/booking-guards';
import { BookingStaff, MixedAudience } from '../../common/booking-guards';
import { hasFreightPermission } from '../../common/freight-permission.util';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { BookingsService } from '../bookings/bookings.service';
import {
AssignCustomsRiskDto,
CreateDjiboutiIncidentDto,
@@ -19,30 +24,38 @@ import { ImportOperationsService } from './import-operations.service';
@ApiBearerAuth()
@Controller('import-operations')
// Post-booking customs / import-operations actions are GL/Ops work, mirroring the
// contracts controller's GL operational endpoints (risk, duty, milestones).
@BookingStaff(FREIGHT_PERMS.bookings.operations)
// contracts controller's GL operational endpoints (risk, duty, milestones). No
// class-level guard: the equipment interchange receipt below is customer-reachable,
// every other route here stays staff-only via its own @BookingStaff.
export class ImportOperationsController {
constructor(private readonly service: ImportOperationsService) {}
constructor(
private readonly service: ImportOperationsService,
private readonly bookingsService: BookingsService,
) {}
@Get('djibouti-incidents')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 8: list Djibouti import incidents' })
listIncidents(@Query('bookingId') bookingId?: string) {
return this.service.listIncidents(bookingId);
}
@Post('djibouti-incidents')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 8: report a Djibouti import incident / exception' })
createIncident(@Body() dto: CreateDjiboutiIncidentDto) {
return this.service.createIncident(dto);
}
@Get('customs/:bookingId')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 12: import customs finalization state' })
getCustoms(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.service.getCustoms(bookingId);
}
@Post('customs/:bookingId/documents')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 12: upload IM4/IM5/T1/permit/payment-slip documents' })
uploadCustomsDocument(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@@ -52,6 +65,7 @@ export class ImportOperationsController {
}
@Post('customs/:bookingId/declaration')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 12: record declaration serial number' })
recordDeclaration(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@@ -61,6 +75,7 @@ export class ImportOperationsController {
}
@Post('customs/:bookingId/notify-duties-taxes')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 12: notify duties and taxes' })
notifyDutiesTaxes(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@@ -70,6 +85,7 @@ export class ImportOperationsController {
}
@Post('customs/:bookingId/duties-taxes-paid')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 12: mark duties and taxes paid' })
markDutiesTaxesPaid(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@@ -79,12 +95,14 @@ export class ImportOperationsController {
}
@Post('customs/:bookingId/risk')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 12: assign customs risk' })
assignRisk(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Body() dto: AssignCustomsRiskDto) {
return this.service.assignRisk(bookingId, dto);
}
@Post('customs/:bookingId/release-permitted')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 12: mark import release permitted' })
markReleasePermitted(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@@ -94,18 +112,21 @@ export class ImportOperationsController {
}
@Get('empty-container-returns')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 16: list empty container returns' })
listEmptyReturns() {
return this.service.listEmptyReturns();
}
@Post('empty-container-returns')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 16: create an empty container return record' })
createEmptyReturn(@Body() dto: CreateEmptyContainerReturnDto) {
return this.service.createEmptyReturn(dto);
}
@Post('empty-container-returns/load-on-train')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({
summary: 'Load returned empties onto an export train (1×40ft or 2×20ft per wagon)',
})
@@ -114,6 +135,7 @@ export class ImportOperationsController {
}
@Post('empty-container-returns/:id/status')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 16: advance empty container return workflow' })
updateEmptyReturnStatus(
@Param('id', ParseUUIDPipe) id: string,
@@ -121,4 +143,53 @@ export class ImportOperationsController {
) {
return this.service.updateEmptyReturnStatus(id, dto);
}
@Get('bookings/:bookingId/empty-container-returns')
@MixedAudience(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'List empty container returns for a booking (customer portal)' })
async listEmptyReturnsForBooking(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@CurrentUser() user: TCurrentUser,
) {
await this.assertCanAccessBooking(user, bookingId);
return this.service.listEmptyReturnsForBooking(bookingId);
}
@Get('empty-container-returns/:id/document')
@MixedAudience(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Download the equipment interchange receipt PDF (customer portal)' })
async equipmentInterchangeDocument(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
@Res() res: Response,
) {
const row = await this.service.getEmptyReturnOrThrow(id);
// A standalone (no-booking) return has no owner to check against, so it
// stays staff-only.
if (!row.bookingId) {
await this.assertCanAccessBooking(user, null);
} else {
await this.assertCanAccessBooking(user, row.bookingId);
}
const { filename, buffer } = await this.service.equipmentInterchangeDocument(row);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
res.setHeader('Content-Length', buffer.length);
return res.send(buffer);
}
/**
* Staff pass on permission alone. A customer must own the booking; `null`
* (a standalone, booking-less return) has no owner for a customer to match,
* so it 404s them the same way a foreign booking would.
*/
private async assertCanAccessBooking(user: TCurrentUser, bookingId: string | null): Promise<void> {
if (hasFreightPermission(user, FREIGHT_PERMS.bookings.operations)) return;
if (!bookingId) {
throw new NotFoundException('Not found');
}
const booking = await this.bookingsService.findById(bookingId);
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
}

View File

@@ -1,6 +1,10 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BookingsModule } from '../bookings/bookings.module';
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { WarehousesModule } from '../warehouses/warehouses.module';
import { DjiboutiIncident } from './entities/djibouti-incident.entity';
import { EmptyContainerReturn } from './entities/empty-container-return.entity';
import { ImportCustomsFinalization } from './entities/import-customs-finalization.entity';
@@ -14,6 +18,14 @@ import { ImportOperationsService } from './import-operations.service';
ImportCustomsFinalization,
EmptyContainerReturn,
]),
// WarehouseReleaseDocumentService (the shared PDF renderer) for the
// equipment interchange receipt; BookingsModule for the customer
// ownership check on that same route; the notification modules to tell
// the customer their receipt is ready at handover.
WarehousesModule,
BookingsModule,
NotificationInboxModule,
NotificationsModule,
],
controllers: [ImportOperationsController],
providers: [ImportOperationsService],

View File

@@ -1,7 +1,14 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import { NotificationAudience, NotificationType } from '@edr/types';
import { LogoSettingsService } from '../logo-settings/logo-settings.service';
import { logoImageCss, logoMarkup } from '../billing/documents/logo-markup.util';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { NotificationsService } from '../notifications/notifications.service';
import { sendCompanyChannels } from '../notifications/notify-company.util';
import { WarehouseReleaseDocumentService } from '../warehouses/warehouse-release-document.service';
import {
CreateDjiboutiIncidentDto,
CreateEmptyContainerReturnDto,
@@ -32,6 +39,8 @@ const DAMAGE_INCIDENTS: DjiboutiIncidentType[] = [
@Injectable()
export class ImportOperationsService {
private readonly logger = new Logger(ImportOperationsService.name);
constructor(
@InjectRepository(DjiboutiIncident)
private readonly incidents: Repository<DjiboutiIncident>,
@@ -39,6 +48,10 @@ export class ImportOperationsService {
private readonly customs: Repository<ImportCustomsFinalization>,
@InjectRepository(EmptyContainerReturn)
private readonly emptyReturns: Repository<EmptyContainerReturn>,
private readonly pdfDocuments: WarehouseReleaseDocumentService,
private readonly logoSettings: LogoSettingsService,
private readonly inbox: NotificationInboxService,
private readonly notifications: NotificationsService,
) {}
listIncidents(bookingId?: string) {
@@ -150,9 +163,13 @@ export class ImportOperationsService {
return this.emptyReturns.find({ order: { createdAt: 'DESC' } as never });
}
listEmptyReturnsForBooking(bookingId: string) {
return this.emptyReturns.find({ where: { bookingId }, order: { createdAt: 'DESC' } as never });
}
async createEmptyReturn(dto: CreateEmptyContainerReturnDto) {
const returnDate = dto.returnDate ? new Date(dto.returnDate) : new Date();
return this.emptyReturns.save(
const saved = await this.emptyReturns.save(
this.emptyReturns.create({
containerNumber: dto.containerNumber,
bookingId: dto.bookingId ?? null,
@@ -171,6 +188,15 @@ export class ImportOperationsService {
],
}),
);
// RETURNED is the physical interchange itself — the customer's/trucker's
// custody of the box ends here, EDR's begins. The receipt exists from this
// point on, so tell the customer now, not at some later internal status.
// Standalone returns (no booking) have no company to notify.
if (saved.bookingId) {
await this.notifyEquipmentInterchangeReady(saved);
}
return saved;
}
/**
@@ -248,6 +274,170 @@ export class ImportOperationsService {
return this.emptyReturns.findOneOrFail({ where: { id } });
}
private async notifyEquipmentInterchangeReady(row: EmptyContainerReturn): Promise<void> {
try {
const [booking]: Array<{ companyId: string | null; reference: string }> =
await this.emptyReturns.manager.query(
`SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
[row.bookingId],
);
if (!booking?.companyId) return;
const body = `Container ${row.containerNumber} was handed over${
row.facility ? ` at ${row.facility}` : ''
}. Your equipment interchange receipt for booking ${booking.reference} is ready to download from the portal.`;
await this.inbox.notify({
recipients: { companyId: booking.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.DOCUMENT_ACTION,
title: 'Equipment interchange receipt ready',
body,
link: `/bookings/${row.bookingId}`,
data: { bookingId: row.bookingId, emptyContainerReturnId: row.id },
});
await sendCompanyChannels(this.emptyReturns.manager.connection, this.notifications, booking.companyId, body);
} catch (err) {
this.logger.warn(
`Failed to notify equipment interchange ready for return ${row.id}: ${(err as Error).message}`,
);
}
}
async getEmptyReturnOrThrow(id: string): Promise<EmptyContainerReturn> {
const row = await this.emptyReturns.findOne({ where: { id } });
if (!row) {
throw new NotFoundException(`Empty container return ${id} not found`);
}
return row;
}
/**
* Equipment Interchange Receipt — container number/size, exact return
* timestamp, depot, condition, and the carrier/booking reference that ties
* the box back to its bill of lading. Handed to the customer to download.
*/
async equipmentInterchangeDocument(
row: EmptyContainerReturn,
): Promise<{ filename: string; buffer: Buffer }> {
const booking = row.bookingId
? ((
await this.emptyReturns.manager.query(
`SELECT b.reference, c.name AS company_name
FROM freight.bookings b
LEFT JOIN freight.companies c ON c.id = b.company_id
WHERE b.id = $1`,
[row.bookingId],
)
)[0] as { reference: string; company_name: string | null } | undefined)
: undefined;
const html = this.buildEquipmentInterchangeHtml(row, booking, {
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
});
const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Equipment interchange receipt');
return {
filename: `equipment-interchange-${row.containerNumber || row.id.slice(0, 8)}.pdf`,
buffer,
};
}
private buildEquipmentInterchangeHtml(
row: EmptyContainerReturn,
booking: { reference: string; company_name: string | null } | undefined,
opts: { logoImageUrl?: string | null },
): string {
const esc = (value: unknown) =>
String(value ?? '-')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const dateTime = (value: unknown) =>
value ? new Date(value as string | Date).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }) : '-';
const carrier =
row.returnedBy === 'EDR'
? 'EDR Last Mile'
: row.returnedBy === 'CUSTOMER'
? 'Customer Self-Haul'
: '-';
const rows: Array<[string, string]> = [
['Container Number', row.containerNumber],
['Container Size', row.containerSize ? `${row.containerSize}ft` : 'Not recorded'],
['Date & Time of Return', dateTime(row.returnDate)],
['Depot / Location', [row.facility, row.yard, row.zone].filter(Boolean).join(' — ') || '-'],
['Condition Status', row.condition || 'Good — no exceptions noted'],
['Carrier', carrier],
['Booking / BOL Reference', booking?.reference || 'Standalone — no booking'],
['Shipping Line / Customer', booking?.company_name || '-'],
['Current Status', row.status.replace(/_/g, ' ')],
['Handover Note', row.handoverNote || '-'],
];
const rowsHtml = rows
.map(
([label, value]) =>
`<tr><th>${esc(label)}</th><td>${esc(value)}</td></tr>`,
)
.join('');
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Equipment Interchange Receipt</title>
<style>
@page { size: A4; margin: 14mm; }
* { box-sizing: border-box; }
body { margin: 0; color: #0f172a; font-family: Arial, sans-serif; }
.top { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 3px solid #0f766e; padding-bottom: 12px; gap: 24px; }
.brand { font-size: 11px; color: #475569; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }
h1 { margin: 6px 0 0; font-size: 22px; line-height: 1.1; }
.meta { text-align: right; font-size: 11px; color: #475569; }
.meta strong { display: block; margin-top: 4px; color: #0f172a; font-size: 15px; }
${logoImageCss()}
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th, td { border: 1px solid #cbd5e1; padding: 8px 10px; font-size: 11.5px; text-align: left; vertical-align: top; }
th { width: 220px; background: #f8fafc; color: #475569; font-weight: 700; }
.notice { margin-top: 16px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 10px 12px; font-size: 10.5px; color: #134e4a; }
.signatures { display: grid; grid-template-columns: repeat(2, 1fr); gap: 24px; margin-top: 40px; }
.line { border-top: 1px solid #334155; padding-top: 8px; font-size: 10px; color: #475569; min-height: 40px; }
</style>
</head>
<body>
<div class="top">
<div>
${logoMarkup(opts.logoImageUrl)}
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>Equipment Interchange Receipt</h1>
</div>
<div class="meta">
Receipt No.
<strong>${esc(`EIR-${row.id.slice(0, 8).toUpperCase()}`)}</strong>
Generated: ${esc(new Date().toLocaleString('en-GB'))}
</div>
</div>
<table>
<tbody>
${rowsHtml}
</tbody>
</table>
<div class="notice">
This receipt confirms the physical interchange of the equipment described above at the
depot/location and time stated. Both parties should verify the container number, size,
and condition recorded here before signing.
</div>
<div class="signatures">
<div class="line">Depot officer name / signature / date</div>
<div class="line">Customer or driver name / signature / date</div>
</div>
</body>
</html>`;
}
private async getOrCreateCustoms(bookingId: string) {
const existing = await this.customs.findOne({ where: { bookingId } });
if (existing) return existing;

View File

@@ -32,6 +32,14 @@ export class CreateServiceTypeDto {
@IsBoolean()
includesCustoms?: boolean;
@ApiPropertyOptional({
default: false,
description: 'Customs cleared on the Ethiopian side only (alternative to full includesCustoms; implies it). Prices off the Ethiopian customs rate.',
})
@IsOptional()
@IsBoolean()
includesEthiopianCustomsOnly?: boolean;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()

View File

@@ -16,6 +16,7 @@ describe('deriveRateType — surcharge triggers', () => {
['DEMURRAGE', 'DEMURRAGE'],
['PIL_EXTRA_FEE', 'PIL_EXTRA_FEE'],
['CUSTOMS_CLEARANCE', 'CUSTOMS_CLEARANCE'],
['ETHIOPIAN_CUSTOMS_CLEARANCE', 'ETHIOPIAN_CUSTOMS_CLEARANCE'],
] as const)('maps trigger %s to %s', (trigger, expected) => {
expect(deriveRateType({ appliesTo: 'OTHER', trigger })).toBe(expected);
});

View File

@@ -46,6 +46,8 @@ export function deriveRateType(input: {
return 'PIL_EXTRA_FEE';
case 'CUSTOMS_CLEARANCE':
return 'CUSTOMS_CLEARANCE';
case 'ETHIOPIAN_CUSTOMS_CLEARANCE':
return 'ETHIOPIAN_CUSTOMS_CLEARANCE';
case 'FUEL':
return 'FUEL_SURCHARGE';
}

View File

@@ -28,7 +28,7 @@ export const isBulkQuantityUnit = (unit: string): boolean =>
export function allowedRateUnits(input: {
appliesTo: RateAppliesTo;
trigger: RateTrigger;
/** CUSTOMS_CLEARANCE / CANCELLATION only: which cargo kind the fee covers. */
/** Customs clearance / CANCELLATION only: which cargo kind the fee covers. */
cargoKind?: 'CONTAINER' | 'BULK' | null;
/** Unit of measure of the bulk commodity the rate is scoped to, when any. */
cargoUnitOfMeasure?: CargoUom;
@@ -67,6 +67,7 @@ function unitsForShape(input: {
// per wagon is the only unit the wagon-cancel flow can apply.
return ['PER_WAGON'];
case 'CUSTOMS_CLEARANCE':
case 'ETHIOPIAN_CUSTOMS_CLEARANCE':
// Sold per cargo kind: container fees bill per box or per wagon, bulk
// fees per ton or per wagon. Billed on the booking invoice.
return input.cargoKind === 'BULK'

View File

@@ -25,6 +25,7 @@ export const RATE_TYPES = [
'RETURN_SURCHARGE',
'PIL_EXTRA_FEE',
'CUSTOMS_CLEARANCE',
'ETHIOPIAN_CUSTOMS_CLEARANCE',
'FUEL_SURCHARGE',
] as const;
@@ -96,12 +97,22 @@ export const RATE_TRIGGERS = [
// Customs clearance service fee — billed up front via a clearance invoice,
// never auto-applied to booking pricing (matchesTrigger returns false).
'CUSTOMS_CLEARANCE',
// Same shape as CUSTOMS_CLEARANCE; priced instead of it when the booking's
// service type has includesEthiopianCustomsOnly (Ethiopian-side clearance).
'ETHIOPIAN_CUSTOMS_CLEARANCE',
// Fuel surcharge — fires when the booking's cargo type has hasFuel = true,
// billed off the lane-scoped rate (direction + route + cargo type).
'FUEL',
] as const;
export type RateTrigger = typeof RATE_TRIGGERS[number];
/**
* The two customs clearance service fees share one rate shape (per direction +
* route + cargo kind); only which one a booking prices off differs.
*/
export const isCustomsClearanceTrigger = (trigger: string): boolean =>
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'ETHIOPIAN_CUSTOMS_CLEARANCE';
@Entity({ schema: 'freight', name: 'rates' })
@Index(['rateType'])
@Index(['status'])
@@ -117,7 +128,7 @@ export class Rate extends BaseEntity {
@Column({ name: 'applies_to', type: 'varchar', length: 20, default: 'OTHER' })
appliesTo!: RateAppliesTo;
@Column({ name: 'trigger', type: 'varchar', length: 20, default: 'ALWAYS' })
@Column({ name: 'trigger', type: 'varchar', length: 30, default: 'ALWAYS' })
trigger!: RateTrigger;
@Column({ name: 'container_type_id', type: 'uuid', nullable: true })

View File

@@ -27,6 +27,16 @@ export class ServiceType extends BaseEntity {
@Column({ name: 'includes_customs', type: 'boolean', default: false })
includesCustoms!: boolean;
/**
* EDR clears customs on the Ethiopian side only. The admin picks full customs
* OR Ethiopian-only, never both; the API stores includesCustoms = true for
* either so every clearance read (GL review, duty, docs) stays unchanged —
* only the fee differs: pricing looks up ETHIOPIAN_CUSTOMS_CLEARANCE instead
* of CUSTOMS_CLEARANCE.
*/
@Column({ name: 'includes_ethiopian_customs_only', type: 'boolean', default: false })
includesEthiopianCustomsOnly!: boolean;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;

View File

@@ -13,7 +13,7 @@ import { ShippingLineCompaniesService } from '../../shipping-lines/shipping-line
import { CreateRateDto } from '../dto/create-rate.dto';
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
import { UpdateRateDto } from '../dto/update-rate.dto';
import { Rate } from '../entities/rate.entity';
import { Rate, isCustomsClearanceTrigger } from '../entities/rate.entity';
import { deriveRateType } from '../entities/rate-type.util';
import { CargoUom, allowedRateUnits, isRateUnitAllowed } from '../entities/rate-unit.util';
import {
@@ -29,10 +29,15 @@ const BASE_FREIGHT_CATEGORIES: readonly Rate['appliesTo'][] = ['BULK', 'CONTAINE
* Surcharges sold per cargo kind: the admin says container or bulk, a
* container fee then names its container type and a bulk fee its commodity.
*/
const CARGO_KIND_TRIGGERS: readonly Rate['trigger'][] = ['CUSTOMS_CLEARANCE', 'CANCELLATION'];
const CARGO_KIND_TRIGGERS: readonly Rate['trigger'][] = [
'CUSTOMS_CLEARANCE',
'ETHIOPIAN_CUSTOMS_CLEARANCE',
'CANCELLATION',
];
/** Surcharges that keep a trade direction (everything else is direction-agnostic). */
const DIRECTED_SURCHARGE_TRIGGERS: readonly Rate['trigger'][] = [
'CUSTOMS_CLEARANCE',
'ETHIOPIAN_CUSTOMS_CLEARANCE',
'CANCELLATION',
'WITH_RETURN',
'LASHING',
@@ -144,7 +149,7 @@ export class RatesService {
private isRouteScoped(appliesTo: Rate['appliesTo'], trigger: Rate['trigger']): boolean {
return (
this.isBaseFreight(appliesTo, trigger) ||
trigger === 'CUSTOMS_CLEARANCE' ||
isCustomsClearanceTrigger(trigger) ||
trigger === 'WITH_RETURN' ||
trigger === 'FUEL'
);
@@ -261,7 +266,7 @@ export class RatesService {
}): void {
const { appliesTo, trigger, tradeDirection, intercityKind, cargoKind } = input;
const { containerTypeId, cargoTypeId } = input;
if (trigger === 'CUSTOMS_CLEARANCE' || trigger === 'CANCELLATION') {
if (isCustomsClearanceTrigger(trigger) || trigger === 'CANCELLATION') {
// Both fees are sold per direction + cargo kind + type: customs clearance
// per lane, the wagon cancellation fee per direction only.
const fee = trigger === 'CANCELLATION' ? 'cancellation fee' : 'customs clearance';

View File

@@ -1,5 +1,11 @@
import { PaginatedResponse } from '@edr/types';
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
ConflictException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { generateCode } from '../../../common/utils/generate-code.util';
import { CreateServiceTypeDto } from '../dto/create-service-type.dto';
import { ListServiceTypesQueryDto } from '../dto/list-rule-engine-query.dto';
@@ -43,6 +49,10 @@ export class ServiceTypesService {
const existing = await this.repository.findByCode(code);
if (existing) throw new ConflictException(`Service type with name "${dto.serviceName}" conflicts with existing code "${code}"`);
const customs = this.resolveCustomsFlags(
dto.includesCustoms ?? false,
dto.includesEthiopianCustomsOnly ?? false,
);
const displayOrder = await this.displayOrder.resolveCreateOrder(ServiceType, 'displayOrder', {
explicitOrder: dto.displayOrder,
insertAfterId: dto.insertAfterId,
@@ -55,7 +65,7 @@ export class ServiceTypesService {
canBeBookedAlone: dto.canBeBookedAlone ?? true,
includesFirstMile: dto.includesFirstMile ?? false,
includesLastMile: dto.includesLastMile ?? false,
includesCustoms: dto.includesCustoms ?? false,
...customs,
isActive: dto.isActive ?? true,
displayOrder,
});
@@ -63,13 +73,44 @@ export class ServiceTypesService {
/** Update an existing service type. */
async update(id: string, dto: UpdateServiceTypeDto): Promise<ServiceType> {
await this.findById(id);
const { ...patch } = dto;
const existing = await this.findById(id);
const ethiopian = dto.includesEthiopianCustomsOnly ?? existing.includesEthiopianCustomsOnly;
// The form sends both flags whenever either is touched; a payload with only
// one is a plain edit (name, order…) that keeps the stored pair.
const customs =
dto.includesCustoms !== undefined || dto.includesEthiopianCustomsOnly !== undefined
? this.resolveCustomsFlags(
dto.includesCustoms ?? (existing.includesCustoms && !existing.includesEthiopianCustomsOnly),
ethiopian,
)
: {};
const patch = { ...dto, ...customs };
const updated = await this.repository.update(id, patch);
if (!updated) throw new NotFoundException(`Service type ${id} not found`);
return updated;
}
/**
* Full customs and Ethiopian-only customs are alternatives: the admin picks
* one. Ethiopian-only is still a customs service, so it is stored with
* includesCustoms = true — every clearance read keeps working unchanged and
* only pricing looks at the Ethiopian flag.
*/
private resolveCustomsFlags(
includesCustoms: boolean,
ethiopianOnly: boolean,
): Pick<ServiceType, 'includesCustoms' | 'includesEthiopianCustomsOnly'> {
if (includesCustoms && ethiopianOnly) {
throw new BadRequestException(
'Pick either "Includes customs" or "Ethiopian customs only", not both.',
);
}
return {
includesCustoms: includesCustoms || ethiopianOnly,
includesEthiopianCustomsOnly: ethiopianOnly,
};
}
/** Soft-delete a service type. */
async remove(id: string): Promise<void> {
await this.findById(id);

View File

@@ -17,8 +17,9 @@ export type WagonAdjustmentAction = (typeof WAGON_ADJUSTMENT_ACTIONS)[number];
@Index(['trainScheduleId'])
@Index(['trainId'])
export class ScheduleWagonAdjustmentLog extends BaseEntity {
@Column({ name: 'train_schedule_id', type: 'uuid' })
trainScheduleId!: string;
/** Null when the change was made from the train builder with no live schedule. */
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
trainScheduleId!: string | null;
@Column({ name: 'train_id', type: 'uuid' })
trainId!: string;

View File

@@ -120,6 +120,43 @@ export class TrainSchedule extends BaseEntity {
@Column({ name: 'max_wagons', type: 'int', default: 53 })
maxWagons!: number;
/**
* Where THIS departure plans to board each consist wagon: `{ wagonId: yardId }`.
* Sparse — a wagon absent from the map boards from its physical
* `wagons.current_yard_id`. Independent of the built train's physical spread
* so a departure can be sold from Dire while the steel still stands in Mojo;
* dispatch requires plan and physical yards to agree.
*/
@Column({ name: 'planned_wagon_yards', type: 'jsonb', nullable: true })
plannedWagonYards?: Record<string, string> | null;
/**
* Where THIS departure plans to CUT (detach and leave) each consist wagon:
* `{ wagonId: yardId }`. Sparse — a wagon absent from the map rides to the
* schedule destination. A cap, not a promise: cargo may alight earlier, but
* validation forbids cargo allocated past the cut.
*/
@Column({ name: 'planned_wagon_cut_yards', type: 'jsonb', nullable: true })
plannedWagonCutYards?: Record<string, string> | null;
/**
* LOOSE wagons this departure plans to COUPLE onto the train at a route
* stop: `{ wagonId: pickupYardId }`. They join the built train permanently
* when the trip reaches that stop (dispatch for the origin, checkpoint log
* for mid-route stops).
*/
@Column({ name: 'planned_wagon_couples', type: 'jsonb', nullable: true })
plannedWagonCouples?: Record<string, string> | null;
/**
* Cut wagons (see plannedWagonCutYards) flagged as REAL cuts: the built
* train permanently loses the wagon at its cut yard. Absent from this list,
* a cut is soft — the wagon sits out the rest of this trip but stays in
* the build.
*/
@Column({ name: 'planned_wagon_real_cuts', type: 'jsonb', nullable: true })
plannedWagonRealCuts?: string[] | null;
/** OPEN = accepting/holding bookings; FULL = train filled; CLOSED = manually closed. Orthogonal to `status`. */
@Column({ name: 'booking_window_status', type: 'varchar', length: 10, default: 'OPEN' })
bookingWindowStatus!: string;

View File

@@ -18,6 +18,30 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
return manager ? manager.getRepository(TrainSchedule) : this.repository;
}
/**
* Slim consist view for read paths that only need the route stops, the
* built train, and slot→allocation existence (e.g. the schedule-yards tab):
* skips the booking/company/container branches of the full graph, which
* dominate its cost and go unused there.
*/
findByIdWithConsistLite(id: string): Promise<TrainSchedule | null> {
return this.repository.findOne({
where: { id },
relationLoadStrategy: 'query',
relations: {
route: { milestones: { yard: true } },
trainSet: {
train: true,
locomotive: true,
locomotives: { locomotive: true },
wagons: { allocations: true },
},
originStation: true,
destinationStation: true,
},
});
}
findByIdWithFullGraph(id: string, manager?: EntityManager): Promise<TrainSchedule | null> {
return this.repo(manager).findOne({
where: { id },

View File

@@ -246,6 +246,71 @@ describe('BookingBatchService — PAID reconcile', () => {
expect(reconcileOrder).toBeLessThan(wagonOrder);
});
describe('expire — consolidated pair, one side paid', () => {
const pairBooking = (id: string, partnerId: string, paid: boolean): Booking =>
({
id,
reference: id,
status: paid ? 'PAID' : 'SELECTED_FOR_BATCH',
paymentStatus: paid ? 'PAID' : 'PENDING',
consolidationPartnerId: partnerId,
trainScheduleId: null,
bookingContainers: [],
}) as unknown as Booking;
let emit: jest.Mock;
let unpaid: Booking;
let paid: Booking;
beforeEach(() => {
unpaid = pairBooking('unpaid-1', 'paid-1', false);
paid = pairBooking('paid-1', 'unpaid-1', true);
emit = jest.fn();
(service as unknown as { eventEmitter: { emit: jest.Mock } }).eventEmitter = { emit };
(bookingsRepository as unknown as { clearConsolidationPair: jest.Mock }).clearConsolidationPair =
jest.fn().mockResolvedValue(undefined);
dataSource.getRepository().findOne.mockImplementation(
async ({ where }: { where: { id: string } }) =>
where.id === 'paid-1' ? paid : unpaid,
);
});
it('expires the unpaid side fee-free and cancels the PAID partner via partnerLapsed', async () => {
await (service as unknown as { expire(b: Booking): Promise<void> }).expire(unpaid);
// Paid partner is NOT rescued onto a train — the listener cancels it with the fee.
expect(emit).toHaveBeenCalledWith('booking.consolidation.partnerLapsed', {
paidBookingId: 'paid-1',
});
expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled();
// The unpaid side itself just expires.
expect(bookingsRepository.update).toHaveBeenCalledWith(
'unpaid-1',
expect.objectContaining({ status: 'EXPIRED' }),
);
expect(notifier.expired).toHaveBeenCalledTimes(1);
});
it('wrong side called first: PAID booking is cancelled via partnerLapsed, never rescued', async () => {
await (service as unknown as { expire(b: Booking): Promise<void> }).expire(paid);
expect(emit).toHaveBeenCalledWith('booking.consolidation.partnerLapsed', {
paidBookingId: 'paid-1',
});
// The unpaid partner expired fee-free…
expect(bookingsRepository.update).toHaveBeenCalledWith(
'unpaid-1',
expect.objectContaining({ status: 'EXPIRED' }),
);
// …and the paid side was neither expired nor allocated here.
expect(bookingsRepository.update).not.toHaveBeenCalledWith(
'paid-1',
expect.objectContaining({ status: 'EXPIRED' }),
);
expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled();
});
});
describe('extendPaymentPhaseForTopUp', () => {
const schedRepo = () => dataSource.getRepository();
@@ -1536,4 +1601,72 @@ describe('BookingBatchService — physical wagon-type gate', () => {
// Those 16 are now held, so the next booking in the pass cannot re-take them.
expect(stock.availableFor([NW5], WHOLE_LEG)).toBe(0);
});
it('sizes a capped-bulk partial on ONE type at the cargo cap, not the 70T rating', async () => {
const svc = service();
const inner = internals(svc);
(inner as { isSplitEligible: unknown }).isSplitEligible = () => true;
const dims = { lengthMeters: 14, tareWeightTons: 24, capacityTons: 70 };
(inner as unknown as { loadWagonDims: unknown }).loadWagonDims = async () => ({
container: dims,
bulk: dims,
byWagonTypeId: new Map([
[NW5, dims],
[PW2, dims],
]),
});
const tryPartial = jest
.fn()
.mockResolvedValue({ wagons: 16, weightTons: 864, lengthMeters: 224 });
(inner as { tryPartialOffer: unknown }).tryPartialOffer = tryPartial;
const stock = mixedStock();
const candidate = {
id: 'schedule-1',
budget: {
legOf: () => WHOLE_LEG,
remainingFor: () => ({ wagons: 20, weightTons: 99_999, lengthMeters: 99_999 }),
subtract: jest.fn(),
},
armed: false,
stock,
};
const booking = {
id: 'b2',
reference: 'BK-2',
originYardId: 'a',
destinationYardId: 'b',
freightType: 'BULK',
cargoTotalWeightVgm: 695,
cargoType: {
id: 'cargo-perishable',
wagonTypes: [
{ id: NW5, capacityTons: 70 },
{ id: PW2, capacityTons: 70 },
],
tonsPerWagonMap: { [NW5]: 30, [PW2]: 20 },
},
bookingContainers: [],
} as unknown as Booking;
const offered = await inner.maybeOfferPartial(
booking,
false,
[candidate],
{ wagons: 24, weightTons: 1400, lengthMeters: 336 },
[NW5, PW2],
);
expect(offered).toBe(true);
// Room capped to the 16 NW5 that exist (biggest capped take), and the seat
// carries the 30T cargo cap — never the wagon's raw 70T rating.
expect(tryPartial.mock.calls[0][2]).toMatchObject({ wagons: 16 });
expect(tryPartial.mock.calls[0][4]).toMatchObject({
wagonTypeId: NW5,
perWagon: { capacityTons: 30 },
});
// Only the seated type is held; the PW2s stay free for bulk-only cargo.
expect(stock.availableFor([NW5], WHOLE_LEG)).toBe(0);
expect(stock.availableFor([PW2], WHOLE_LEG)).toBe(4);
});
});

View File

@@ -10,6 +10,7 @@ import {
Optional,
} from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { SchedulerRegistry } from '@nestjs/schedule';
import {
Between,
@@ -92,13 +93,16 @@ import { BookingWindowGateway } from './booking-window.gateway';
import {
MAX_TEU_SLOTS_PER_WAGON,
containerWagonsForLines,
roundTons,
} from './utils/wagon-plan.util';
import {
Capacity,
CorridorBudget,
CorridorLeg,
OverageTolerance,
addCoupledWagons,
stopYardsFor,
subtractCutWagons,
} from './corridor-capacity.util';
import { WagonStockLedger } from './wagon-stock-ledger.util';
@@ -398,6 +402,8 @@ export class BookingBatchService implements OnModuleInit {
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
@Optional() private readonly splitService?: BookingSplitService,
// Optional so hand-constructed spec instances keep compiling.
@Optional() private readonly eventEmitter?: EventEmitter2,
@Optional()
@Inject(forwardRef(() => RemainderPlacementService))
private readonly remainderPlacement?: RemainderPlacementService,
@@ -1218,12 +1224,7 @@ export class BookingBatchService implements OnModuleInit {
schedule.originStationId,
budget.stops,
);
const ledger = new WagonStockLedger(
stock.remainingByTypeId,
Math.max(1, budget.stops.length - 1),
stock.byYardId,
budget.stops,
);
const ledger = await this.stockLedgerFor(schedule, budget, [booking.id]);
// On a multi-yard consist the pool that matters is the one standing at
// the booking's own boarding yard — a type carried only in Mojo must not
// be advertised to a customer boarding at Dire.
@@ -1489,6 +1490,44 @@ export class BookingBatchService implements OnModuleInit {
"Train is full — no export capacity left for this day",
);
}
// Physical wagon gate — a pay window must never open for wagons that do
// not exist in a type this cargo can ride. PER_TON bulk is seated
// type-by-type at its per-wagon caps (the count allocation will really
// need); everything else checks the summed free stock of its types.
const stock = await this.stockLedgerFor(
schedule,
budget,
bookings.map((b) => b.id),
);
const allowedWagonTypes = await this.loadAllowedWagonTypeIds();
const primary = bookings[0];
const wagonTypeIds = this.allowedWagonTypeIdsFor(primary, allowedWagonTypes);
const perItemBulk =
Number(primary.bulkTotalWeightTons ?? 0) > 0 &&
Number(primary.cargoTotalWeightVgm ?? 0) > 0;
const useSmart =
bookings.length === 1 &&
primary.freightType === "BULK" &&
!perItemBulk &&
wagonTypeIds.length > 0;
const smart = useSmart
? this.smartBulkNeed(
primary,
wagonDims,
stock,
leg,
this.scarcityRankForPool([primary], allowedWagonTypes),
wagonTypeIds,
)
: null;
const seated = useSmart
? smart != null && budget.fits(smart.need, leg)
: this.hasWagonStock(stock, wagonTypeIds, need.wagons, leg);
if (!seated) {
throw new ConflictException(
"Train has no free wagons of a type this cargo can ride — payment was not opened",
);
}
for (const b of bookings) await this.reserve(b, scheduleId);
});
@@ -2280,6 +2319,7 @@ export class BookingBatchService implements OnModuleInit {
await this.recomputeBulkPriorities(pool, wagonDims);
this.resortPoolByPriority(pool, await this.windowCycleIndexer(schedule));
const units = this.groupConsolidatedPool(pool);
const scarcityRank = this.scarcityRankForPool(pool, allowedWagonTypes);
let armed = false;
let preempted = false;
let reservedThisPass = 0;
@@ -2306,17 +2346,34 @@ export class BookingBatchService implements OnModuleInit {
const leg = budget.legForYards(booking.originYardId, booking.destinationYardId);
const wagonTypeIds = this.allowedWagonTypeIdsFor(booking, allowedWagonTypes);
// Abstract room AND real wagons of a type this booking can ride — see
// fillRouteDayInternal for why both gates are needed.
const stocked = this.hasWagonStock(stock, wagonTypeIds, need.wagons, leg);
// fillRouteDayInternal for why both gates are needed. PER_TON bulk
// singles get the smart gate (exact per-type seating at the cargo's
// caps); a booking is only reserved — and only ever invoiced — when
// that seating is proven against the train's actual free wagons.
const perItemBulk =
Number(booking.bulkTotalWeightTons ?? 0) > 0 &&
Number(booking.cargoTotalWeightVgm ?? 0) > 0;
const useSmart =
!isPair &&
booking.freightType === "BULK" &&
!perItemBulk &&
wagonTypeIds.length > 0;
const smart = useSmart
? this.smartBulkNeed(booking, wagonDims, stock, leg, scarcityRank, wagonTypeIds)
: null;
const admitted = useSmart
? smart != null && budget.fits(smart.need, leg)
: budget.fits(need, leg) &&
this.hasWagonStock(stock, wagonTypeIds, need.wagons, leg);
// Per-unit fit trace: which axis (wagons/weight/length/stock) admits or rejects.
this.logger.debug(
`[fillSchedule ${scheduleId}] unit ${booking.reference}: need=${JSON.stringify(need)} ` +
`roomOnLeg=${JSON.stringify(budget.remainingFor(leg))} fits=${budget.fits(need, leg)} ` +
`stocked=${stocked}`,
`[fillSchedule ${scheduleId}] unit ${booking.reference}: need=${JSON.stringify(
smart?.need ?? need,
)} roomOnLeg=${JSON.stringify(budget.remainingFor(leg))} admitted=${admitted}`,
);
if (!budget.fits(need, leg) || !stocked) {
if (!admitted) {
if (isGov) {
const freed = await this.preemptForGovernment(
scheduleId,
@@ -2360,9 +2417,16 @@ export class BookingBatchService implements OnModuleInit {
armed = true;
commercialReserved += 1;
}
budget.subtract(need, leg);
budget.subtract(smart?.need ?? need, leg);
// Hold the physical wagons too — the next unit must not re-count them.
stock.consume(wagonTypeIds, need.wagons, leg);
// The smart gate holds the exact per-type counts it seated.
if (smart) {
for (const part of smart.perType) {
stock.consume([part.wagonTypeId], part.wagons, leg);
}
} else {
stock.consume(wagonTypeIds, need.wagons, leg);
}
reservedThisPass += 1;
} catch (err) {
this.logger.error(
@@ -2537,6 +2601,10 @@ export class BookingBatchService implements OnModuleInit {
// Consolidated partners collapse into one atomic unit (both-or-neither); a
// consolidated booking whose partner isn't ready this cycle is skipped.
const units = this.groupConsolidatedPool(pool);
// Least-shareable-type-first seating for bulk (see smartBulkNeed): ranked
// once against the whole pool, so what containers will need is known
// before any bulk booking picks its wagons.
const scarcityRank = this.scarcityRankForPool(pool, allowedWagonTypes);
// Batch fill trace: each train's caps + the day pool size at entry.
this.logger.debug(
@@ -2560,18 +2628,54 @@ export class BookingBatchService implements OnModuleInit {
// Consolidated pairs share one wagon set; the primary's types stand for both.
const wagonTypeIds = this.allowedWagonTypeIdsFor(booking, allowedWagonTypes);
// PER_TON bulk singles get the smart gate: seated type-by-type at the
// cargo's per-wagon caps, scarcest type first — the count the allocator
// will actually need, not a one-type estimate. Pairs, PER_ITEM and
// unconfigured cargo keep the generic gate (gov preemption and partial
// offers below also still size on the generic `need`).
const perItemBulk =
Number(booking.bulkTotalWeightTons ?? 0) > 0 &&
Number(booking.cargoTotalWeightVgm ?? 0) > 0;
const useSmart =
!isPair &&
booking.freightType === "BULK" &&
!perItemBulk &&
wagonTypeIds.length > 0;
let smart: {
need: Capacity;
perType: Array<{ wagonTypeId: string; wagons: number }>;
} | null = null;
// First train (earliest departure) whose corridor carries this booking's
// leg, still fits it as-is AND physically holds enough wagons of a type the
// booking can ride. Both gates matter: abstract room without the right
// wagon type is space the allocator can never turn into a loaded consist.
let target = trains.find((t) => {
let target: (typeof trains)[number] | undefined;
for (const t of trains) {
const leg = legOn(t);
return (
leg != null &&
if (leg == null) continue;
if (useSmart) {
const probe = this.smartBulkNeed(
booking,
wagonDims,
t.stock,
leg,
scarcityRank,
wagonTypeIds,
);
if (probe != null && t.budget.fits(probe.need, leg)) {
smart = probe;
target = t;
break;
}
} else if (
t.budget.fits(need, leg) &&
this.hasWagonStock(t.stock, wagonTypeIds, need.wagons, leg)
);
});
) {
target = t;
break;
}
}
// Per-unit trace: chosen train + each train's remaining room on this leg.
this.logger.debug(
@@ -2650,10 +2754,18 @@ export class BookingBatchService implements OnModuleInit {
target.armed = true;
commercialReserved += 1;
}
target.budget.subtract(need, legOn(target)!);
target.budget.subtract(smart?.need ?? need, legOn(target)!);
// Hold the physical wagons too, so the next unit in this pass sees them
// gone — otherwise two bookings both "fit" the same 16 NW5.
target.stock.consume(wagonTypeIds, need.wagons, legOn(target)!);
// gone — otherwise two bookings both "fit" the same 16 NW5. The smart
// gate holds the EXACT per-type counts it seated (10 PW2 + 17 NW5),
// not a type-blind total drained deepest-first.
if (smart) {
for (const part of smart.perType) {
target.stock.consume([part.wagonTypeId], part.wagons, legOn(target)!);
}
} else {
target.stock.consume(wagonTypeIds, need.wagons, legOn(target)!);
}
target.changed = true;
reservedThisPass += 1;
} catch (err) {
@@ -2729,6 +2841,21 @@ export class BookingBatchService implements OnModuleInit {
wagonTypeIds: string[] = [],
): Promise<boolean> {
if (!this.isSplitEligible(booking, isPair)) return false;
// PER_TON bulk partials are sized on ONE concrete wagon type at the
// cargo's per-wagon cap — sizing on the first type's raw 70T rating
// offered tonnage the wagons could never carry (Perishable caps at
// 20/30T), taking payment for cargo that stalls at allocation.
// ponytail: single-type bulk partials; a multi-type partial (PW2+NW5
// mixed) is the upgrade path if offers come out too small.
const perItemBulk =
Number(booking.bulkTotalWeightTons ?? 0) > 0 &&
Number(booking.cargoTotalWeightVgm ?? 0) > 0;
const cappedBulk =
!isPair &&
booking.freightType === "BULK" &&
!perItemBulk &&
wagonTypeIds.length > 0;
const wagonDims = cappedBulk ? await this.loadWagonDims() : null;
const target = candidates
.map((c) => {
const leg = c.budget.legOf(booking.originYardId, booking.destinationYardId);
@@ -2739,12 +2866,43 @@ export class BookingBatchService implements OnModuleInit {
// them NW5" into an offer for 16 — the customer pays for 16 and the
// other 4 leave as the usual remainder booking, instead of paying for
// 20 and stalling at allocation on wagon 17.
if (cappedBulk && wagonDims) {
// Types resolved from the id list (join tables), never the pool
// entity's unloaded cargoType.wagonTypes relation — see smartBulkNeed.
const best = [...new Set(wagonTypeIds)]
.map((wagonTypeId) => ({
wagonTypeId,
dims: wagonDims.byWagonTypeId.get(wagonTypeId),
}))
.filter((o): o is { wagonTypeId: string; dims: PerWagonDims } => o.dims != null)
.map((o) => ({
...o,
free: c.stock?.availableFor([o.wagonTypeId], leg) ?? 0,
takePerWagon: bulkTonsPerWagon(
booking.cargoType,
o.wagonTypeId,
o.dims.capacityTons,
),
}))
.filter((o) => o.free > 0 && o.takePerWagon > 0)
.sort((a, b) => b.takePerWagon - a.takePerWagon)[0];
if (!best) return null;
return {
c,
leg,
room: { ...room, wagons: Math.min(room.wagons, best.free) },
seat: {
wagonTypeId: best.wagonTypeId,
perWagon: { ...best.dims, capacityTons: best.takePerWagon },
},
};
}
const physical = wagonTypeIds.length
? c.stock?.availableFor(wagonTypeIds, leg)
: undefined;
const wagons =
physical == null ? room.wagons : Math.min(room.wagons, physical);
return { c, leg, room: { ...room, wagons } };
return { c, leg, room: { ...room, wagons }, seat: undefined };
})
.filter((x): x is NonNullable<typeof x> => x != null && x.room.wagons >= 1)
.sort((a, b) => b.room.wagons - a.room.wagons)[0];
@@ -2754,10 +2912,15 @@ export class BookingBatchService implements OnModuleInit {
target.c.id,
target.room,
need,
target.seat,
);
if (!offered) return false;
target.c.budget.subtract(offered, target.leg);
target.c.stock?.consume(wagonTypeIds, offered.wagons, target.leg);
target.c.stock?.consume(
target.seat ? [target.seat.wagonTypeId] : wagonTypeIds,
offered.wagons,
target.leg,
);
target.c.armed = true;
return true;
}
@@ -2772,6 +2935,12 @@ export class BookingBatchService implements OnModuleInit {
scheduleId: string,
budget: Capacity,
need: Capacity,
/**
* Capped-bulk seating (see maybeOfferPartial): the ONE wagon type this
* offer rides, with capacityTons already reduced to the cargo's per-wagon
* cap — so the offered tonnage is what those wagons can really carry.
*/
seat?: { wagonTypeId: string; perWagon: PerWagonDims },
): Promise<Capacity | null> {
if (!this.splitService) return null;
// A consolidated booking is already half of a shared wagon — never split it.
@@ -2789,8 +2958,14 @@ export class BookingBatchService implements OnModuleInit {
// measured on the booking's REAL wagon type — the same one allocation
// validates against. Bulk splits ride FULL wagons only: the offer never
// part-loads its last wagon.
const perWagon = this.dimsFor(booking, wagonDims);
const partial = sizePartialOfferWagons(budget, need.wagons, perWagon, {
const perWagon = seat?.perWagon ?? this.dimsFor(booking, wagonDims);
// With a capped seat, the whole booking's wagon count follows the cap too
// (695T at 30T/wagon = 24, not 10 at the raw rating) — the offer must be a
// strict subset of THAT count.
const wholeWagons = seat
? Math.max(1, Math.ceil(bookingCargoTons(booking) / perWagon.capacityTons))
: need.wagons;
const partial = sizePartialOfferWagons(budget, wholeWagons, perWagon, {
fullWagonsOnly: booking.freightType === "BULK",
});
if (!partial) return null;
@@ -2798,7 +2973,7 @@ export class BookingBatchService implements OnModuleInit {
const sized = await this.splitService.sizeOffer(
booking,
partial.wagons,
need.wagons,
wholeWagons,
perWagon.capacityTons,
partial.maxCargoTons,
);
@@ -2837,8 +3012,9 @@ export class BookingBatchService implements OnModuleInit {
* Settle a schedule's reserved bookings. `expireUnpaidUnknownDeadline` decides
* how to treat a reservation with no deadline (durable path: leave it; timeout
* path: expire it). Consolidated pairs settle atomically: both allocate only
* when both paid; if either partner expires, both expire (a half-paid shared
* wagon must not ship). Returns whether anything changed.
* when both paid; when neither paid, both expire. A half-paid pair splits:
* the paid half keeps the whole wagon, the lapsed half expires and owes the
* cancellation fee (expire()'s pair cascade). Returns whether anything changed.
*/
private async settleReserved(
scheduleId: string,
@@ -2881,8 +3057,10 @@ export class BookingBatchService implements OnModuleInit {
await this.allocate(scheduleId, partner, "paid");
anySettled = true;
} else if (isExpired(booking) || isExpired(partner)) {
// One call is enough: expire()'s pair cascade settles both sides —
// both expire when neither paid; a paid half is rescued (keeps the
// whole wagon) while the lapsed half expires with its fee.
await this.expire(booking);
await this.expire(partner);
anySettled = true;
}
continue;
@@ -3813,14 +3991,68 @@ export class BookingBatchService implements OnModuleInit {
* taken, so it boards, even when the webhook arrived after the deadline or the
* settle read a stale row. It allocates onto the train it was selected for; if
* the wagon planner then finds no physical wagon, the booking stays linked and
* staff assign wagons manually. Consolidated bookings are exempt from the
* rescue: the shared wagon is both-or-neither, and settleReserved owns that
* pair decision.
* staff assign wagons manually. EXCEPTION — a consolidated booking whose
* partner lapsed unpaid is NOT rescued: its odd 20ft cannot board without the
* partner, so the paid side is cancelled with the cancellation fee (the
* partnerLapsed listener in BookingWagonCancellationService).
*/
private async expire(
booking: Booking,
reason: "payment" | "no-capacity" = "payment",
): Promise<void> {
// Consolidated pair: break the link FIRST, then settle each side singly.
// - neither paid → both expire, no fee.
// - one side paid → BOTH die: the unpaid half expires fee-free (fees only
// apply to paid bookings); the paid half cannot board alone, so the
// 'partnerLapsed' event cancels it with the cancellation fee on ceil of
// its wagons (BookingWagonCancellationService) — paid freight kept as
// rebooking credit for GL staff.
// - both paid → nothing to expire; the paid guard rescues.
if (booking.consolidationPartnerId) {
const partnerId = booking.consolidationPartnerId;
const bookingRepo = this.dataSource.getRepository(Booking);
const partnerRow = await bookingRepo.findOne({
where: { id: partnerId },
relations: { company: true },
});
const freshSelf = await bookingRepo.findOne({
where: { id: booking.id },
});
const paidOf = (b: Booking | null) =>
b != null && (b.paymentStatus === "PAID" || b.status === "PAID");
const selfPaid = paidOf(freshSelf);
const partnerPaid = paidOf(partnerRow);
await this.bookingsRepository.clearConsolidationPair(
booking.id,
partnerId,
);
booking.consolidationPartnerId = null;
if (partnerRow) partnerRow.consolidationPartnerId = null;
if (selfPaid && !partnerPaid) {
// Wrong side called first: the unpaid partner expires fee-free; this
// PAID booking cannot board without it, so the listener cancels it
// with the cancellation fee — never rescued.
if (partnerRow && !["EXPIRED", "CANCELLED"].includes(partnerRow.status)) {
await this.expire(partnerRow, reason);
}
this.eventEmitter?.emit("booking.consolidation.partnerLapsed", {
paidBookingId: booking.id,
});
return;
} else if (!selfPaid && partnerPaid) {
// This unpaid side expires below, fee-free; the PAID partner cannot
// board alone, so the listener cancels it with the cancellation fee.
this.eventEmitter?.emit("booking.consolidation.partnerLapsed", {
paidBookingId: partnerId,
});
} else if (!selfPaid && !partnerPaid) {
if (partnerRow && !["EXPIRED", "CANCELLED"].includes(partnerRow.status)) {
await this.expire(partnerRow, reason);
}
}
}
if (!booking.consolidationPartnerId) {
const fresh = await this.dataSource
.getRepository(Booking)
@@ -4102,7 +4334,50 @@ export class BookingBatchService implements OnModuleInit {
// and push once per schedule after the sweep (most unaccepted rows are
// unpinned under day-level pooling, so this usually emits nothing).
const touchedScheduleIds = new Set<string>();
const swept = new Set<string>();
for (const booking of unaccepted) {
if (swept.has(booking.id)) continue;
swept.add(booking.id);
// Consolidated pair: the partner may sit outside this route-day's result
// set (different yards/day/status), so cascade explicitly — an unpaid
// partner expires with this booking, fee-free; a PAID partner cannot
// board alone, so partnerLapsed cancels it with the cancellation fee.
if (booking.consolidationPartnerId) {
const partner = await this.dataSource.getRepository(Booking).findOne({
where: { id: booking.consolidationPartnerId },
relations: { company: true },
});
await this.bookingsRepository.clearConsolidationPair(
booking.id,
booking.consolidationPartnerId,
);
booking.consolidationPartnerId = null;
if (partner) {
const partnerPaid =
partner.paymentStatus === "PAID" || partner.status === "PAID";
if (partnerPaid) {
this.eventEmitter?.emit("booking.consolidation.partnerLapsed", {
paidBookingId: partner.id,
});
} else if (!["EXPIRED", "CANCELLED"].includes(partner.status)) {
swept.add(partner.id);
partner.consolidationPartnerId = null;
if (partner.trainScheduleId) touchedScheduleIds.add(partner.trainScheduleId);
await this.bookingsRepository.update(partner.id, {
status: "EXPIRED",
schedulingStatus: "ELIGIBLE",
scheduledDate: null,
} as never);
await this.billing
.expirePayable(Freight.InvoiceSource.Booking, partner.id, "PREPAID")
.catch(() => undefined);
this.notifier.expired(partner);
this.logger.log(
`[BATCH] EXPIRED (unaccepted, with consolidation partner) ${partner.reference}:${partner.id} at doc-review end`,
);
}
}
}
if (booking.trainScheduleId) touchedScheduleIds.add(booking.trainScheduleId);
await this.bookingsRepository.update(booking.id, {
status: "EXPIRED",
@@ -4782,18 +5057,60 @@ export class BookingBatchService implements OnModuleInit {
private async stockLedgerFor(
schedule: TrainSchedule,
budget: CorridorBudget,
excludeBookingIds?: string[],
): Promise<WagonStockLedger> {
const stock = await this.trainSchedulingService.wagonStockForSchedule(
schedule.id,
schedule.originStationId,
budget.stops,
);
return new WagonStockLedger(
const ledger = new WagonStockLedger(
stock.remainingByTypeId,
Math.max(1, budget.stops.length - 1),
stock.byYardId,
budget.stops,
);
// Wagons staff cut mid-route are not stock past their cut stop.
ledger.debitCutWagons(stock.cutWagons ?? []);
// Debit what is already committed, per boarding yard and wagon type — the
// same bookings the corridor budget subtracted. A booking with no resolvable
// wagon type still occupies steel, so it drains any type at its yard.
const [wagonDims, allowed] = await Promise.all([
this.loadWagonDims(),
this.loadAllowedWagonTypeIds(),
]);
const anyType = [...stock.remainingByTypeId.keys()];
const committed = await this.committedBookings(schedule, excludeBookingIds);
// Debit committed PER_TON bulk the way it was SEATED — per type at the
// cargo's caps, scarcest type first — not a one-type wagon count drained
// deepest-first (which mis-charged 695T Perishable as 24 NW5 when it holds
// 10 PW2 + 17 NW5, so later passes over-counted free PW2 and sold NW5 that
// were already spoken for).
const rank = this.scarcityRankForPool(committed, allowed);
for (const b of committed) {
const typeIds = this.allowedWagonTypeIdsFor(b, allowed);
const leg = budget.legForYards(b.originYardId, b.destinationYardId);
const perItemBulk =
Number(b.bulkTotalWeightTons ?? 0) > 0 &&
Number(b.cargoTotalWeightVgm ?? 0) > 0;
if (b.freightType === "BULK" && !perItemBulk && typeIds.length) {
const smart = this.smartBulkNeed(b, wagonDims, ledger, leg, rank, typeIds);
if (smart) {
for (const part of smart.perType) {
ledger.consume([part.wagonTypeId], part.wagons, leg);
}
continue;
}
// Over-committed (stock cannot seat it any more) — drain what exists,
// same as before, so the shortage stays visible to the gates.
}
ledger.consume(
typeIds.length ? typeIds : anyType,
this.wagonsFor(b, wagonDims),
leg,
);
}
return ledger;
}
/**
@@ -4812,6 +5129,122 @@ export class BookingBatchService implements OnModuleInit {
return stock.availableFor(wagonTypeIds, leg) >= wagonsNeeded;
}
/**
* Scarcity rank over the day pool: how many distinct demand groups (bulk
* cargo types / container types among these bookings) may ride each wagon
* type. The batch seats least-shareable types first, so bulk with a
* bulk-only alternative (PW2) never eats the container-capable stock (NW5)
* that containers cannot substitute.
*/
private scarcityRankForPool(
pool: Booking[],
allowed: {
byCargoTypeId: Map<string, string[]>;
byContainerTypeId: Map<string, string[]>;
},
): Map<string, number> {
const groups = new Map<string, string[]>();
for (const b of pool) {
if (b.freightType === "BULK") {
const cargoTypeId = b.cargoTypeId ?? b.cargoType?.id;
if (cargoTypeId) {
groups.set(`B:${cargoTypeId}`, allowed.byCargoTypeId.get(cargoTypeId) ?? []);
}
} else {
for (const line of b.bookingContainers ?? []) {
const containerTypeId = line.containerTypeId ?? line.containerType?.id;
if (containerTypeId) {
groups.set(
`C:${containerTypeId}`,
allowed.byContainerTypeId.get(containerTypeId) ?? [],
);
}
}
}
}
const rank = new Map<string, number>();
for (const ids of groups.values()) {
for (const id of ids) rank.set(id, (rank.get(id) ?? 0) + 1);
}
return rank;
}
/**
* Cap-aware, scarcity-ordered seating of a PER_TON bulk booking across the
* wagon types this train actually has free on its leg — the same policy the
* wagon planner applies at allocation time (least-shareable type first, each
* wagon filled to the cargo type's per-wagon cap, one booking per wagon).
*
* This is the payment gate's real fit check for bulk: the generic
* `hasWagonStock` sums free wagons across allowed types against a count
* sized on ONE type, so 695T Perishable read "24 wagons needed, 28 free"
* when seating it across 10 PW2 (20T) + NW5 (30T) really takes 27 wagons.
* Returns the exact per-type counts and the three-axis capacity they
* consume, or null when the free stock cannot seat the whole booking.
*/
private smartBulkNeed(
booking: Booking,
wagonDims: WagonDims,
stock: WagonStockLedger,
leg: CorridorLeg,
scarcityRank: Map<string, number>,
/**
* Wagon-type ids this booking may ride, from {@link loadAllowedWagonTypeIds}
* — NEVER from `booking.cargoType.wagonTypes`. The batch pool finders
* deliberately do not join that relation (hot path), so on a pool entity
* it is always empty; resolving through it made every PER_TON bulk booking
* unseatable — no whole fit and no partial offer, silently READY forever
* (the S-2026-00020 / BK-2026-000036 incident).
*/
wagonTypeIds: readonly string[],
): { need: Capacity; perType: Array<{ wagonTypeId: string; wagons: number }> } | null {
const options = [...new Set(wagonTypeIds)]
.map((wagonTypeId) => ({ wagonTypeId, dims: wagonDims.byWagonTypeId.get(wagonTypeId) }))
.filter((o): o is { wagonTypeId: string; dims: PerWagonDims } => o.dims != null)
.map((o) => ({
...o,
free: stock.availableFor([o.wagonTypeId], leg),
takePerWagon: bulkTonsPerWagon(
booking.cargoType,
o.wagonTypeId,
o.dims.capacityTons,
),
}))
.filter((o) => o.free > 0 && o.takePerWagon > 0)
.sort(
(a, b) =>
(scarcityRank.get(a.wagonTypeId) ?? 1) -
(scarcityRank.get(b.wagonTypeId) ?? 1) ||
b.takePerWagon - a.takePerWagon,
);
let remaining = bookingCargoTons(booking);
if (remaining <= 0) return null;
const perType: Array<{ wagonTypeId: string; wagons: number }> = [];
let weightTons = remaining; // gross: cargo plus each seated wagon's tare
let lengthMeters = 0;
let wagons = 0;
for (const option of options) {
if (remaining <= 1e-9) break;
const take = Math.min(option.free, Math.ceil(remaining / option.takePerWagon));
if (take <= 0) continue;
remaining = roundTons(Math.max(0, remaining - take * option.takePerWagon));
wagons += take;
weightTons += take * option.dims.tareWeightTons;
lengthMeters += take * option.dims.lengthMeters;
perType.push({ wagonTypeId: option.wagonTypeId, wagons: take });
}
if (remaining > 1e-9) return null;
return {
need: {
wagons,
weightTons: roundTons(weightTons),
lengthMeters: roundTons(lengthMeters),
},
perType,
};
}
private allowedWagonTypeCache: {
byCargoTypeId: Map<string, string[]>;
byContainerTypeId: Map<string, string[]>;
@@ -4956,6 +5389,35 @@ export class BookingBatchService implements OnModuleInit {
// wagon serves disjoint legs — capacity freed past an alight yard is real.
const stops = await this.stopsForSchedule(schedule);
const budget = new CorridorBudget(stops, limits.base, limits.tolerance);
// Wagons staff plan to cut mid-route are gone from every edge past the cut.
// ponytail: the wagon-type stock ledger stays cut-blind; bucket
// builtTrainStock by (yard, reach) if mixed-type cut trains appear.
subtractCutWagons(budget, schedule.plannedWagonCutYards);
// Planned couples add a slot from their couple stop onward.
addCoupledWagons(budget, schedule.plannedWagonCouples);
for (const b of await this.committedBookings(schedule, excludeBookingIds)) {
budget.subtract(
this.needFor(b, wagonDims),
budget.legForYards(b.originYardId, b.destinationYardId),
);
}
return budget;
}
/**
* Every booking already holding capacity on the schedule: allocated (linked),
* live-reserved (unexpired pay window or paid), and pending export requests
* that named this train. The ONE list both the abstract corridor budget and
* the per-yard wagon-type ledger must debit — when only the budget saw them,
* a train with 15 wagons planned at Mojo and 15 already booked from Mojo
* still advertised "15 free" there, because the whole-train budget had room
* left on that edge (from the other yard's wagons) and the ledger was born
* full.
*/
private async committedBookings(
schedule: TrainSchedule,
excludeBookingIds?: string[],
): Promise<Booking[]> {
const allocated = (schedule.scheduleBookings ?? [])
.map((sb) => sb.booking)
.filter((b): b is Booking => Boolean(b));
@@ -4988,13 +5450,14 @@ export class BookingBatchService implements OnModuleInit {
relations: ['bookingContainers'],
})
).filter((b) => !excludeBookingIds?.includes(b.id));
for (const b of [...allocated, ...reserved, ...pendingHolds]) {
budget.subtract(
this.needFor(b, wagonDims),
budget.legForYards(b.originYardId, b.destinationYardId),
);
}
return budget;
// A booking can sit in more than one set (allocated AND still reserved);
// it holds its wagons once.
const seen = new Set<string>();
return [...allocated, ...reserved, ...pendingHolds].filter((b) => {
if (seen.has(b.id) || excludeBookingIds?.includes(b.id)) return false;
seen.add(b.id);
return true;
});
}
/**

View File

@@ -0,0 +1,143 @@
import { Booking } from '../bookings/entities/booking.entity';
import { BookingBatchService } from './booking-batch.service';
import { WagonStockLedger } from './wagon-stock-ledger.util';
/**
* smartBulkNeed math in isolation: the private helpers it touches
* (allowedDimsWithTypes) read only their arguments, so a bare prototype
* instance is enough — no Nest wiring.
*///
describe('BookingBatchService.smartBulkNeed', () => {
const service = Object.create(BookingBatchService.prototype) as BookingBatchService;
const call = (
booking: Booking,
stock: WagonStockLedger,
rank: Map<string, number>,
) =>
(
service as unknown as {
smartBulkNeed: (
b: Booking,
d: unknown,
s: WagonStockLedger,
l: { fromEdge: number; toEdge: number },
r: Map<string, number>,
ids: readonly string[],
) => { need: { wagons: number }; perType: Array<{ wagonTypeId: string; wagons: number }> } | null;
}
).smartBulkNeed(booking, wagonDims, stock, { fromEdge: 0, toEdge: 1 }, rank, allowedIds);
const nw5 = { id: 'wt-nw5', capacityTons: 70 };
const pw2 = { id: 'wt-pw2', capacityTons: 70 };
// Shaped like a BATCH POOL entity: cargoType WITHOUT the wagonTypes
// relation (the pool query never joins it) — allowed types must come from
// the ids parameter, or every pool bulk booking reads as unseatable.
const perishable = {
id: 'cargo-perishable',
tonsPerWagonMap: { [nw5.id]: 30, [pw2.id]: 20 },
};
const allowedIds = [nw5.id, pw2.id];
const wagonDims = {
container: { lengthMeters: 14, tareWeightTons: 24, capacityTons: 70 },
bulk: { lengthMeters: 14, tareWeightTons: 24, capacityTons: 70 },
byWagonTypeId: new Map([
[nw5.id, { lengthMeters: 14, tareWeightTons: 24, capacityTons: 70 }],
[pw2.id, { lengthMeters: 14, tareWeightTons: 24, capacityTons: 70 }],
]),
};
const booking = (tons: number): Booking =>
({
id: 'b1',
reference: 'b1',
freightType: 'BULK',
cargoTotalWeightVgm: tons,
cargoTypeId: perishable.id,
cargoType: perishable,
bookingContainers: [],
}) as unknown as Booking;
// Containers compete for NW5 → NW5 rank 2, PW2 rank 1.
const contested = new Map([
[nw5.id, 2],
[pw2.id, 1],
]);
it('seats 695T as 10 PW2 (20T) + 17 NW5 (30T) = 27 wagons, PW2 first', () => {
const stock = new WagonStockLedger(
new Map([
[nw5.id, 18],
[pw2.id, 10],
]),
1,
);
const smart = call(booking(695), stock, contested);
expect(smart).not.toBeNull();
expect(smart!.need.wagons).toBe(27);
expect(smart!.perType).toEqual([
{ wagonTypeId: pw2.id, wagons: 10 },
{ wagonTypeId: nw5.id, wagons: 17 },
]);
});
it('returns null when the free stock cannot seat the whole booking', () => {
const stock = new WagonStockLedger(
new Map([
[nw5.id, 5],
[pw2.id, 10],
]),
1,
);
// 10×20 + 5×30 = 350T < 695T.
expect(call(booking(695), stock, contested)).toBeNull();
});
it('S-2026-00020 shape: 42x40ft eat the NW5, 200T bulk still seats on the 10 coupled PW2', () => {
// The staging complaint: a built train of 42 NW5 + 10 PW2, containers
// hold every NW5, and a bulk booking sits in "Ready for batch" while the
// PW2 ride empty. The chain: committed containers drain NW5 from the
// ledger (their types cannot touch PW2), then the smart gate must seat
// 200T of Perishable on the 10 PW2 at the 20T cap.
const stock = new WagonStockLedger(
new Map([
[nw5.id, 42],
[pw2.id, 10],
]),
2, // DCT -> Dire -> GMP: two edges
);
// Committed container booking rides Dire->GMP (edge 1) on 42 NW5 —
// container-capable types only, exactly how stockLedgerFor debits it.
stock.consume([nw5.id], 42, { fromEdge: 1, toEdge: 2 });
expect(stock.availableFor([nw5.id], { fromEdge: 0, toEdge: 2 })).toBe(0);
expect(stock.availableFor([pw2.id], { fromEdge: 0, toEdge: 2 })).toBe(10);
const smart = (
service as unknown as {
smartBulkNeed: (
b: Booking,
d: unknown,
s: WagonStockLedger,
l: { fromEdge: number; toEdge: number },
r: Map<string, number>,
ids: readonly string[],
) => { need: { wagons: number }; perType: Array<{ wagonTypeId: string; wagons: number }> } | null;
}
).smartBulkNeed(booking(200), wagonDims, stock, { fromEdge: 0, toEdge: 2 }, contested, allowedIds);
expect(smart).not.toBeNull();
expect(smart!.perType).toEqual([{ wagonTypeId: pw2.id, wagons: 10 }]);
});
it('uncontested types fall back to biggest per-cargo take (fewest wagons)', () => {
const stock = new WagonStockLedger(
new Map([
[nw5.id, 10],
[pw2.id, 10],
]),
1,
);
const even = new Map([
[nw5.id, 1],
[pw2.id, 1],
]);
const smart = call(booking(60), stock, even);
expect(smart!.perType).toEqual([{ wagonTypeId: nw5.id, wagons: 2 }]);
});
});

View File

@@ -0,0 +1,115 @@
import { BookingBatchService } from './booking-batch.service';
import { CorridorBudget } from './corridor-capacity.util';
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
import { Booking } from '../bookings/entities/booking.entity';
/**
* Regression: a train with 15 wagons planned at Mojo and a 15-wagon booking
* already committed from Mojo advertised "15 free at Mojo" — the whole-train
* corridor budget still had room on that edge (GMP's wagons), and the per-yard
* stock ledger was born full. The ledger must be debited by the SAME committed
* bookings the budget subtracts.
*/
describe('BookingBatchService — per-yard stock ledger debits committed bookings', () => {
const GMP = 'gmp', MOJO = 'mojo', DCT = 'dct';
const booking = (id: string, originYardId: string, wagonsRequired: number) =>
({
id,
freightType: 'BULK',
cargoTypeId: 'ct-coffee',
wagonsRequired,
originYardId,
destinationYardId: DCT,
cargoTotalWeightVgm: 1,
bookingContainers: [],
}) as unknown as Booking;
const schedule = {
id: 'S-35',
routeId: 'route-1',
originStationId: GMP,
destinationStationId: DCT,
scheduleBookings: [{ booking: booking('BK-118', MOJO, 15) }, { booking: booking('BK-120', GMP, 1) }],
} as never;
const makeService = (pendingHolds: Booking[] = []) => {
const milestoneRepo = {
find: jest.fn().mockResolvedValue([
{ yardId: GMP, sequenceNo: 1 },
{ yardId: MOJO, sequenceNo: 2 },
{ yardId: DCT, sequenceNo: 3 },
]),
};
const emptyRepo = { find: jest.fn().mockResolvedValue([]) };
// Booking.find is only used for OPERATION_REQUEST_PENDING export holds.
const bookingRepo = { find: jest.fn().mockResolvedValue(pendingHolds) };
const dataSource = {
getRepository: jest.fn((entity: unknown) =>
entity === RouteMilestone ? milestoneRepo : entity === Booking ? bookingRepo : emptyRepo,
),
query: jest.fn(async (sql: string) =>
sql.includes('cargo_type_wagon_types') ? [{ typeId: 'ct-coffee', wagonTypeId: 'nw5' }] : [],
),
};
const trainSchedulingService = {
wagonStockForSchedule: jest.fn().mockResolvedValue({
mode: 'TRAIN',
remainingByTypeId: new Map([['nw5', 46]]),
codesByTypeId: new Map([['nw5', 'NW5']]),
byYardId: new Map([
[GMP, new Map([['nw5', 31]])],
[MOJO, new Map([['nw5', 15]])],
]),
}),
};
return new BookingBatchService(
dataSource as never,
{ findReservedForSchedule: jest.fn().mockResolvedValue([]) } as never,
{} as never,
{} as never,
{} as never,
{} as never,
trainSchedulingService as never,
{} as never,
{} as never,
{} as never,
);
};
const budget = () =>
new CorridorBudget([GMP, MOJO, DCT], {
wagons: 46,
weightTons: Number.POSITIVE_INFINITY,
lengthMeters: Number.POSITIVE_INFINITY,
});
it('shows 0 free at Mojo once its 15 planned wagons are booked, while GMP keeps its own', async () => {
const service = makeService() as unknown as {
stockLedgerFor: BookingBatchService['stockLedgerFor'];
};
const b = budget();
const ledger = await service.stockLedgerFor(schedule, b);
expect(ledger.availableFor(['nw5'], b.legOf(MOJO, DCT)!)).toBe(0);
expect(ledger.availableFor(['nw5'], b.legOf(GMP, DCT)!)).toBe(30);
});
it('a pending export request already holds its wagons at its yard (before staff accept)', async () => {
const service = makeService([booking('BK-REQ', MOJO, 10)]) as unknown as {
stockLedgerFor: BookingBatchService['stockLedgerFor'];
};
const emptySchedule = { ...(schedule as object), scheduleBookings: [] } as never;
const b = budget();
const ledger = await service.stockLedgerFor(emptySchedule, b);
expect(ledger.availableFor(['nw5'], b.legOf(MOJO, DCT)!)).toBe(5);
expect(ledger.availableFor(['nw5'], b.legOf(GMP, DCT)!)).toBe(31);
});
it('excludes the booking being evaluated so a request never blocks its own accept', async () => {
const service = makeService() as unknown as {
stockLedgerFor: BookingBatchService['stockLedgerFor'];
};
const b = budget();
const ledger = await service.stockLedgerFor(schedule, b, ['BK-118']);
expect(ledger.availableFor(['nw5'], b.legOf(MOJO, DCT)!)).toBe(15);
});
});

View File

@@ -1,6 +1,7 @@
import {
autoFillPlacements,
findMissingContainerNumberIssues,
occupiedTeuPerEdgeBySlot,
type ContainerUnitForPlacement,
} from './container-placement.util';
@@ -27,14 +28,15 @@ describe('container-placement.util', () => {
];
it('auto-fills placements across slots', () => {
const placements = autoFillPlacements(units, [1, 2]);
const { placements, overflow } = autoFillPlacements(units, [1, 2]);
expect(placements).toHaveLength(2);
expect(overflow).toHaveLength(0);
expect(placements[0].sequenceNo).toBe(1);
expect(placements[1].sequenceNo).toBe(2);
});
it('reports missing container numbers only when placement is empty', () => {
const placements = autoFillPlacements(units, [1, 2]);
const { placements } = autoFillPlacements(units, [1, 2]);
const issues = findMissingContainerNumberIssues(units, placements);
expect(issues).toHaveLength(0);
expect(placements[1].containerNumber).toMatch(/^TBD-/);
@@ -53,7 +55,84 @@ describe('container-placement.util', () => {
containerNumber: null,
},
];
const placements = autoFillPlacements(single, [1]);
const { placements } = autoFillPlacements(single, [1]);
expect(placements[0].containerNumber).toBe('TBD-BK-2026-000033-1');
});
const ft40 = (
bookingId: string,
i: number,
leg?: { from: number; to: number },
): ContainerUnitForPlacement => ({
bookingId,
bookingReference: bookingId,
bookingContainerId: `${bookingId}-line`,
unitIndex: i,
label: `${bookingId} · ${i + 1} · 40GP`,
teuSlots: 2,
sizeFt: 40,
containerNumber: `CNT${bookingId}${i}`,
leg,
});
it('never clamps overflow onto the last slot — returns it instead', () => {
// 3 × 40ft, 2 slots. The old walk piled unit 3 onto slot #2 and let the
// validator reject it once per container ("Wagon #42…", the reported bug).
const three = [ft40('A', 0), ft40('A', 1), ft40('A', 2)];
const { placements, overflow } = autoFillPlacements(three, [1, 2]);
expect(placements).toHaveLength(2);
expect(overflow).toHaveLength(1);
expect(placements.every((p) => p.sequenceNo === 1 || p.sequenceNo === 2)).toBe(true);
});
it('leg-aware: disjoint-leg 40fts share one wagon (the staging case)', () => {
// 2 slots riding the whole 2-edge route. Leg-blind fill fits only two of
// these four 40fts; per-edge TEU fits all four — two per wagon, one per leg.
const slots = [
{ sequenceNo: 1, from: 0, to: 2 },
{ sequenceNo: 2, from: 0, to: 2 },
];
const four = [
ft40('LEG1', 0, { from: 0, to: 1 }),
ft40('LEG1', 1, { from: 0, to: 1 }),
ft40('LEG2', 0, { from: 1, to: 2 }),
ft40('LEG2', 1, { from: 1, to: 2 }),
];
const { placements, overflow } = autoFillPlacements(four, slots, new Map(), 2);
expect(overflow).toHaveLength(0);
expect(placements).toHaveLength(4);
});
it('same-leg 40fts still never share a wagon', () => {
const slots = [{ sequenceNo: 1, from: 0, to: 2 }];
const two = [ft40('X', 0, { from: 0, to: 1 }), ft40('X', 1, { from: 0, to: 1 })];
const { placements, overflow } = autoFillPlacements(two, slots, new Map(), 2);
expect(placements).toHaveLength(1);
expect(overflow).toHaveLength(1);
});
it('respects per-edge occupied TEU from caller-provided placements', () => {
const slots = [{ sequenceNo: 1, from: 0, to: 2 }];
const provided = [{ bookingContainerId: 'P-line', unitIndex: 0, sequenceNo: 1 }];
const providedUnit = ft40('P', 0, { from: 0, to: 1 });
const occupied = occupiedTeuPerEdgeBySlot(provided, [providedUnit], 2);
// Edge 0 is full on slot 1; an edge-0 unit overflows, an edge-1 unit fits.
const edge0 = autoFillPlacements([ft40('Q', 0, { from: 0, to: 1 })], slots, occupied, 2);
expect(edge0.overflow).toHaveLength(1);
const edge1 = autoFillPlacements([ft40('Q', 0, { from: 1, to: 2 })], slots, occupied, 2);
expect(edge1.overflow).toHaveLength(0);
expect(edge1.placements[0].sequenceNo).toBe(1);
});
it('a unit never lands on a slot that does not ride its leg', () => {
const slots = [{ sequenceNo: 1, from: 0, to: 1 }]; // alights at stop 1
const { placements, overflow } = autoFillPlacements(
[ft40('Y', 0, { from: 1, to: 2 })],
slots,
new Map(),
2,
);
expect(placements).toHaveLength(0);
expect(overflow).toHaveLength(1);
});
});

View File

@@ -9,6 +9,18 @@ export type ContainerUnitForPlacement = {
teuSlots?: number;
sizeFt?: number;
containerNumber?: string | null;
/**
* Stop-index span this unit's BOOKING rides (leg-aware trains). Omitted →
* the whole route, which is exact for single-leg schedules.
*/
leg?: { from: number; to: number };
};
/** A container-capable wagon slot with the stop-index span it physically rides. */
export type ContainerSlotForPlacement = {
sequenceNo: number;
from: number;
to: number;
};
export function placeholderContainerNumber(unit: ContainerUnitForPlacement): string {
@@ -25,41 +37,137 @@ export function resolveContainerNumber(unit: ContainerUnitForPlacement): string
return trimmed || placeholderContainerNumber(unit);
}
/**
* Auto-place container units onto the plan's container slots.
*
* TEU is tracked PER CORRIDOR EDGE, because that is how the planner and the
* validator count it: a wagon whose 40ft alights at Dire Dawa has both TEU
* free again for a 40ft boarding there. The old whole-route walk believed a
* wagon was full after one 40ft on ANY leg, ran out of slots on a leg-sharing
* train, and — worse — CLAMPED every leftover unit onto the last slot. That
* produced placements the validator then rejected one by one ("Wagon #42
* cannot fit another 40FT… total weight 560T"), a wall of errors for what is
* really one condition.
*
* Units that genuinely fit nowhere are returned in `overflow` — never
* force-placed. The caller owns turning that into ONE honest message.
*
* `containerSlots` may be plain sequence numbers (whole-route spans — exact
* for single-leg schedules and identical to the old behaviour) or spans.
*/
export function autoFillPlacements(
units: ContainerUnitForPlacement[],
containerSlots: number[],
): ContainerPlacementInput[] {
if (!units.length || !containerSlots.length) return [];
containerSlots: ReadonlyArray<number | ContainerSlotForPlacement>,
/**
* TEU already taken per slot sequenceNo by placements the caller supplied.
* A plain number occupies every edge of the slot; an array is per-edge.
*/
occupiedTeuBySlot: ReadonlyMap<number, number | readonly number[]> = new Map(),
edgeCount = 1,
): { placements: ContainerPlacementInput[]; overflow: ContainerUnitForPlacement[] } {
const edges = Math.max(1, edgeCount);
const slots: ContainerSlotForPlacement[] = containerSlots.map((s) =>
typeof s === 'number' ? { sequenceNo: s, from: 0, to: edges } : s,
);
const placements: ContainerPlacementInput[] = [];
const overflow: ContainerUnitForPlacement[] = [];
if (!units.length) return { placements, overflow };
if (!slots.length) return { placements, overflow: [...units] };
const MAX_TEU_PER_WAGON = 2;
let currentSlotIndex = 0;
let teuInCurrentSlot = 0;
const used = new Map<number, number[]>();
const usedRow = (sequenceNo: number): number[] => {
let row = used.get(sequenceNo);
if (!row) {
const seed = occupiedTeuBySlot.get(sequenceNo) ?? 0;
row =
typeof seed === 'number'
? new Array<number>(edges).fill(seed)
: Array.from({ length: edges }, (_, e) => seed[e] ?? 0);
used.set(sequenceNo, row);
}
return row;
};
const legOf = (unit: ContainerUnitForPlacement): { from: number; to: number } => {
const leg = unit.leg;
if (!leg || leg.from < 0 || leg.to > edges || leg.from >= leg.to) {
return { from: 0, to: edges };
}
return leg;
};
for (const unit of units) {
const teu = unit.teuSlots ?? (unit.sizeFt && unit.sizeFt >= 40 ? 2 : 1);
if (teuInCurrentSlot > 0 && teuInCurrentSlot + teu > MAX_TEU_PER_WAGON) {
currentSlotIndex += 1;
teuInCurrentSlot = 0;
const leg = legOf(unit);
const slot = slots.find((s) => {
if (s.from > leg.from || leg.to > s.to) return false;
const row = usedRow(s.sequenceNo);
for (let e = leg.from; e < leg.to; e += 1) {
if ((row[e] ?? 0) + teu > MAX_TEU_PER_WAGON) return false;
}
return true;
});
if (!slot) {
overflow.push(unit);
continue;
}
const sequenceNo =
containerSlots[Math.min(currentSlotIndex, containerSlots.length - 1)] ??
containerSlots[containerSlots.length - 1] ??
containerSlots[0];
const row = usedRow(slot.sequenceNo);
for (let e = leg.from; e < leg.to; e += 1) row[e] = (row[e] ?? 0) + teu;
placements.push({
bookingContainerId: unit.bookingContainerId,
unitIndex: unit.unitIndex,
sequenceNo,
sequenceNo: slot.sequenceNo,
containerNumber: resolveContainerNumber(unit),
});
teuInCurrentSlot += teu;
}
return placements;
return { placements, overflow };
}
/**
* Per-edge TEU consumed by the given placements, using each placed unit's own
* leg — the seed `autoFillPlacements` needs on a leg-aware train.
*/
export function occupiedTeuPerEdgeBySlot(
placements: ReadonlyArray<{ bookingContainerId: string; unitIndex: number; sequenceNo: number }>,
units: ContainerUnitForPlacement[],
edgeCount: number,
): Map<number, number[]> {
const edges = Math.max(1, edgeCount);
const unitByKey = new Map(units.map((u) => [`${u.bookingContainerId}:${u.unitIndex}`, u]));
const out = new Map<number, number[]>();
for (const p of placements) {
const unit = unitByKey.get(`${p.bookingContainerId}:${p.unitIndex}`);
const teu = unit ? (unit.teuSlots ?? (unit.sizeFt && unit.sizeFt >= 40 ? 2 : 1)) : 1;
const leg =
unit?.leg && unit.leg.from >= 0 && unit.leg.to <= edges && unit.leg.from < unit.leg.to
? unit.leg
: { from: 0, to: edges };
const row = out.get(p.sequenceNo) ?? new Array<number>(edges).fill(0);
for (let e = leg.from; e < leg.to; e += 1) row[e] = (row[e] ?? 0) + teu;
out.set(p.sequenceNo, row);
}
return out;
}
/** TEU per slot sequenceNo consumed by the given placements. */
export function occupiedTeuBySlot(
placements: ReadonlyArray<{ bookingContainerId: string; unitIndex: number; sequenceNo: number }>,
units: ContainerUnitForPlacement[],
): Map<number, number> {
const teuOfUnit = new Map(
units.map((u) => [
`${u.bookingContainerId}:${u.unitIndex}`,
u.teuSlots ?? (u.sizeFt && u.sizeFt >= 40 ? 2 : 1),
]),
);
const out = new Map<number, number>();
for (const p of placements) {
const teu = teuOfUnit.get(`${p.bookingContainerId}:${p.unitIndex}`) ?? 1;
out.set(p.sequenceNo, (out.get(p.sequenceNo) ?? 0) + teu);
}
return out;
}
export function findMissingContainerNumberIssues(

View File

@@ -1,6 +1,7 @@
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import type { Response } from "express";
import type { AuthUserPayload } from "../../../common/resolve-auth-user-id";
import { PaginationQueryDto } from "../../../common/dto/pagination-query.dto";
import { UserTradeAccessService } from "../../user-trade-access/user-trade-access.service";
import { resolveAuthUserId } from "../../../common/resolve-auth-user-id";
@@ -16,7 +17,9 @@ import {
TrainSchedulingCancel,
TrainSchedulingCreate,
TrainSchedulingEditTrainNumber,
TrainSchedulingLoad,
TrainSchedulingReschedule,
TrainSchedulingUnload,
TrainSchedulingRulesManage,
TrainSchedulingUpdate,
TrainSchedulingView,
@@ -48,6 +51,7 @@ import {
} from "../dto/import-djibouti-operation.dto";
import { AvailableLocomotivesQueryDto } from "../dto/available-locomotives-query.dto";
import { AdjustScheduleConsistDto } from "../dto/adjust-schedule-consist.dto";
import { UpdateScheduleWagonYardsDto } from "../dto/update-schedule-wagon-yards.dto";
import { AvailableTrainsQueryDto } from "../dto/available-trains-query.dto";
import { BatchBoardQueryDto } from "../dto/batch-board-query.dto";
import { BookableSchedulesQueryDto } from "../dto/bookable-schedules-query.dto";
@@ -201,6 +205,29 @@ export class TrainSchedulingController {
return this.trainSchedulingService.getScheduleConsist(id);
}
@Get("schedules/:id/wagon-yards")
@TrainSchedulingView()
@ApiOperation({
summary:
"Schedule wagon yard plan: where THIS departure boards and cuts each consist wagon vs where it physically stands, per-stop totals, locked wagons",
})
getScheduleWagonYards(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.getScheduleWagonYards(id);
}
@Patch("schedules/:id/wagon-yards")
@TrainSchedulingUpdate()
@ApiOperation({
summary:
"Re-plan the yard this departure boards wagons from and/or cuts them at (schedule-only; physical yards untouched, dispatch requires alignment)",
})
updateScheduleWagonYards(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateScheduleWagonYardsDto,
) {
return this.trainSchedulingService.updateScheduleWagonYards(id, dto);
}
@Post("schedules/:id/adjust-consist")
@TrainSchedulingUpdate()
@ApiOperation({
@@ -219,14 +246,27 @@ export class TrainSchedulingController {
);
}
@Get("schedules/:id/phase")
@TrainSchedulingView()
@ApiOperation({
summary:
"Lightweight polling heartbeat: the schedule's status, booking-window phase and deadlines plus its updated_at — one row, no joins, so clients can poll cheaply and refetch the full detail only when something actually changed",
})
getSchedulePhase(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.getSchedulePhase(id);
}
@Get("schedules/:id/history")
@TrainSchedulingView()
@ApiOperation({
summary:
"Unified change history for a schedule: wagon consist adjustments (add/remove/switch, with the stop they happened at) merged with booking composition removals, newest first",
})
getScheduleHistory(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.getScheduleHistory(id);
getScheduleHistory(
@Param("id", ParseUUIDPipe) id: string,
@Query() query: PaginationQueryDto,
) {
return this.trainSchedulingService.getScheduleHistory(id, query);
}
@Get("bookable-schedules")
@@ -574,7 +614,7 @@ export class TrainSchedulingController {
}
@Post("schedules/:id/bookings/:bookingId/load")
@TrainSchedulingUpdate()
@TrainSchedulingLoad()
@ApiOperation({
summary:
"Confirm a booking's cargo loaded at its origin yard (any direction; train must be at that yard)",
@@ -587,7 +627,7 @@ export class TrainSchedulingController {
}
@Post("schedules/:id/bookings/:bookingId/unload")
@TrainSchedulingUpdate()
@TrainSchedulingUnload()
@ApiOperation({
summary:
"Confirm a booking's cargo unloaded at its destination yard — per-booking arrival, may precede the train's final arrival",
@@ -600,7 +640,7 @@ export class TrainSchedulingController {
}
@Post("schedules/:id/intercity/:bookingId/load")
@TrainSchedulingUpdate()
@TrainSchedulingLoad()
@ApiOperation({
summary: "Confirm intercity cargo loaded (train must be at the booking's origin yard)",
})
@@ -612,7 +652,7 @@ export class TrainSchedulingController {
}
@Post("schedules/:id/intercity/:bookingId/unload")
@TrainSchedulingUpdate()
@TrainSchedulingUnload()
@ApiOperation({
summary:
"Confirm intercity cargo unloaded at the booking's destination yard (completes the booking)",

View File

@@ -1,4 +1,11 @@
import { Capacity, CorridorBudget } from './corridor-capacity.util';
import {
addCoupledWagons,
Capacity,
CorridorBudget,
orientStopsToSchedule,
stopYardsFor,
subtractCutWagons,
} from './corridor-capacity.util';
import { sizePartialOfferWagons } from './train-capacity.util';
describe('corridor-capacity.util — overage tolerance', () => {
@@ -118,3 +125,135 @@ describe('corridor-capacity.util — overage tolerance', () => {
});
});
});
describe('corridor-capacity.util — subtractCutWagons', () => {
const stops = ['a', 'b', 'c', 'd'];
const wagonsOnly: Capacity = {
wagons: 53,
weightTons: Number.POSITIVE_INFINITY,
lengthMeters: Number.POSITIVE_INFINITY,
};
// 10 wagons cut at b, 13 cut at c, 30 ride through to d.
const cutPlan = Object.fromEntries([
...Array.from({ length: 10 }, (_, i) => [`w-b-${i}`, 'b']),
...Array.from({ length: 13 }, (_, i) => [`w-c-${i}`, 'c']),
]);
const remaining = (budget: CorridorBudget, from: string, to: string): number =>
budget.remainingFor(budget.legOf(from, to)!).wagons;
it('debits each cut wagon from every edge at/after its cut stop', () => {
const budget = new CorridorBudget(stops, wagonsOnly);
subtractCutWagons(budget, cutPlan);
expect(remaining(budget, 'a', 'b')).toBe(53);
expect(remaining(budget, 'a', 'c')).toBe(43);
expect(remaining(budget, 'b', 'c')).toBe(43);
expect(remaining(budget, 'a', 'd')).toBe(30);
expect(remaining(budget, 'c', 'd')).toBe(30);
});
it('stacks with per-booking subtraction on overlapping edges', () => {
const budget = new CorridorBudget(stops, wagonsOnly);
subtractCutWagons(budget, cutPlan);
budget.subtract({ wagons: 5, weightTons: 0, lengthMeters: 0 }, budget.legOf('a', 'd')!);
expect(remaining(budget, 'a', 'b')).toBe(48);
expect(remaining(budget, 'c', 'd')).toBe(25);
});
it('ignores cut yards off the corridor and at the destination, and a missing plan', () => {
const budget = new CorridorBudget(stops, wagonsOnly);
subtractCutWagons(budget, { 'w-1': 'elsewhere', 'w-2': 'd' });
subtractCutWagons(budget, null);
subtractCutWagons(budget, undefined);
expect(remaining(budget, 'a', 'd')).toBe(53);
});
it('works identically on an export-direction corridor — pure index math', () => {
// Export runs the other way geographically (Kality → Mojo → Doraleh); the
// stop LIST still runs origin→destination, so a cut at Mojo debits every
// edge from Mojo to Doraleh. Nothing in the math is import-specific.
const exportStops = ['kality', 'mojo', 'doraleh'];
const budget = new CorridorBudget(exportStops, wagonsOnly);
subtractCutWagons(budget, { 'w-1': 'mojo', 'w-2': 'mojo' });
expect(remaining(budget, 'kality', 'mojo')).toBe(53);
expect(remaining(budget, 'mojo', 'doraleh')).toBe(51);
expect(remaining(budget, 'kality', 'doraleh')).toBe(51);
});
});
describe('corridor-capacity.util — stop orientation and fallback', () => {
it('keeps a stop list that already runs origin→destination', () => {
expect(orientStopsToSchedule(['a', 'b', 'c'], 'a', 'c')).toEqual(['a', 'b', 'c']);
});
it('reverses a route traversed backwards (return-leg reuse) so cuts still land', () => {
// Milestones stored Doraleh→Mojo→Kality (the import route), reused by an
// export schedule Kality→Doraleh: without orientation every legOf() would
// return null and every cut silently no-op.
const oriented = orientStopsToSchedule(
['doraleh', 'mojo', 'kality'],
'kality',
'doraleh',
);
expect(oriented).toEqual(['kality', 'mojo', 'doraleh']);
const budget = new CorridorBudget(oriented, {
wagons: 10,
weightTons: Number.POSITIVE_INFINITY,
lengthMeters: Number.POSITIVE_INFINITY,
});
subtractCutWagons(budget, { w: 'mojo' });
expect(budget.remainingFor(budget.legOf('mojo', 'doraleh')!).wagons).toBe(9);
});
it('leaves a partially mismatched list untouched (unknown data keeps old behavior)', () => {
expect(orientStopsToSchedule(['x', 'y', 'z'], 'a', 'c')).toEqual(['x', 'y', 'z']);
});
it('stopYardsFor orients a backwards milestone list to the schedule endpoints', () => {
expect(stopYardsFor(['c', 'b', 'a'], 'a', 'c')).toEqual(['a', 'b', 'c']);
});
it('stopYardsFor keeps a single stray milestone as a middle stop', () => {
// Must agree with stopYardsForSchedule/mapScheduleStops: a one-milestone
// route offers that stop for cuts, in capacity AND validation alike.
expect(stopYardsFor(['m'], 'a', 'c')).toEqual(['a', 'm', 'c']);
expect(stopYardsFor([], 'a', 'c')).toEqual(['a', 'c']);
expect(stopYardsFor(null, 'a', 'c')).toEqual(['a', 'c']);
});
});
describe('corridor-capacity.util — addCoupledWagons', () => {
const stops = ['a', 'b', 'c', 'd'];
const wagonsOnly: Capacity = {
wagons: 10,
weightTons: Number.POSITIVE_INFINITY,
lengthMeters: Number.POSITIVE_INFINITY,
};
const remaining = (budget: CorridorBudget, from: string, to: string): number =>
budget.remainingFor(budget.legOf(from, to)!).wagons;
it('credits every edge at/after the couple stop', () => {
const budget = new CorridorBudget(stops, wagonsOnly);
addCoupledWagons(budget, { 'w-1': 'a', 'w-2': 'c' });
expect(remaining(budget, 'a', 'b')).toBe(11); // origin couple rides everything
expect(remaining(budget, 'b', 'c')).toBe(11);
expect(remaining(budget, 'c', 'd')).toBe(12); // + the c-coupled wagon
});
it('nets against cuts on the same budget', () => {
const budget = new CorridorBudget(stops, wagonsOnly);
subtractCutWagons(budget, { 'w-cut': 'c' });
addCoupledWagons(budget, { 'w-new': 'c' });
expect(remaining(budget, 'a', 'c')).toBe(10);
expect(remaining(budget, 'c', 'd')).toBe(10); // cut 1, couple +1
expect(remaining(budget, 'a', 'd')).toBe(10);
});
it('ignores off-corridor and destination couple yards, and a missing plan', () => {
const budget = new CorridorBudget(stops, wagonsOnly);
addCoupledWagons(budget, { 'w-1': 'elsewhere', 'w-2': 'd' });
addCoupledWagons(budget, null);
addCoupledWagons(budget, undefined);
expect(remaining(budget, 'a', 'd')).toBe(10);
});
});

View File

@@ -48,9 +48,38 @@ export function capacityFits(need: Capacity, budget: Capacity): boolean {
}
/**
* Ordered stop yard ids for a schedule. Route milestones (already ordered by
* sequence) when there are at least two; otherwise the schedule's own
* origin/destination pair — the legacy two-stop pseudo-route.
* Orient a milestone-derived stop list to THIS schedule's endpoints.
*
* Milestones run in the route's own direction (import and export routes each
* carry their own ordered sequence, so normally nothing changes). But a
* schedule pointed at a route traversed BACKWARDS (return-leg reuse) would
* otherwise silently break every index-based consumer — `legOf` returns null,
* `subtractCutWagons` no-ops, capacity oversells with zero signal. When the
* list plainly runs destination→origin, reverse it; anything else is left
* untouched (unknown data keeps today's behavior).
*/
export function orientStopsToSchedule(
stops: string[],
originStationId: string,
destinationStationId: string,
): string[] {
if (
stops.length >= 2 &&
stops[0] !== originStationId &&
stops[0] === destinationStationId &&
stops[stops.length - 1] === originStationId
) {
return [...stops].reverse();
}
return stops;
}
/**
* Ordered stop yard ids for a schedule. Route milestones (ordered by
* sequence, oriented to the schedule's own endpoints — import and export
* both) when there are at least two; otherwise the schedule's own
* origin/destination pair around any stray milestone, so a one-milestone
* route keeps its middle stop (same shape as `stopYardsForSchedule`).
*/
export function stopYardsFor(
milestoneYardIdsInOrder: string[] | null | undefined,
@@ -58,9 +87,60 @@ export function stopYardsFor(
destinationStationId: string,
): string[] {
if (milestoneYardIdsInOrder && milestoneYardIdsInOrder.length >= 2) {
return milestoneYardIdsInOrder;
return orientStopsToSchedule(
milestoneYardIdsInOrder,
originStationId,
destinationStationId,
);
}
const raw = [
originStationId,
...(milestoneYardIdsInOrder ?? []),
destinationStationId,
];
const unique: string[] = [];
for (const yardId of raw) {
if (yardId && !unique.includes(yardId)) unique.push(yardId);
}
return unique;
}
/**
* Debit the corridor for wagons staff cut mid-route: each cut wagon is gone
* from every edge at/after its cut stop ([cut, destination)). A cut yard not
* on this corridor — or equal to the destination — is ignored; validation in
* updateScheduleWagonYards owns rejecting it, and a fullLeg() fallback here
* would wrongly zero the whole route.
*/
export function subtractCutWagons(
budget: CorridorBudget,
cutPlan: Record<string, string> | null | undefined,
): void {
if (!cutPlan) return;
const destination = budget.stops[budget.stops.length - 1];
for (const cutYardId of Object.values(cutPlan)) {
const leg = budget.legOf(cutYardId, destination);
if (leg) budget.subtract({ wagons: 1, weightTons: 0, lengthMeters: 0 }, leg);
}
}
/**
* Credit the corridor for LOOSE wagons the schedule plans to COUPLE onto the
* train mid-route: each coupled wagon adds a slot on every edge at/after its
* couple stop ([couple, destination)). A couple yard not on the corridor —
* or equal to the destination — is ignored; updateScheduleWagonYards owns
* rejecting it.
*/
export function addCoupledWagons(
budget: CorridorBudget,
couplePlan: Record<string, string> | null | undefined,
): void {
if (!couplePlan) return;
const destination = budget.stops[budget.stops.length - 1];
for (const coupleYardId of Object.values(couplePlan)) {
const leg = budget.legOf(coupleYardId, destination);
if (leg) budget.add({ wagons: 1, weightTons: 0, lengthMeters: 0 }, leg);
}
return [originStationId, destinationStationId];
}
/** Overage a locomotive may absorb beyond its base caps. */

View File

@@ -56,6 +56,14 @@ export class AssignBookingsDto {
@IsBoolean()
forceAssign?: boolean;
@ApiPropertyOptional({
description:
'Linked bookings the plan cannot seat stay linked as WAITING_FOR_WAGON instead of failing the whole allocation (auto-allocation mode). Requested bookingIds still fail loudly.',
})
@IsOptional()
@IsBoolean()
keepDeferredLinked?: boolean;
@ApiPropertyOptional({ type: [ContainerPlacementDto] })
@IsOptional()
@IsArray()

View File

@@ -0,0 +1,93 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsOptional,
IsUUID,
ValidateIf,
ValidateNested,
} from 'class-validator';
export class ScheduleWagonYardMoveDto {
@ApiProperty({ format: 'uuid', description: "Wagon coupled to the schedule's built train." })
@IsUUID()
wagonId!: string;
@ApiPropertyOptional({
format: 'uuid',
description: 'Pickup stop of the route this departure boards the wagon from. Omit to leave unchanged.',
})
@IsOptional()
@IsUUID()
yardId?: string;
@ApiPropertyOptional({
format: 'uuid',
nullable: true,
description:
'Drop stop this departure CUTS the wagon at (detached, left behind). null clears it — the wagon rides to the destination. Omit to leave unchanged.',
})
@IsOptional()
@ValidateIf((o: ScheduleWagonYardMoveDto) => o.cutYardId !== null)
@IsUUID()
cutYardId?: string | null;
@ApiPropertyOptional({
description:
'true: REAL cut — the built train permanently loses the wagon at its cut yard. false: soft cut (default) — the wagon sits out this trip but stays in the build. Requires a cut yard.',
})
@IsOptional()
@IsBoolean()
realCut?: boolean;
}
export class ScheduleWagonCoupleDto {
@ApiProperty({ format: 'uuid', description: 'Loose wagon (no built train) to couple.' })
@IsUUID()
wagonId!: string;
@ApiProperty({
format: 'uuid',
description: 'Pickup stop the wagon joins the train at. It must physically stand there.',
})
@IsUUID()
yardId!: string;
}
export class UpdateScheduleWagonYardsDto {
@ApiProperty({
type: [ScheduleWagonYardMoveDto],
description:
'Wagon → planned boarding yard for THIS schedule only. Physical wagon yards are untouched; dispatch requires both to agree.',
})
@IsOptional()
@IsArray()
@ArrayMaxSize(500)
@ValidateNested({ each: true })
@Type(() => ScheduleWagonYardMoveDto)
moves?: ScheduleWagonYardMoveDto[];
@ApiPropertyOptional({
type: [ScheduleWagonCoupleDto],
description:
'Loose wagons to plan-couple onto the train at a pickup stop. They join the built train permanently when the trip reaches that stop.',
})
@IsOptional()
@IsArray()
@ArrayMaxSize(100)
@ValidateNested({ each: true })
@Type(() => ScheduleWagonCoupleDto)
couple?: ScheduleWagonCoupleDto[];
@ApiPropertyOptional({
type: [String],
description: 'Wagon ids to remove from the couple plan (before execution).',
})
@IsOptional()
@IsArray()
@ArrayMaxSize(100)
@IsUUID('all', { each: true })
uncouple?: string[];
}

View File

@@ -0,0 +1,59 @@
import { computeEdgeLoads } from './edge-load.util';
describe('edge-load.util — computeEdgeLoads', () => {
// gmp -> lebu -> mojo -> adama -> dct: 4 edges.
const EDGES = 4;
const wagon = (fromEdge: number, toEdge: number) => ({
fromEdge,
toEdge,
tareTons: 25,
lengthMeters: 17,
});
it('an uncut whole-route consist loads every edge flat', () => {
const loads = computeEdgeLoads(EDGES, [wagon(0, 4), wagon(0, 4)], []);
for (const e of loads) {
expect(e.weightTons).toBe(50);
expect(e.lengthMeters).toBe(34);
}
});
it('a cut frees tare and length on the edges past the cut', () => {
// One wagon cut at mojo (edge index 2): rides edges 0-1 only.
const loads = computeEdgeLoads(EDGES, [wagon(0, 4), wagon(0, 2)], []);
expect(loads[1]).toEqual({ weightTons: 50, lengthMeters: 34 });
expect(loads[2]).toEqual({ weightTons: 25, lengthMeters: 17 });
expect(loads[3]).toEqual({ weightTons: 25, lengthMeters: 17 });
});
it('a couple adds tare and length only from its couple stop', () => {
const loads = computeEdgeLoads(EDGES, [wagon(0, 4), wagon(2, 4)], []);
expect(loads[1]).toEqual({ weightTons: 25, lengthMeters: 17 });
expect(loads[2]).toEqual({ weightTons: 50, lengthMeters: 34 });
});
it('cut-then-couple at the same stop nets to a flat load', () => {
const loads = computeEdgeLoads(EDGES, [wagon(0, 2), wagon(2, 4)], []);
for (const e of loads) {
expect(e.weightTons).toBe(25);
expect(e.lengthMeters).toBe(17);
}
});
it('cargo weighs only the edges of its own leg', () => {
const loads = computeEdgeLoads(
EDGES,
[wagon(0, 4)],
[{ fromEdge: 1, toEdge: 3, weightTons: 60 }],
);
expect(loads[0].weightTons).toBe(25);
expect(loads[1].weightTons).toBe(85);
expect(loads[2].weightTons).toBe(85);
expect(loads[3].weightTons).toBe(25);
});
it('clamps out-of-range spans instead of throwing', () => {
const loads = computeEdgeLoads(EDGES, [wagon(-2, 99)], []);
for (const e of loads) expect(e.weightTons).toBe(25);
});
});

View File

@@ -0,0 +1,50 @@
/**
* Per-corridor-edge physical load of a train: tare + length of the wagons
* spanning each edge, plus the cargo weight riding it. Used to validate that
* a planned mid-route COUPLE keeps every leg within the locomotives' pull
* weight and train length limits — a wagon cut at Mojo frees its tare/length
* on the edges past Mojo, a wagon coupled there adds its own only from there.
*/
export interface EdgeLoad {
weightTons: number;
lengthMeters: number;
}
export interface EdgeWagonSpan {
/** Half-open edge span [fromEdge, toEdge) the wagon physically rides. */
fromEdge: number;
toEdge: number;
tareTons: number;
lengthMeters: number;
}
export interface EdgeCargoLeg {
fromEdge: number;
toEdge: number;
weightTons: number;
}
export function computeEdgeLoads(
edgeCount: number,
wagonSpans: readonly EdgeWagonSpan[],
cargoLegs: readonly EdgeCargoLeg[],
): EdgeLoad[] {
const loads: EdgeLoad[] = Array.from({ length: Math.max(1, edgeCount) }, () => ({
weightTons: 0,
lengthMeters: 0,
}));
const clamp = (edge: number) => Math.min(Math.max(edge, 0), loads.length);
for (const span of wagonSpans) {
for (let e = clamp(span.fromEdge); e < clamp(span.toEdge); e += 1) {
loads[e].weightTons += span.tareTons;
loads[e].lengthMeters += span.lengthMeters;
}
}
for (const cargo of cargoLegs) {
for (let e = clamp(cargo.fromEdge); e < clamp(cargo.toEdge); e += 1) {
loads[e].weightTons += cargo.weightTons;
}
}
return loads;
}

View File

@@ -35,11 +35,34 @@ export type DeferredBookingRow = {
shortage?: BookingWagonShortage | null;
};
/** A booking the customer has already paid for. */
const isPaid = (booking: Booking): boolean =>
booking.paymentStatus === 'PAID' || booking.status === 'PAID';
/**
* Seating order for the wagon planner.
*
* Government first, then PAID bookings, then priority score, then date.
*
* Payment ranks above priority score on purpose: money has changed hands and
* the customer was promised space on THIS train. Without it the planner
* seated an unpaid booking that merely arrived earlier and left a paid one
* with no wagon — the reported S-2026-00045 case, where a paid 695T bulk
* booking lost every wagon to unpaid container bookings and vanished from
* the train with free PW2 still standing in the consist.
*
* This only decides who is seated FIRST when the train is oversubscribed. It
* never invents capacity: an oversubscribed train still defers someone, and
* that someone is now the party who has not paid.
*/
export function sortBookingsForScheduling(bookings: Booking[]): Booking[] {
return [...bookings].sort((a, b) => {
const govDiff = Number(Boolean(b.isGovernment)) - Number(Boolean(a.isGovernment));
if (govDiff !== 0) return govDiff;
const paidDiff = Number(isPaid(b)) - Number(isPaid(a));
if (paidDiff !== 0) return paidDiff;
const priorityDiff = (b.priorityScore ?? 0) - (a.priorityScore ?? 0);
if (priorityDiff !== 0) return priorityDiff;

View File

@@ -0,0 +1,30 @@
import {
defaultPlannedWagonYards,
misalignedWagons,
scheduleYardOf,
} from './planned-wagon-yards.util';
const w = (id: string, currentYardId: string | null) => ({ id, currentYardId });
describe('planned-wagon-yards.util', () => {
it('scheduleYardOf prefers the plan and falls back to the physical yard', () => {
expect(scheduleYardOf({ w1: 'B' }, w('w1', 'C'))).toBe('B');
expect(scheduleYardOf({ w1: 'B' }, w('w2', 'C'))).toBe('C');
expect(scheduleYardOf(null, w('w2', null))).toBeNull();
});
it('defaultPlannedWagonYards snapshots on-route yards and rehomes the rest to origin', () => {
const { plan, rehomed } = defaultPlannedWagonYards(
[w('a', 'A'), w('b', 'B'), w('x', 'X'), w('n', null)],
new Set(['A', 'B', 'C']),
'A',
);
expect(plan).toEqual({ a: 'A', b: 'B', x: 'A', n: 'A' });
expect(rehomed.map((r) => r.id)).toEqual(['x', 'n']);
});
it('misalignedWagons lists only planned wagons standing elsewhere', () => {
const out = misalignedWagons({ a: 'A', b: 'B' }, [w('a', 'A'), w('b', 'C'), w('z', 'Z')]);
expect(out.map((r) => r.id)).toEqual(['b']);
});
});

View File

@@ -0,0 +1,51 @@
/**
* Per-schedule wagon yard plan: `{ wagonId: yardId }` — where THIS departure
* boards each consist wagon, independent of where the steel physically stands
* (`wagons.current_yard_id`, one fact shared by every schedule of the train).
*/
export type PlannedWagonYards = Record<string, string>;
type YardedWagon = { id: string; currentYardId: string | null };
/** Yard a schedule boards a wagon from: its own plan first, the physical yard otherwise. */
export function scheduleYardOf(
plan: PlannedWagonYards | null | undefined,
wagon: YardedWagon,
): string | null {
return plan?.[wagon.id] ?? wagon.currentYardId;
}
/**
* Default plan when a schedule is created from a built train: snapshot every
* wagon's physical yard (so later physical moves never shift this departure's
* capacity); a wagon standing off the route's pickup stops — or nowhere — is
* planned at the origin instead, and reported back so staff can redistribute.
*/
export function defaultPlannedWagonYards(
wagons: readonly YardedWagon[],
pickupYardIds: ReadonlySet<string>,
originYardId: string,
): { plan: PlannedWagonYards; rehomed: YardedWagon[] } {
const plan: PlannedWagonYards = {};
const rehomed: YardedWagon[] = [];
for (const wagon of wagons) {
if (wagon.currentYardId && pickupYardIds.has(wagon.currentYardId)) {
plan[wagon.id] = wagon.currentYardId;
} else {
plan[wagon.id] = originYardId;
rehomed.push(wagon);
}
}
return { plan, rehomed };
}
/** Wagons whose planned yard disagrees with where they physically stand. */
export function misalignedWagons<T extends YardedWagon>(
plan: PlannedWagonYards | null | undefined,
wagons: readonly T[],
): T[] {
return wagons.filter((w) => {
const planned = plan?.[w.id];
return planned != null && planned !== w.currentYardId;
});
}

View File

@@ -14,6 +14,8 @@ import {
sumWagonsRequired,
validate20ftContainerRules,
validateContainerPlacements,
validateMixedTrainLimitsPerEdge,
validateWagonCargoExclusivity,
} from './wagon-plan.util';
const nw5: WagonType = {
@@ -222,6 +224,85 @@ describe('wagon-plan.util', () => {
expect(plan[0]?.slotLoadType).toBe('BULK');
expect(buildBulkWagonPlan([bulkBooking], cw3)).toHaveLength(1);
});
it('never pools two bulk bookings on one wagon', () => {
// 5T + 40T both fit a single 60T CW3 by tonnage — but a wagon with bulk
// takes that one load only, so each booking gets its own wagon.
const small = {
id: 'bulk-5',
reference: 'bulk-5',
freightType: 'BULK',
cargoTotalWeightVgm: 5,
bookingContainers: [],
} as unknown as Booking;
const other = {
id: 'bulk-40',
reference: 'bulk-40',
freightType: 'BULK',
cargoTotalWeightVgm: 40,
bookingContainers: [],
} as unknown as Booking;
const plan = buildBulkWagonPlan([small, other], cw3);
expect(plan).toHaveLength(2);
for (const slot of plan) {
expect(slot.allocations).toHaveLength(1);
}
expect(plan[0]?.allocations[0]?.bookingId).toBe('bulk-5');
expect(plan[1]?.allocations[0]?.bookingId).toBe('bulk-40');
expect(validateWagonCargoExclusivity(plan)).toEqual([]);
});
it('a multi-wagon bulk booking still spreads over its own wagons', () => {
const big = {
id: 'bulk-130',
reference: 'bulk-130',
freightType: 'BULK',
cargoTotalWeightVgm: 130,
bookingContainers: [],
} as unknown as Booking;
const plan = buildBulkWagonPlan([big], cw3);
expect(plan).toHaveLength(3);
expect(plan.map((s) => s.allocations[0]?.allocatedWeightTons)).toEqual([60, 60, 10]);
});
it('flags a wagon mixing bulk with anything else', () => {
const bulkAlloc = {
bookingId: 'b',
bookingReference: 'b',
allocatedWeightTons: 5,
loadType: AllocationLoadType.Bulk,
};
const containerAlloc = {
bookingId: 'c',
bookingReference: 'c',
allocatedWeightTons: 25,
loadType: AllocationLoadType.Container,
};
const slot = (allocations: (typeof bulkAlloc)[]) => ({
sequenceNo: 1,
wagonTypeId: cw3.id,
wagonTypeCode: cw3.code,
capacityTons: 60,
lengthMeters: 14,
tareWeightTons: 24,
assignedWeightTons: 0,
allocations,
});
// bulk + container on one wagon
expect(validateWagonCargoExclusivity([slot([bulkAlloc, containerAlloc])]))
.toHaveLength(1);
// bulk + bulk on one wagon
expect(
validateWagonCargoExclusivity([slot([bulkAlloc, { ...bulkAlloc, bookingId: 'b2' }])]),
).toHaveLength(1);
// bulk alone, and containers sharing, are fine
expect(validateWagonCargoExclusivity([slot([bulkAlloc])])).toEqual([]);
expect(
validateWagonCargoExclusivity([
slot([containerAlloc, { ...containerAlloc, bookingId: 'c2' }]),
]),
).toEqual([]);
});
});
describe('containerWagonsForLines — TEU-aware, ceil booking total once', () => {
@@ -332,4 +413,91 @@ describe('maxEdgeConsistUsage — the binding edge, not the whole-route sum', ()
loadedWagonCount: 2,
});
});
it('with a legs map, shared-slot cargo weighs only its own edges (the S-2026-00045 shape)', () => {
// One wagon reused across legs: booking X rides a→b (40T), booking Y
// boards at b with 30T. The slot spans the whole route, but edge a→b
// must weigh 24 + 40 = 64T — not 24 + 70. Tare rides both edges.
const shared = {
tareWeightTons: 24,
assignedWeightTons: 70,
lengthMeters: 14,
boardYardId: null,
alightYardId: null,
allocations: [
{ bookingId: 'X', allocatedWeightTons: 40 },
{ bookingId: 'Y', allocatedWeightTons: 30 },
],
} as never;
const legs = new Map([
['X', { from: 0, to: 1 }],
['Y', { from: 1, to: 2 }],
]);
// Without legs: whole-span scalar on both edges (94T binding edge).
expect(maxEdgeConsistUsage([shared], stops).grossWeightTons).toBe(94);
// With legs: heaviest edge is a→b at 64T (b→c is 54T).
expect(maxEdgeConsistUsage([shared], stops, legs).grossWeightTons).toBe(64);
});
it('falls back to the whole-span scalar when an allocation has no readable weight', () => {
const shared = {
tareWeightTons: 24,
assignedWeightTons: 70,
lengthMeters: 14,
boardYardId: null,
alightYardId: null,
allocations: [{ bookingId: 'X' }],
} as never;
const legs = new Map([['X', { from: 0, to: 1 }]]);
expect(maxEdgeConsistUsage([shared], stops, legs).grossWeightTons).toBe(94);
});
});
describe('validateMixedTrainLimitsPerEdge — leg-aware cargo weighing', () => {
it('does not flag a leg whose overweight is only later-boarding cargo (S-2026-00045)', () => {
// 2 shared wagons, 100T cap. Booking X rides a→b with 30T/wagon, booking Y
// boards at b with 25T/wagon. Whole-span scalars read every edge as
// 2×(20 + 55) = 150T > 100T; the cargo actually aboard is 100T (a→b) and
// 90T (b→c) — both fit.
const slot = (seq: number) => ({
sequenceNo: seq,
wagonTypeId: 'wt-nw5',
wagonTypeCode: 'NW5',
capacityTons: 70,
lengthMeters: 14,
tareWeightTons: 20,
assignedWeightTons: 55,
boardYardId: null,
alightYardId: null,
allocations: [
{
bookingId: 'X',
bookingReference: 'X',
allocatedWeightTons: 30,
loadType: AllocationLoadType.Container,
},
{
bookingId: 'Y',
bookingReference: 'Y',
allocatedWeightTons: 25,
loadType: AllocationLoadType.Container,
},
],
});
const legs = new Map([
['X', { from: 0, to: 1 }],
['Y', { from: 1, to: 2 }],
]);
const run = (withLegs?: typeof legs) =>
validateMixedTrainLimitsPerEdge(
[slot(1), slot(2)] as never,
[{ lengthMeters: 14 }],
{ maxWeightTons: 100 },
['a', 'b', 'c'],
undefined,
withLegs,
);
expect(run()).toHaveLength(2); // both edges falsely overweight without legs
expect(run(legs)).toHaveLength(0);
});
});

View File

@@ -200,16 +200,14 @@ export function buildBulkWagonPlan(
);
const cappedTonSlots = cappedTonSlotsByBooking.reduce((sum, n) => sum + n, 0);
const totalWeight = roundTons(
bookings.reduce(
(sum, b, i) =>
itemSlotsByBooking[i] > 0 || cappedTonSlotsByBooking[i] > 0
? sum
: sum + Number(b.cargoTotalWeightVgm ?? 0),
0,
),
);
const tonSlots = totalWeight > 0 ? Math.ceil(totalWeight / capacity) : 0;
// One bulk booking per wagon — bookings never pool tonnage on a shared
// wagon, so each uncapped booking sizes its own wagons (ceil per booking,
// not over the pooled total).
const tonSlots = bookings.reduce((sum, b, i) => {
if (itemSlotsByBooking[i] > 0 || cappedTonSlotsByBooking[i] > 0) return sum;
const weight = roundTons(Number(b.cargoTotalWeightVgm ?? 0));
return weight > 0 ? sum + Math.ceil(weight / capacity) : sum;
}, 0);
const slots = Math.max(1, tonSlots + itemSlots + cappedTonSlots);
const basePlan: WagonPlanSlot[] = Array.from({ length: slots }, (_, index) => ({
@@ -374,13 +372,12 @@ function allocateBookingsToSlots(
if (booking.remainingWeightTons <= 0) {
bookingIndex += 1;
} else if (allocatedWeightTons >= takeCap) {
// The cap stopped this wagon short of its rating and the booking has
// more to load. The leftover room is NOT free: `buildBulkWagonPlan`
// already reserved a wagon for the rest, so backfilling another booking
// here would double-book the consist. Close the wagon.
break;
}
// One bulk booking per wagon: a wagon carrying bulk takes nothing else —
// never a second booking's cargo. `buildBulkWagonPlan` sized the slots
// per booking, so leftover room on this wagon is not free capacity.
// Close the wagon after its single allocation.
break;
}
return { ...slot, assignedWeightTons, allocations };
@@ -504,6 +501,54 @@ export function sumWagonsRequired(booking: Booking, wagonPlan?: WagonPlanSlot[])
);
}
/**
* One wagon carries one kind of cargo AT A TIME: while a bulk load rides, the
* wagon holds nothing else — no container beside it and no second bulk
* booking. Container allocations may share a wagon with each other (TEU rules
* apply).
*
* "At a time" is the whole rule: a wagon whose cargo alights at Dire Dawa is
* empty steel for whatever boards there, so an import container on
* Doraleh→Dire and bulk on Dire→Kality legitimately share one wagon. Pass
* `legs` (booking id → stop-index span) to check per corridor edge; without
* it every allocation is treated as riding the whole route, which is the
* correct reading for a single-leg train.
*/
export function validateWagonCargoExclusivity(
wagonPlan: WagonPlanSlot[],
legs?: Map<string, { from: number; to: number }>,
edgeCount = 1,
): string[] {
const violations: string[] = [];
const edges = Math.max(1, edgeCount);
const spanOf = (bookingId: string) => {
const leg = legs?.get(bookingId);
if (!leg || leg.from < 0 || leg.to > edges || leg.from >= leg.to) {
return { from: 0, to: edges };
}
return leg;
};
for (const slot of wagonPlan) {
if (slot.allocations.length < 2) continue;
// Per edge: who is on this wagon while it rides that edge?
for (let edge = 0; edge < edges; edge += 1) {
const riding = slot.allocations.filter((a) => {
const span = spanOf(a.bookingId);
return span.from <= edge && edge < span.to;
});
if (riding.length < 2) continue;
if (riding.some((a) => a.loadType === AllocationLoadType.Bulk)) {
violations.push(
`Wagon #${slot.sequenceNo} mixes bulk with other cargo — a wagon carrying bulk takes that one load only`,
);
break;
}
}
}
return violations;
}
export function validateBulkWagonSlotWeights(wagonPlan: WagonPlanSlot[]): string[] {
const violations: string[] = [];
for (const slot of wagonPlan.filter((s) => s.slotLoadType === 'BULK')) {
@@ -530,6 +575,9 @@ export function validateTrainLimits(
wagonPlan: WagonPlanSlot[],
wagonType: Pick<WagonType, 'lengthMeters'>,
limits?: TrainLimitConfig,
/** Leg-aware cargo exclusivity — see {@link validateWagonCargoExclusivity}. */
legs?: Map<string, { from: number; to: number }>,
edgeCount?: number,
): string[] {
const maxWeightTons = limits?.maxWeightTons ?? MAX_TRAIN_WEIGHT_TONS;
const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS;
@@ -547,6 +595,7 @@ export function validateTrainLimits(
);
violations.push(...validateBulkWagonSlotWeights(wagonPlan));
violations.push(...validateWagonCargoExclusivity(wagonPlan, legs, edgeCount));
return violations;
}
@@ -560,6 +609,8 @@ export function validateMixedTrainLimits(
wagonPlan: WagonPlanSlot[],
wagonTypes: Array<Pick<WagonType, 'lengthMeters'>>,
limits?: TrainLimitConfig,
legs?: Map<string, { from: number; to: number }>,
edgeCount?: number,
): string[] {
const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS;
const minWagonLength = Math.min(
@@ -573,6 +624,8 @@ export function validateMixedTrainLimits(
wagonPlan,
{ lengthMeters: minWagonLength },
{ ...limits, maxWagonsPerTrain },
legs,
edgeCount,
);
}
@@ -590,17 +643,35 @@ export function validateMixedTrainLimitsPerEdge(
stops: string[],
/** Display names parallel to `stops` — violations then name the leg they hit. */
stopLabels?: string[],
/** Booking id → stop-index span, so cargo exclusivity is judged per edge. */
legs?: Map<string, { from: number; to: number }>,
): string[] {
if (stops.length <= 2) return validateMixedTrainLimits(wagonPlan, wagonTypes, limits);
const edges = Math.max(1, stops.length - 1);
if (stops.length <= 2) {
return validateMixedTrainLimits(wagonPlan, wagonTypes, limits, legs, edges);
}
const spans = slotSpans(wagonPlan, stops);
const label = (i: number) => stopLabels?.[i] ?? stops[i];
const violations = new Set<string>();
for (let edge = 0; edge < stops.length - 1; edge += 1) {
const active = wagonPlan.filter(
(_, i) => spans[i].from <= edge && edge < spans[i].to,
);
// A shared slot rides the UNION of its cargo legs, but only carries each
// booking's cargo on that booking's own edges — weigh the edge with the
// cargo actually aboard there, not the slot's whole-route scalar, or a
// container boarding at Dire Dawa reads as hauled from Djibouti.
const active = wagonPlan
.filter((_, i) => spans[i].from <= edge && edge < spans[i].to)
.map((slot) => ({
...slot,
assignedWeightTons: slotCargoOnEdge(slot, edge, edges, legs),
}));
if (!active.length) continue;
for (const violation of validateMixedTrainLimits(active, wagonTypes, limits)) {
for (const violation of validateMixedTrainLimits(
active,
wagonTypes,
limits,
legs,
edges,
)) {
violations.add(`Leg ${label(edge)}${label(edge + 1)}: ${violation}`);
}
}
@@ -620,6 +691,40 @@ export type EdgeUsageSlot = Pick<
allocations?: unknown[];
};
/**
* Cargo tons a slot actually carries on one edge. With a legs map and readable
* allocation records, each booking's cargo counts only on the edges that
* booking rides (an unmapped booking stays on the slot's whole span). Without
* either — or when any allocation lacks a numeric weight, e.g. persisted rows
* fed through {@link EdgeUsageSlot} — falls back to the slot's whole-span
* `assignedWeightTons`, the pre-existing reading.
*/
function slotCargoOnEdge(
slot: EdgeUsageSlot,
edge: number,
edgeCount: number,
legs?: Map<string, { from: number; to: number }>,
): number {
const wholeSpanCargo = Number(slot.assignedWeightTons ?? 0);
const allocations = (slot.allocations ?? []) as Array<{
bookingId?: string;
allocatedWeightTons?: number | string;
}>;
if (!legs?.size || !allocations.length) return wholeSpanCargo;
let cargo = 0;
for (const allocation of allocations) {
const weight = Number(allocation?.allocatedWeightTons);
if (!Number.isFinite(weight)) return wholeSpanCargo;
const leg = allocation.bookingId ? legs.get(allocation.bookingId) : undefined;
const rides =
!leg || leg.from < 0 || leg.to > edgeCount || leg.from >= leg.to
? true
: leg.from <= edge && edge < leg.to;
if (rides) cargo += weight;
}
return cargo;
}
/** Per-slot stop-index spans; a yard missing from the stop list keeps the slot on the whole route. */
function slotSpans(
wagonPlan: EdgeUsageSlot[],
@@ -644,8 +749,10 @@ function slotSpans(
export function maxEdgeConsistUsage(
wagonPlan: EdgeUsageSlot[],
stops: string[],
/** Booking id → stop-index span; cargo then weighs only its own edges. */
legs?: Map<string, { from: number; to: number }>,
): { grossWeightTons: number; lengthMeters: number; loadedWagonCount: number } {
return perEdgeConsistUsage(wagonPlan, stops).reduce(
return perEdgeConsistUsage(wagonPlan, stops, legs).reduce(
(max, e) => ({
grossWeightTons: Math.max(max.grossWeightTons, e.grossWeightTons),
lengthMeters: Math.max(max.lengthMeters, e.lengthMeters),
@@ -673,12 +780,19 @@ export type EdgeConsistUsage = {
export function perEdgeConsistUsage(
wagonPlan: EdgeUsageSlot[],
stops: string[],
/**
* Booking id → stop-index span. When given, a shared slot's cargo weighs
* only the edges its booking rides (tare still rides the slot's whole
* span) — without it a slot's full cargo counts on every edge it spans.
*/
legs?: Map<string, { from: number; to: number }>,
): EdgeConsistUsage[] {
const edgeCount = Math.max(1, stops.length - 1);
const totals = (edge: number, slots: EdgeUsageSlot[]): EdgeConsistUsage => ({
edge,
grossWeightTons: slots.reduce(
(sum, w) =>
sum + Number(w.tareWeightTons ?? 0) + Number(w.assignedWeightTons ?? 0),
sum + Number(w.tareWeightTons ?? 0) + slotCargoOnEdge(w, edge, edgeCount, legs),
0,
),
lengthMeters: slots.reduce((sum, w) => sum + Number(w.lengthMeters ?? 0), 0),

View File

@@ -443,3 +443,159 @@ describe('planWagonsWithStock — break-bulk (PER_ITEM) item-aware packing', ()
expect(result.plan.map((s) => s.assignedWeightTons)).toEqual([70, 30]);
});
});
describe('planWagonsWithStock — consist split across yards', () => {
const GMP = 'gmp', MOJO = 'mojo', DCT = 'dct';
const boards = (id: string, quantity: number, originYardId: string): Booking =>
({ ...containerBooking(id, quantity, quantity), originYardId, destinationYardId: DCT }) as Booking;
const allowed = { byContainerTypeId: new Map([['ct-1', [nw6]]]), byCargoTypeId: new Map() };
const legsFor = (bookings: Booking[]) =>
new Map(bookings.map((b) => [b.id, { from: b.originYardId === GMP ? 0 : 1, to: 2 }]));
const splitStock = {
mode: 'TRAIN' as const,
remainingByTypeId: new Map([[nw6.id, 46]]),
codesByTypeId: new Map([[nw6.id, nw6.code]]),
byYardId: new Map([
[GMP, new Map([[nw6.id, 31]])],
[MOJO, new Map([[nw6.id, 15]])],
]),
};
it('seats a boarding yard only from the wagons planned there', () => {
// 20fts pack two per wagon: BKG-A's 30 boxes take all 15 Mojo wagons;
// BKG-B needs 2 more at Mojo → deferred, while BKG-C at Gelan still fits
// (the whole-train 46 is irrelevant).
const bookings = [boards('BKG-A', 30, MOJO), boards('BKG-B', 4, MOJO), boards('BKG-C', 2, GMP)];
const result = planWagonsWithStock({
bookings, allowed, stock: splitStock, legs: legsFor(bookings), edgeCount: 2, stops: [GMP, MOJO, DCT],
});
expect(result.fitting.map((b) => b.id)).toEqual(['BKG-A', 'BKG-C']);
expect(result.deferred.map((d) => d.reference)).toEqual(['BKG-B']);
expect(result.deferred[0]!.reason).toContain('planned at the boarding yard');
expect(result.plan).toHaveLength(16);
});
it('never lets a Gelan 20ft share a wagon that only exists at Mojo', () => {
const stock = {
...splitStock,
remainingByTypeId: new Map([[nw6.id, 1]]),
byYardId: new Map([[MOJO, new Map([[nw6.id, 1]])]]),
};
const bookings = [boards('BKG-M', 1, MOJO), boards('BKG-G', 1, GMP)];
const result = planWagonsWithStock({
bookings, allowed, stock, legs: legsFor(bookings), edgeCount: 2, stops: [GMP, MOJO, DCT],
});
// The Mojo wagon has TEU room, but it is not standing in Gelan.
expect(result.fitting.map((b) => b.id)).toEqual(['BKG-M']);
expect(result.deferred.map((d) => d.reference)).toEqual(['BKG-G']);
});
});
describe('planWagonsWithStock — scarcity-aware bulk (one booking per wagon, capped fill)', () => {
// The S-2026-00044 shape: Perishable rides NW5 (30T cap) or PW2 (20T cap);
// containers ride only NW5. NW5 is the shared, scarce type.
const nw5: WagonType = {
id: 'wt-nw5',
code: 'NW5',
name: 'Flat Wagon',
capacityTons: 70,
lengthMeters: 14,
supportedLoadTypes: ['CONTAINER'],
isActive: true,
supportsContainer: true,
} as WagonType;
const pw2: WagonType = {
id: 'wt-pw2',
code: 'PW2',
name: 'Flat Wagon',
capacityTons: 70,
lengthMeters: 14,
supportedLoadTypes: ['BULK'],
isActive: true,
supportsContainer: false,
} as WagonType;
const perishable = {
id: 'cargo-perishable',
cargoTypeName: 'Perishable',
wagonTypes: [nw5, pw2],
tonsPerWagonMap: { [nw5.id]: 30, [pw2.id]: 20 },
};
const bulkBooking = (id: string, tons: number): Booking =>
({
id,
reference: id,
freightType: 'BULK',
cargoTotalWeightVgm: tons,
cargoTypeId: perishable.id,
cargoType: perishable,
bookingContainers: [],
}) as unknown as Booking;
const allowed = {
byContainerTypeId: new Map([['ct-1', [nw5]]]),
byCargoTypeId: new Map([[perishable.id, [nw5, pw2]]]),
};
const stockOf = (nw5Count: number, pw2Count: number) => ({
mode: 'YARD' as const,
remainingByTypeId: new Map([
[nw5.id, nw5Count],
[pw2.id, pw2Count],
]),
codesByTypeId: new Map([
[nw5.id, nw5.code],
[pw2.id, pw2.code],
]),
});
it('fills the bulk-only PW2s first when containers compete for NW5', () => {
// 695T Perishable + one 40ft container. Smart split: 10 PW2 × 20T = 200T,
// remainder 495T → 17 NW5 × 30T. The container still gets an NW5.
const container = containerBooking('BKG-C', 1, 1);
container.bookingContainers![0]!.containerType = { code: '40GP', sizeFt: 40 } as never;
const result = planWagonsWithStock({
bookings: [bulkBooking('BKG-BULK', 695), container],
allowed,
stock: stockOf(18, 10),
});
expect(result.deferred).toEqual([]);
const bulkSlots = result.plan.filter((s) => s.slotLoadType === 'BULK');
expect(bulkSlots.filter((s) => s.wagonTypeCode === 'PW2')).toHaveLength(10);
expect(bulkSlots.filter((s) => s.wagonTypeCode === 'NW5')).toHaveLength(17);
// Capped fill: no PW2 slot above 20T, no NW5 bulk slot above 30T.
for (const slot of bulkSlots) {
expect(slot.assignedWeightTons).toBeLessThanOrEqual(
slot.wagonTypeCode === 'PW2' ? 20 : 30,
);
}
const containerSlots = result.plan.filter((s) => s.slotLoadType === 'CONTAINER');
expect(containerSlots).toHaveLength(1);
expect(containerSlots[0]?.wagonTypeCode).toBe('NW5');
});
it('prefers the bigger per-cargo take when nothing competes for the shared type', () => {
// Bulk alone (no containers in the run): NW5 30T beats PW2 20T — fewest
// wagons wins, PW2-first would waste consist length.
const result = planWagonsWithStock({
bookings: [bulkBooking('BKG-BULK', 60)],
allowed,
stock: stockOf(10, 10),
});
expect(result.deferred).toEqual([]);
expect(result.plan).toHaveLength(2);
expect(result.plan.every((s) => s.wagonTypeCode === 'NW5')).toBe(true);
});
it('never puts two bulk bookings on one wagon, even same cargo type', () => {
// 5T + 40T both fit one wagon's cap by tonnage — each still gets its own.
const result = planWagonsWithStock({
bookings: [bulkBooking('BKG-A', 5), bulkBooking('BKG-B', 40)],
allowed,
stock: stockOf(10, 0),
});
expect(result.deferred).toEqual([]);
expect(result.plan).toHaveLength(3); // 5T → 1 wagon; 40T @30 cap → 2 wagons
for (const slot of result.plan) {
expect(new Set(slot.allocations.map((a) => a.bookingId)).size).toBe(1);
}
});
});

View File

@@ -5,6 +5,7 @@ import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import {
bookingCargoTons,
bulkItemsFitFor,
bulkTonsPerWagon,
bulkWagonsForAllowedTypes,
} from './train-capacity.util';
import {
@@ -53,6 +54,13 @@ export type WagonStock = {
* math.
*/
byYardId?: Map<string, Map<string, number>>;
/**
* Wagons the schedule CUTS mid-route (staff plan): each is stock only up to
* its cut stop. Consumers debit it from its pool on every edge at/after the
* cut, so a leg riding past the cut never counts it. Absent = no cuts.
* `poolYardId` is the wagon's boarding pool ('' on a single-yard consist).
*/
cutWagons?: Array<{ wagonTypeId: string; poolYardId: string; cutYardId: string }>;
};
export type FlexPlanResult = {
@@ -93,6 +101,12 @@ type OpenSlot = {
legKey: string;
/** Contiguous stop-index span this wagon physically rides (union of its cargo legs). */
covered: { from: number; to: number };
/**
* Boarding-yard pool this wagon was opened from — the yard the consist plans
* it at (`''` when the consist is not split across yards). A Mojo wagon
* cannot later be stretched back to board at Gelan.
*/
pool: string;
};
/** Stop-index range a booking occupies: edges `from..to-1` of the corridor. */
@@ -147,10 +161,46 @@ const shortageFor = (
),
)
: Math.max(1, containerWagonsForLines(booking.bookingContainers ?? []));
const wagonsAvailable = candidates.reduce(
(sum, wt) => sum + availableOf(wt.id),
0,
);
const freeByType = candidates.map((wt) => ({ wt, free: availableOf(wt.id) }));
const wagonsAvailable = freeByType.reduce((sum, c) => sum + c.free, 0);
// PER_TON bulk: a bare wagon COUNT lies when the types carry different
// tonnage for this cargo. 14 NW5 (30T) + 10 PW2 (20T) is "24 wagons free"
// against a 24-wagon need, yet only 620T of the 695T booking fits — which
// is how a deferral could read "needs 24, 24 available (short 1)". Size the
// shortfall in the wagons the cargo's OWN caps require: how many more
// wagons of the best remaining type would carry the leftover tonnage.
const tons = bookingCargoTons(booking);
const perItem =
Number(booking.bulkTotalWeightTons ?? 0) > 0 &&
Number(booking.cargoTotalWeightVgm ?? 0) > 0;
if (booking.freightType === 'BULK' && !perItem && tons > 0) {
let seatable = 0;
let usedWagons = 0;
for (const { wt, free } of freeByType) {
const perWagon = bulkTonsPerWagon(booking.cargoType, wt.id, Number(wt.capacityTons));
if (!(perWagon > 0) || free <= 0) continue;
seatable += free * perWagon;
usedWagons += free;
}
if (seatable < tons) {
const bestPerWagon = Math.max(
1,
...candidates.map((wt) =>
bulkTonsPerWagon(booking.cargoType, wt.id, Number(wt.capacityTons)),
),
);
return {
wagonTypeCodes: [...new Set(candidates.map((wt) => wt.code))].join('/'),
wagonsNeeded,
wagonsAvailable: usedWagons,
// Wagons of the best type still missing to carry the leftover tonnage.
wagonsShort: Math.max(1, Math.ceil((tons - seatable) / bestPerWagon)),
};
}
}
return {
wagonTypeCodes: [...new Set(candidates.map((wt) => wt.code))].join('/'),
wagonsNeeded,
@@ -181,8 +231,10 @@ const addAllocation = (
* containers/tonnage placed on wagons whose type is allowed for its container
* or cargo type) or is deferred with the shortfall reason. Wagon purity rules:
* a wagon carries one kind at a time — containers pack by TEU (one 40ft, or
* two 20ft, never mixed sizes), bulk fills by weight and never shares a wagon
* with a different cargo type.
* two 20ft, never mixed sizes); a bulk wagon carries ONE booking's cargo only,
* filled to the cargo type's per-wagon cap. Type choice is scarcity-aware:
* least-shareable wagon type first, so bulk with a PW2 alternative leaves the
* container-capable NW5s to the containers.
*/
export function planWagonsWithStock(params: {
bookings: Booking[];
@@ -197,14 +249,56 @@ export function planWagonsWithStock(params: {
*/
legs?: Map<string, BookingLeg>;
edgeCount?: number;
/**
* Ordered corridor stop ids, parallel to the edges. Required for a consist
* split across yards (`stock.byYardId`): a booking then draws ONLY from the
* wagons planned at the yard it boards from (`stops[leg.from]`) — the
* whole-train count would happily plan 17 Mojo wagons on a train that has
* 15 there and 31 in Gelan, and the physical pin then fails after the
* customer has paid.
*/
stops?: readonly string[];
}): FlexPlanResult {
const { bookings, allowed, stock, legs } = params;
const edgeCount = Math.max(1, params.edgeCount ?? 1);
const stops = params.stops ?? [];
const openSlots: OpenSlot[] = [];
const fitting: Booking[] = [];
const deferred: DeferredBookingRow[] = [];
const configIssues = new Set<string>();
// Scarcity rank: how many distinct demand groups (container types / bulk
// cargo types) among THESE bookings can ride each wagon type. When a cargo
// can choose, it takes the least-shareable type first, keeping versatile
// types (e.g. container-capable NW5) free for the cargo that has no
// alternative. A type nobody else wants ranks 1; unranked types rank 1 too
// (nothing competes for them).
const demandGroups = new Map<string, WagonType[]>();
for (const b of bookings) {
if (b.freightType === 'CONTAINER') {
for (const line of b.bookingContainers ?? []) {
const containerTypeId = line.containerTypeId ?? line.containerType?.id;
if (!containerTypeId) continue;
demandGroups.set(
`C:${containerTypeId}`,
allowed.byContainerTypeId.get(containerTypeId) ?? [],
);
}
} else {
const cargoTypeId = b.cargoTypeId ?? b.cargoType?.id;
if (cargoTypeId) {
demandGroups.set(`B:${cargoTypeId}`, allowed.byCargoTypeId.get(cargoTypeId) ?? []);
}
}
}
const scarcityRank = new Map<string, number>();
for (const types of demandGroups.values()) {
for (const wt of types) {
scarcityRank.set(wt.id, (scarcityRank.get(wt.id) ?? 0) + 1);
}
}
const rankOf = (wt: WagonType): number => scarcityRank.get(wt.id) ?? 1;
const legFor = (booking: Booking): BookingLeg => {
const leg = legs?.get(booking.id);
if (!leg || leg.from < 0 || leg.to > edgeCount || leg.from >= leg.to) {
@@ -214,32 +308,55 @@ export function planWagonsWithStock(params: {
};
const legKeyOf = (leg: BookingLeg) => `${leg.from}-${leg.to}`;
// Wagons of a type in use per corridor edge. A type is available for a leg
// when its busiest edge WITHIN that leg still has stock spare — the max over
// edges is the number of physical wagons the type needs simultaneously.
// Split consist: each boarding yard is its own pool of steel (mirrors
// WagonStockLedger). Single-yard consist / loose yard pool: one pool ''.
const poolOf = (leg: BookingLeg): string =>
stock.byYardId ? (stops[leg.from] ?? '') : '';
const rowKeyFor = (wagonTypeId: string, pool: string): string =>
pool ? `${pool}\u0000${wagonTypeId}` : wagonTypeId;
const totalFor = (wagonTypeId: string, pool: string): number =>
pool
? (stock.byYardId?.get(pool)?.get(wagonTypeId) ?? 0)
: (stock.remainingByTypeId.get(wagonTypeId) ?? 0);
// Wagons of a type in use per corridor edge, per pool. A type is available
// for a leg when its busiest edge WITHIN that leg still has stock spare — the
// max over edges is the number of physical wagons the type needs simultaneously.
const usedPerEdge = new Map<string, number[]>();
const usedRow = (wagonTypeId: string): number[] => {
let row = usedPerEdge.get(wagonTypeId);
const usedRow = (key: string): number[] => {
let row = usedPerEdge.get(key);
if (!row) {
row = new Array<number>(edgeCount).fill(0);
usedPerEdge.set(wagonTypeId, row);
usedPerEdge.set(key, row);
}
return row;
};
// Cut wagons are pre-consumed on every edge at/after their cut stop: they
// are steel for gmp→lebu but not for gmp→dct. Unknown cut yard (no stops
// given / off-corridor) is skipped — conservative, same as before cuts.
for (const cut of stock.cutWagons ?? []) {
const fromEdge = stops.indexOf(cut.cutYardId);
if (fromEdge < 0) continue;
const pool = stock.byYardId ? cut.poolYardId : '';
const row = usedRow(rowKeyFor(cut.wagonTypeId, pool));
for (let e = fromEdge; e < edgeCount; e += 1) row[e] += 1;
}
const availableFor = (wagonTypeId: string, leg: BookingLeg): number => {
const total = stock.remainingByTypeId.get(wagonTypeId) ?? 0;
const row = usedPerEdge.get(wagonTypeId);
const pool = poolOf(leg);
const total = totalFor(wagonTypeId, pool);
const row = usedPerEdge.get(rowKeyFor(wagonTypeId, pool));
if (!row) return total;
let busiest = 0;
for (let e = leg.from; e < leg.to; e += 1) busiest = Math.max(busiest, row[e] ?? 0);
return total - busiest;
};
const noStockMessage = (candidates: WagonType[]): string => {
const noStockMessage = (candidates: WagonType[], leg: BookingLeg): string => {
const codes = candidates.map((wt) => wt.code).join('/');
return stock.mode === 'TRAIN'
? `Train has no free ${codes} wagon left`
: `No available ${codes} wagon at the yard`;
if (stock.mode !== 'TRAIN') return `No available ${codes} wagon at the yard`;
return poolOf(leg)
? `Train has no free ${codes} wagon planned at the boarding yard`
: `Train has no free ${codes} wagon left`;
};
/** Open a new wagon of one of the candidate types, consuming stock on the leg's edges. */
@@ -248,29 +365,42 @@ export function planWagonsWithStock(params: {
kind: SlotLoadType,
cargoTypeId: string | null,
leg: BookingLeg,
/** Bulk only: the booking's cargo type, for its per-wagon tonnage cap. */
cargoType?: Booking['cargoType'],
): OpenSlot | PlacementProblem => {
const inStock = candidates.filter((wt) => availableFor(wt.id, leg) > 0);
if (!inStock.length) {
return { kind: 'stock', message: noStockMessage(candidates), candidates };
return { kind: 'stock', message: noStockMessage(candidates, leg), candidates };
}
// Bulk favors the largest wagon (fewest wagons for the tonnage); containers
// Least-shareable type first (see scarcityRank) so cargo with alternatives
// never starves cargo without one. Bulk then favors the biggest per-wagon
// take for THIS cargo (its configured cap, not the raw rating); containers
// favor the deepest stock so the consist drains evenly. Ties keep config order.
const bulkTakeOf = (wt: WagonType): number =>
bulkTonsPerWagon(cargoType, wt.id, Number(wt.capacityTons));
const chosen = [...inStock].sort((a, b) =>
kind === 'BULK'
? Number(b.capacityTons) - Number(a.capacityTons) ||
? rankOf(a) - rankOf(b) ||
bulkTakeOf(b) - bulkTakeOf(a) ||
availableFor(b.id, leg) - availableFor(a.id, leg)
: availableFor(b.id, leg) - availableFor(a.id, leg),
: rankOf(a) - rankOf(b) ||
availableFor(b.id, leg) - availableFor(a.id, leg),
)[0];
const row = usedRow(chosen.id);
const pool = poolOf(leg);
const row = usedRow(rowKeyFor(chosen.id, pool));
for (let e = leg.from; e < leg.to; e += 1) row[e] = (row[e] ?? 0) + 1;
const open: OpenSlot = {
slot: slotFromWagonType(chosen, kind),
teuPerEdge: new Array<number>(edgeCount).fill(0),
kind,
cargoTypeId,
freeCapacityTons: Number(chosen.capacityTons),
// A bulk wagon fills to the cargo type's configured per-wagon cap
// (Perishable: 20T on PW2, 30T on NW5), never the raw 70T rating.
freeCapacityTons:
kind === 'BULK' ? bulkTakeOf(chosen) : Number(chosen.capacityTons),
legKey: legKeyOf(leg),
covered: { ...leg },
pool,
};
openSlots.push(open);
return open;
@@ -290,8 +420,11 @@ export function planWagonsWithStock(params: {
* slot's type spare — extending the span puts this wagon on those edges.
*/
const canExtendSpan = (open: OpenSlot, leg: BookingLeg): boolean => {
const total = stock.remainingByTypeId.get(open.slot.wagonTypeId) ?? 0;
const row = usedPerEdge.get(open.slot.wagonTypeId);
// A pooled wagon boards where its yard is; it cannot be stretched back to
// an EARLIER stop (the steel is not there), only ridden further.
if (open.pool && leg.from < open.covered.from) return false;
const total = totalFor(open.slot.wagonTypeId, open.pool);
const row = usedPerEdge.get(rowKeyFor(open.slot.wagonTypeId, open.pool));
const from = Math.min(open.covered.from, leg.from);
const to = Math.max(open.covered.to, leg.to);
for (let e = from; e < to; e += 1) {
@@ -303,7 +436,7 @@ export function planWagonsWithStock(params: {
/** Grow the slot's span onto the leg's new edges, consuming stock there. */
const extendSpan = (open: OpenSlot, leg: BookingLeg): void => {
const row = usedRow(open.slot.wagonTypeId);
const row = usedRow(rowKeyFor(open.slot.wagonTypeId, open.pool));
const from = Math.min(open.covered.from, leg.from);
const to = Math.max(open.covered.to, leg.to);
for (let e = from; e < to; e += 1) {
@@ -336,8 +469,14 @@ export function planWagonsWithStock(params: {
}
const allowedIds = new Set(candidates.map((wt) => wt.id));
const teu = unit.teuSlots ?? teuSlotsForSizeFt(unit.sizeFt ?? 20);
// A BULK wagon whose cargo alights before this unit boards is empty
// steel again and may carry containers on the later leg (and vice
// versa — see the bulk reuse pass). While both ride together, the
// kinds never mix.
const disjointFrom = (open: OpenSlot): boolean =>
open.covered.to <= leg.from || leg.to <= open.covered.from;
const fitsSlot = (open: OpenSlot): boolean =>
open.kind === 'CONTAINER' &&
(open.kind === 'CONTAINER' || disjointFrom(open)) &&
allowedIds.has(open.slot.wagonTypeId) &&
teuFits(open, leg, teu) &&
canExtendSpan(open, leg);
@@ -389,53 +528,80 @@ export function planWagonsWithStock(params: {
const perItemTons = perItem ? remainingWeight / quantity : 0;
let remainingItems = perItem ? quantity : 0;
/** Whole items one wagon of this slot's type can still take. */
const itemRoomOf = (open: OpenSlot): number =>
Math.min(
open.freeItems ?? Number.MAX_SAFE_INTEGER,
perItemTons > 0 ? Math.floor(open.freeCapacityTons / perItemTons) : 0,
);
/** Fresh wagon's whole-item budget: items-fit map floor'd by tonnage. */
/** Fresh wagon's whole-item budget: items-fit map floor'd by (capped) tonnage. */
const itemBudgetOf = (open: OpenSlot): number => {
const fit = bulkItemsFitFor(booking.cargoType, open.slot.wagonTypeId);
const byTonnage =
perItemTons > 0
? Math.max(1, Math.floor(Number(open.slot.capacityTons) / perItemTons))
? Math.max(1, Math.floor(open.freeCapacityTons / perItemTons))
: 1;
return Math.min(fit ?? Number.MAX_SAFE_INTEGER, byTonnage);
};
let placedAnywhere = false;
// Per-item: prefer the type carrying the most whole items per wagon.
// openSlot's own capacity sort is stable, so this order breaks its ties.
// Per-item: least-shareable type first (same scarcity rule as openSlot),
// then the type carrying the most whole items per wagon.
const itemBudgetOfType = (wt: WagonType): number =>
Math.min(
bulkItemsFitFor(booking.cargoType, wt.id) ?? Number.MAX_SAFE_INTEGER,
perItemTons > 0
? Math.max(1, Math.floor(Number(wt.capacityTons) / perItemTons))
? Math.max(
1,
Math.floor(
bulkTonsPerWagon(booking.cargoType, wt.id, Number(wt.capacityTons)) /
perItemTons,
),
)
: 1,
);
const orderedCandidates = perItem
? [...candidates].sort((a, b) => itemBudgetOfType(b) - itemBudgetOfType(a))
? [...candidates].sort(
(a, b) => rankOf(a) - rankOf(b) || itemBudgetOfType(b) - itemBudgetOfType(a),
)
: candidates;
// Top off wagons already carrying THIS cargo type before opening new ones.
// ponytail: per-item cargo only shares wagons that were opened per-item
// (freeItems tracked); mixing itemized and loose loads of one cargo type
// on one wagon is not modeled — open a new wagon instead.
for (const open of openSlots) {
// One bulk booking per wagon PER LEG: a wagon carrying bulk takes that one
// booking's cargo for as long as it rides — never topped up from another
// booking on the same edges, even of the same cargo type.
//
// A wagon whose cargo ALIGHTS before this booking boards is free steel
// again, though: an import container uncoupled at Dire Dawa leaves its
// wagon empty for bulk loading there. Reuse those disjoint-leg slots
// before opening new stock — containers already do this, and without it a
// train with 3 wagons could not seat 3 wagons of leg-1 cargo plus 3 of
// leg-2 cargo.
const disjoint = (open: OpenSlot): boolean =>
open.covered.to <= leg.from || leg.to <= open.covered.from;
const reusable = openSlots.filter(
(open) =>
disjoint(open) &&
allowedIds.has(open.slot.wagonTypeId) &&
// A pooled wagon boards at its own yard; it cannot ride backwards.
!(open.pool && leg.from < open.covered.from),
);
for (const open of reusable) {
if (perItem ? remainingItems <= 0 : remainingWeight <= 0) break;
if (open.kind !== 'BULK') continue;
if (open.legKey !== legKey) continue;
if (open.cargoTypeId !== cargoTypeId) continue;
if (!allowedIds.has(open.slot.wagonTypeId)) continue;
if (open.freeCapacityTons <= 0) continue;
if (perItem !== (open.freeItems !== undefined)) continue;
const takeItems = perItem ? Math.min(itemRoomOf(open), remainingItems) : 0;
if (perItem && takeItems <= 0) continue;
const take = perItem
? roundTons(takeItems * perItemTons)
: roundTons(Math.min(open.freeCapacityTons, remainingWeight));
const wagonType = candidates.find((wt) => wt.id === open.slot.wagonTypeId);
if (!wagonType) continue;
const room = bulkTonsPerWagon(
booking.cargoType,
open.slot.wagonTypeId,
Number(open.slot.capacityTons),
);
if (!(room > 0)) continue;
let take: number;
if (perItem) {
const budget = Math.min(
bulkItemsFitFor(booking.cargoType, open.slot.wagonTypeId) ??
Number.MAX_SAFE_INTEGER,
perItemTons > 0 ? Math.max(1, Math.floor(room / perItemTons)) : 1,
);
const takeItems = Math.max(1, Math.min(budget, remainingItems));
take = roundTons(Math.min(takeItems * perItemTons, remainingWeight));
remainingItems -= takeItems;
} else {
take = roundTons(Math.min(room, remainingWeight));
}
addAllocation(
open.slot,
booking.id,
@@ -443,11 +609,9 @@ export function planWagonsWithStock(params: {
take,
AllocationLoadType.Bulk,
);
open.freeCapacityTons = roundTons(open.freeCapacityTons - take);
if (perItem) {
open.freeItems = (open.freeItems ?? 0) - takeItems;
remainingItems -= takeItems;
}
// The wagon now rides this leg too — it is the same physical steel, so
// no extra stock is consumed beyond extending its span.
extendSpan(open, leg);
remainingWeight = roundTons(remainingWeight - take);
placedAnywhere = true;
}
@@ -464,6 +628,7 @@ export function planWagonsWithStock(params: {
'BULK',
cargoTypeId,
leg,
booking.cargoType,
);
if ('message' in openedSlot) return openedSlot;
let take: number;

View File

@@ -159,3 +159,52 @@ describe('WagonStockLedger — multi-yard consist', () => {
expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(53);
});
});
describe('WagonStockLedger — cut wagons (S-2026-00050 shape)', () => {
// gmp -> lebu -> mojo -> adama -> dct. 3 NW5 + 2 PW2: two NW5 board at gmp
// (one cut at lebu), one NW5 boards at mojo; both PW2 board at gmp.
const stops = ['gmp', 'lebu', 'mojo', 'adama', 'dct'];
const makeLedger = () => {
const ledger = new WagonStockLedger(
new Map([
['nw5', 3],
['pw2', 2],
]),
stops.length - 1,
new Map([
['gmp', new Map([['nw5', 2], ['pw2', 2]])],
['mojo', new Map([['nw5', 1]])],
]),
stops,
);
ledger.debitCutWagons([{ wagonTypeId: 'nw5', poolYardId: 'gmp', cutYardId: 'lebu' }]);
return ledger;
};
const leg = (from: number, to: number) => ({ fromEdge: from, toEdge: to });
it('a leg past the cut sees only the wagons that reach it', () => {
const ledger = makeLedger();
// gmp -> dct: 2 NW5 stand at gmp but one is cut at lebu — only 1 rides through.
expect(ledger.availableFor(['nw5'], leg(0, 4))).toBe(1);
// gmp -> lebu: both gmp NW5 serve the short leg.
expect(ledger.availableFor(['nw5'], leg(0, 1))).toBe(2);
// PW2 uncut — both ride anywhere from gmp.
expect(ledger.availableFor(['pw2'], leg(0, 4))).toBe(2);
// mojo -> dct: the mojo pool's own NW5, untouched by the gmp cut.
expect(ledger.availableFor(['nw5'], leg(2, 4))).toBe(1);
});
it('cut debit and booking consumption stack', () => {
const ledger = makeLedger();
expect(ledger.consume(['nw5'], 1, leg(0, 4))).toBe(1);
expect(ledger.availableFor(['nw5'], leg(0, 4))).toBe(0);
// Short leg still has the cut wagon (1 = 2 total 1 consumed through-rider).
expect(ledger.availableFor(['nw5'], leg(0, 1))).toBe(1);
});
it('ignores a cut yard that is not on the stops', () => {
const ledger = makeLedger();
ledger.debitCutWagons([{ wagonTypeId: 'pw2', poolYardId: 'gmp', cutYardId: 'elsewhere' }]);
expect(ledger.availableFor(['pw2'], leg(0, 4))).toBe(2);
});
});

View File

@@ -75,6 +75,32 @@ export class WagonStockLedger {
return Math.max(0, total - busiest);
}
/**
* Pre-debit wagons the schedule CUTS mid-route: each cut wagon occupies its
* pool's stock on every edge at/after its cut stop, so a leg riding past the
* cut never counts it ("2 NW5 free from gmp" reads 1 when one cuts at Lebu).
* A cut yard not on this ledger's stops is skipped — conservative, matches
* the pre-cut behavior.
*/
debitCutWagons(
cuts: ReadonlyArray<{ wagonTypeId: string; poolYardId: string; cutYardId: string }>,
): void {
for (const cut of cuts) {
const fromEdge = this.stops.indexOf(cut.cutYardId);
if (fromEdge < 0) continue;
const pool = this.byYardId ? cut.poolYardId : '';
const key = pool ? `${pool}\u0000${cut.wagonTypeId}` : cut.wagonTypeId;
let row = this.usedPerEdge.get(key);
if (!row) {
row = new Array<number>(this.edgeCount).fill(0);
this.usedPerEdge.set(key, row);
}
for (let edge = fromEdge; edge < this.edgeCount; edge += 1) {
row[edge] = (row[edge] ?? 0) + 1;
}
}
}
/**
* Free wagons across every type a booking may ride. A cargo/container type
* mapped to several wagon types can use any of them, so they add up.

View File

@@ -0,0 +1,22 @@
import { ApiProperty } from '@nestjs/swagger';
import { ArrayMinSize, IsArray, IsUUID } from 'class-validator';
export class SetTrainWagonsYardDto {
@ApiProperty({
type: [String],
format: 'uuid',
description:
'Coupled wagons to relocate. All move in one transaction — if any is pinned to a live schedule, none move.',
})
@IsArray()
@ArrayMinSize(1)
@IsUUID('all', { each: true })
wagonIds!: string[];
@ApiProperty({
format: 'uuid',
description: 'Yard the selected wagons now sit in. The train itself stays put.',
})
@IsUUID()
currentYardId!: string;
}

View File

@@ -16,6 +16,7 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common';
import { FleetManage, FleetView } from '../../common/booking-guards';
import { PaginationQueryDto } from '../../common/dto/pagination-query.dto';
import type { AuthUserPayload } from '../../common/resolve-auth-user-id';
import { resolveAuthUserId } from '../../common/resolve-auth-user-id';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
@@ -27,6 +28,7 @@ import { SendWagonToMaintenanceDto } from './dto/send-wagon-to-maintenance.dto';
import { UpdateTrainDetailsDto } from './dto/update-train-details.dto';
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
import { UpdateTrainYardDto } from './dto/update-train-yard.dto';
import { SetTrainWagonsYardDto } from './dto/set-train-wagons-yard.dto';
import { TrainBuilderService } from './train-builder.service';
@ApiTags('train-builder')
@@ -77,6 +79,24 @@ export class TrainBuilderController {
return this.trainBuilderService.getComposition(id);
}
@Get(':id/history')
@ApiOperation({
summary:
"Wagon adjustment history of this built train: who attached/detached/switched which wagon, when and where — builder edits and trip events alike",
})
history(@Param('id', ParseUUIDPipe) id: string, @Query() query: PaginationQueryDto) {
return this.trainBuilderService.getTrainHistory(id, query);
}
@Get(':id/detached-wagons')
@ApiOperation({
summary:
'Wagons previously detached from this train that are still loose — with when/where/by whom they were last detached, ready to re-attach',
})
detachedWagons(@Param('id', ParseUUIDPipe) id: string, @Query() query: PaginationQueryDto) {
return this.trainBuilderService.getDetachedWagons(id, query);
}
@Put(':id/locomotives')
@FleetManage(FREIGHT_PERMS.trains.changeLocomotives)
@ApiOperation({ summary: 'Replace the locomotive set (minimum 1, same yard)' })
@@ -128,6 +148,25 @@ export class TrainBuilderController {
);
}
@Patch(':id/wagons/yard')
@FleetManage(FREIGHT_PERMS.trains.changeWagonYard)
@ApiOperation({
summary:
'Move several coupled wagons to another yard in one transaction — refused outright if any is allocated to a live schedule',
})
setWagonsYard(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SetTrainWagonsYardDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.trainBuilderService.setWagonsYard(
id,
dto.wagonIds,
dto.currentYardId,
resolveAuthUserId(user),
);
}
@Post(':id/wagons')
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
@ApiOperation({ summary: "Append AVAILABLE wagons from the train's yard to the consist" })

View File

@@ -7,6 +7,9 @@ import {
NotFoundException,
} from '@nestjs/common';
import { DataSource, EntityManager, ILike, In } from 'typeorm';
/** Schedule whose FULL flag must be re-derived once the consist edit has committed. */
type PendingWindowCheck = { scheduleId: string; wasFull: boolean };
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { Locomotive } from '../locomotives/entities/locomotive.entity';
@@ -228,6 +231,117 @@ export class TrainBuilderService {
return new Map(rows.map(({ trainId, ...schedule }) => [trainId, schedule]));
}
/**
* Wagon adjustment history of one built train, newest first: builder
* attaches/detaches (no schedule) and trip events (real cuts, couples,
* consist adjustments — carrying their schedule reference) alike.
*/
async getTrainHistory(trainId: string, query: { page?: number; pageSize?: number } = {}) {
const { page, pageSize, skip, take } = normalizePagination(query);
const [countRows, rows]: [
Array<{ total: string }>,
Array<{
id: string;
action: string;
subject: string;
yardLabel: string | null;
actor: string | null;
scheduleReference: string | null;
occurredAt: Date;
}>,
] = await Promise.all([
this.dataSource.query(
`SELECT count(*) AS total
FROM freight.schedule_wagon_adjustment_logs l
WHERE l.train_id = $1
AND l.deleted_at IS NULL`,
[trainId],
),
this.dataSource.query(
`SELECT l.id,
l.action,
l.wagon_number AS "subject",
COALESCE(y.label, y.code) AS "yardLabel",
COALESCE(u.username, u.email) AS "actor",
ts.reference AS "scheduleReference",
l.occurred_at AS "occurredAt"
FROM freight.schedule_wagon_adjustment_logs l
LEFT JOIN freight.yards y ON y.id = l.yard_id
LEFT JOIN iam.users u ON u.id = l.adjusted_by_user_id
LEFT JOIN freight.train_schedules ts ON ts.id = l.train_schedule_id
WHERE l.train_id = $1
AND l.deleted_at IS NULL
ORDER BY l.occurred_at DESC
LIMIT $2 OFFSET $3`,
[trainId, take, skip],
),
]);
const total = Number(countRows[0]?.total ?? 0);
return { items: rows, meta: buildPaginationMeta(total, page, pageSize) };
}
/**
* Wagons last detached from THIS train that are still loose (no train,
* AVAILABLE) — the re-attach shortlist, with when/where/by whom each was
* last detached. Derived from the adjustment log, no denormalized column.
*/
async getDetachedWagons(trainId: string, query: { page?: number; pageSize?: number } = {}) {
const { page, pageSize, skip, take } = normalizePagination(query);
const lastRemovalSql = `
SELECT DISTINCT ON (l.wagon_id)
l.wagon_id AS "wagonId",
l.occurred_at AS "detachedAt",
COALESCE(y.label, y.code) AS "detachedYardLabel",
COALESCE(u.username, u.email) AS "detachedBy"
FROM freight.schedule_wagon_adjustment_logs l
LEFT JOIN freight.yards y ON y.id = l.yard_id
LEFT JOIN iam.users u ON u.id = l.adjusted_by_user_id
WHERE l.train_id = $1
AND l.action = 'REMOVE'
AND l.deleted_at IS NULL
ORDER BY l.wagon_id, l.occurred_at DESC`;
const stillLoose = `w.deleted_at IS NULL AND w.train_id IS NULL AND w.status = 'AVAILABLE'`;
const [countRows, rows]: [
Array<{ total: string }>,
Array<{
wagonId: string;
wagonNumber: string;
wagonTypeCode: string | null;
currentYardLabel: string | null;
detachedAt: Date;
detachedYardLabel: string | null;
detachedBy: string | null;
}>,
] = await Promise.all([
this.dataSource.query(
`SELECT count(*) AS total
FROM (${lastRemovalSql}) last_removal
JOIN freight.wagons w ON w.id = last_removal."wagonId"
WHERE ${stillLoose}`,
[trainId],
),
this.dataSource.query(
`SELECT last_removal."wagonId",
w.wagon_number AS "wagonNumber",
wt.code AS "wagonTypeCode",
COALESCE(cy.label, cy.code) AS "currentYardLabel",
last_removal."detachedAt",
last_removal."detachedYardLabel",
last_removal."detachedBy"
FROM (${lastRemovalSql}) last_removal
JOIN freight.wagons w ON w.id = last_removal."wagonId"
LEFT JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id
LEFT JOIN freight.yards cy ON cy.id = w.current_yard_id
WHERE ${stillLoose}
ORDER BY last_removal."detachedAt" DESC
LIMIT $2 OFFSET $3`,
[trainId, take, skip],
),
]);
const total = Number(countRows[0]?.total ?? 0);
return { items: rows, meta: buildPaginationMeta(total, page, pageSize) };
}
/** Full consist: yard, ordered locomotives + wagons, totals vs. haul limits. */
async getComposition(id: string) {
const train = await this.dataSource.getRepository(Train).findOne({
@@ -550,15 +664,79 @@ export class TrainBuilderService {
return this.getComposition(id);
}
/**
* Move SEVERAL coupled wagons to another yard in one transaction (the train
* and the rest of the consist stay put). All-or-nothing: if any wagon is not
* coupled here, or is pinned to a live schedule, nothing moves — a partial
* relocation would leave the consist split across yards silently. Wagons
* already in the target yard are skipped, not an error.
*/
async setWagonsYard(
id: string,
wagonIds: string[],
currentYardId: string,
userId?: string | null,
) {
await this.dataSource.transaction(async (manager) => {
const train = await this.getEditableTrain(manager, id);
const yard = await manager.getRepository(Yard).findOne({ where: { id: currentYardId } });
if (!yard) throw new NotFoundException(`Yard ${currentYardId} not found`);
const unique = [...new Set(wagonIds)];
const wagons = await manager.getRepository(Wagon).find({ where: unique.map((wid) => ({ id: wid })) });
const byId = new Map(wagons.map((w) => [w.id, w]));
const missing = unique.filter((wid) => byId.get(wid)?.trainId !== train.id);
if (missing.length) {
throw new NotFoundException(
`${missing.length} of ${unique.length} wagons are not coupled to train ${train.code}`,
);
}
// Check every wagon before moving any — the whole point of the bulk call.
const pinned: string[] = [];
for (const wagon of wagons) {
if (await this.isWagonPinnedToLiveSchedule(manager, wagon.id)) {
pinned.push(wagon.wagonNumber);
}
}
if (pinned.length) {
throw new ConflictException(
`${pinned.join(', ')} ${pinned.length === 1 ? 'is' : 'are'} allocated to a scheduled or dispatched run; ${
pinned.length === 1 ? 'its' : 'their'
} yard cannot be changed`,
);
}
const moving = wagons.filter((w) => w.currentYardId !== yard.id);
if (!moving.length) return;
await manager
.getRepository(Wagon)
.update(moving.map((w) => w.id), { currentYardId: yard.id });
await manager.getRepository(WagonMovement).save(
moving.map((w) =>
manager.getRepository(WagonMovement).create({
wagonId: w.id,
fromYardId: w.currentYardId ?? null,
toYardId: yard.id,
kind: WagonMovementKind.Manual,
movedByUserId: userId ?? null,
occurredAt: new Date(),
}),
),
);
});
return this.getComposition(id);
}
/** Append AVAILABLE, unassigned wagons (any yard) to the consist. */
async assignWagons(id: string, dto: AssignTrainWagonsDto, userId?: string | null) {
await this.dataSource.transaction(async (manager) => {
const pending = await this.dataSource.transaction(async (manager) => {
const train = await this.getEditableTrain(manager, id);
const currentCount = await manager
.getRepository(Wagon)
.count({ where: { trainId: train.id } });
const attached = await this.attachWagons(manager, train, dto.wagonIds, currentCount);
await this.syncLiveScheduleAfterConsistChange(
return this.syncLiveScheduleAfterConsistChange(
manager,
train.id,
attached.map((w) => ({ action: 'ADD' as const, wagonId: w.id, wagonNumber: w.wagonNumber })),
@@ -566,12 +744,13 @@ export class TrainBuilderService {
train.currentYardId ?? null,
);
});
await this.reconcileWindowAfterConsistChange(pending);
return this.getComposition(id);
}
/** Detach one wagon and close the sequence gap it leaves. */
async removeWagon(id: string, wagonId: string, userId?: string | null) {
await this.dataSource.transaction(async (manager) => {
const pending = await this.dataSource.transaction(async (manager) => {
const train = await this.getEditableTrain(manager, id);
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
if (!wagon || wagon.trainId !== train.id) {
@@ -586,7 +765,7 @@ export class TrainBuilderService {
exportTrainNumber: null,
});
await this.resequenceWagons(manager, train.id);
await this.syncLiveScheduleAfterConsistChange(
return this.syncLiveScheduleAfterConsistChange(
manager,
train.id,
[{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }],
@@ -594,6 +773,7 @@ export class TrainBuilderService {
wagon.currentYardId ?? train.currentYardId ?? null,
);
});
await this.reconcileWindowAfterConsistChange(pending);
return this.getComposition(id);
}
@@ -608,7 +788,7 @@ export class TrainBuilderService {
userId?: string | null,
note?: string | null,
) {
await this.dataSource.transaction(async (manager) => {
const pending = await this.dataSource.transaction(async (manager) => {
const train = await this.getEditableTrain(manager, id);
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
if (!wagon || wagon.trainId !== train.id) {
@@ -652,6 +832,7 @@ export class TrainBuilderService {
toYardId: yardId,
kind: WagonMovementKind.Maintenance,
note: notes.movementNote,
movedByUserId: userId,
occurredAt: new Date(),
}),
);
@@ -663,7 +844,7 @@ export class TrainBuilderService {
);
}
await this.resequenceWagons(manager, train.id);
await this.syncLiveScheduleAfterConsistChange(
return this.syncLiveScheduleAfterConsistChange(
manager,
train.id,
[{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }],
@@ -671,6 +852,7 @@ export class TrainBuilderService {
yardId,
);
});
await this.reconcileWindowAfterConsistChange(pending);
return this.getComposition(id);
}
@@ -1000,8 +1182,8 @@ export class TrainBuilderService {
changes: Array<{ action: 'ADD' | 'REMOVE'; wagonId: string; wagonNumber: string }>,
userId: string | null,
yardId: string | null,
): Promise<void> {
if (!changes.length) return;
): Promise<PendingWindowCheck | null> {
if (!changes.length) return null;
const trainSet = await manager
.getRepository(TrainSet)
.findOne({ where: { trainId }, order: { createdAt: 'DESC' } });
@@ -1027,15 +1209,14 @@ export class TrainBuilderService {
.getRepository(TrainSet)
.update(trainSet.id, { wagonCount, totalWeightTons, totalLengthMeters });
}
if (!schedule) return;
await manager.getRepository(TrainSchedule).update(schedule.id, { maxWagons: wagonCount });
// Log the consist change even when the train has no live schedule — the
// builder's own detach/attach is the train's history too (who removed
// which wagon, when, where), and the detached-wagons tab reads it back.
const now = new Date();
await manager.getRepository(ScheduleWagonAdjustmentLog).save(
changes.map((c) =>
manager.getRepository(ScheduleWagonAdjustmentLog).create({
trainScheduleId: schedule.id,
trainScheduleId: schedule?.id ?? null,
trainId,
action: c.action,
wagonId: c.wagonId,
@@ -1047,16 +1228,35 @@ export class TrainBuilderService {
),
);
// Same full/reopen rule as adjustScheduleConsist: freeing a slot on a FULL
// schedule reopens its booking window; filling the last one closes it.
const wasFull = schedule.bookingWindowStatus === 'FULL';
const usage = await this.bookingBatchService.scheduleWagonUsage(schedule.id);
if (!schedule) return null;
await manager.getRepository(TrainSchedule).update(schedule.id, { maxWagons: wagonCount });
// The FULL/reopen decision must run AFTER the transaction commits — see
// reconcileWindowAfterConsistChange.
return { scheduleId: schedule.id, wasFull: schedule.bookingWindowStatus === 'FULL' };
}
/**
* Same full/reopen rule as adjustScheduleConsist: freeing a slot on a FULL
* schedule reopens its booking window; filling the last one closes it.
*
* Runs only once the consist transaction has COMMITTED. BookingBatchService
* reads through its own connection, so inside the transaction it still saw
* the old consist: a wagon coupled onto an empty (FULL) train counted as 0
* slots, `nowFull` stayed true and the window was never reopened.
*/
private async reconcileWindowAfterConsistChange(
pending: PendingWindowCheck | null,
): Promise<void> {
if (!pending) return;
const usage = await this.bookingBatchService.scheduleWagonUsage(pending.scheduleId);
if (!usage) return;
const nowFull = usage.remainingSlots <= 0;
if (wasFull && !nowFull) {
await this.bookingBatchService.refreshWindowStatus(schedule.id);
} else if (!wasFull && nowFull) {
await this.bookingBatchService.setWindow(schedule.id, 'FULL');
if (pending.wasFull && !nowFull) {
await this.bookingBatchService.refreshWindowStatus(pending.scheduleId);
} else if (!pending.wasFull && nowFull) {
await this.bookingBatchService.setWindow(pending.scheduleId, 'FULL');
}
}

View File

@@ -218,6 +218,11 @@ export class WagonsService {
if (dto.currentYardId !== undefined) {
wagon.currentYard = null;
}
// Same trap for `wagonType`: the stale eager-loaded relation would win over
// the new `wagonTypeId` and the type change would silently not persist.
if (dto.wagonTypeId !== undefined) {
wagon.wagonType = undefined;
}
await this.wagonRepo.save(wagon);
// Staff manually relocated the wagon — write the movement ledger row so the
// wagon's yard history stays auditable (who moved it, from where, when).

View File

@@ -5,26 +5,34 @@ import { Warehouse } from './entities/warehouse.entity';
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
import { SchedulingReadFacade } from './scheduling-read.facade';
export interface WarehouseDashboardFilter {
/** Inclusive day, `YYYY-MM-DD`. Both omitted → defaults to "today" (the original behaviour). */
dateFrom?: string;
dateTo?: string;
/** Scopes every warehouse_inventory-derived counter. Ignored by the always-global ones (see below). */
warehouseId?: string;
}
export interface WarehouseDashboard {
// ── Always current — dateFrom/dateTo have no effect on these ──────────────
totalWarehouses: number;
totalInventory: number;
receivedToday: number;
// Inspection gate
awaitingInspection: number;
inspected: number;
// Export branch
stored: number;
reserved: number;
readyForLoading: number;
loaded: number;
dispatched: number;
// Import branch
readyForPickup: number;
delivered: number;
// Fleet / train snapshot
/** Never warehouse-scoped — the container fleet isn't tied to a specific warehouse. */
emptyContainers: number;
/** Never warehouse-scoped — trains aren't tied to a specific warehouse. */
importTrains: number;
exportTrains: number;
// ── The one activity counter — respects dateFrom/dateTo (default: today) ──
received: number;
}
@Injectable()
@@ -72,24 +80,40 @@ export class WarehouseDashboardService {
}
}
private async safeReceivedToday(startOfToday: Date): Promise<number> {
/** [inclusive start, exclusive end) for the "received" counter. Defaults to today. */
private resolveRange(filter: WarehouseDashboardFilter): { start: Date; end: Date } {
if (!filter.dateFrom && !filter.dateTo) {
const start = new Date();
start.setHours(0, 0, 0, 0);
return { start, end: new Date() };
}
const start = filter.dateFrom ? new Date(`${filter.dateFrom}T00:00:00`) : new Date(0);
// Exclusive end = start of the day AFTER dateTo, so the whole end day is included.
const end = filter.dateTo
? new Date(new Date(`${filter.dateTo}T00:00:00`).getTime() + 24 * 60 * 60 * 1000)
: new Date();
return { start, end };
}
private async safeReceived(range: { start: Date; end: Date }, warehouseId?: string): Promise<number> {
try {
return await this.dataSource
const qb = this.dataSource
.getRepository(WarehouseInventory)
.createQueryBuilder('inv')
.where('inv.arrived_at >= :start', { start: startOfToday })
.getCount();
.where('inv.arrived_at >= :start AND inv.arrived_at < :end', range);
if (warehouseId) qb.andWhere('inv.warehouse_id = :warehouseId', { warehouseId });
return await qb.getCount();
} catch {
return 0;
}
}
async getDashboard(): Promise<WarehouseDashboard> {
async getDashboard(filter: WarehouseDashboardFilter = {}): Promise<WarehouseDashboard> {
const warehouseRepo = this.dataSource.getRepository(Warehouse);
const inventoryRepo = this.dataSource.getRepository(WarehouseInventory);
const startOfToday = new Date();
startOfToday.setHours(0, 0, 0, 0);
const warehouseId = filter.warehouseId || undefined;
const scope = warehouseId ? { warehouseId } : {};
const range = this.resolveRange(filter);
const [
totalWarehouses,
@@ -103,23 +127,23 @@ export class WarehouseDashboardService {
dispatched,
readyForPickup,
delivered,
receivedToday,
received,
emptyContainers,
importTrains,
exportTrains,
] = await Promise.all([
this.safeCount(warehouseRepo),
this.safeCount(inventoryRepo),
this.safeCount(inventoryRepo, { where: { status: 'RECEIVED', inspectionStatus: IsNull() } }),
this.safeCount(inventoryRepo, { where: { inspectionStatus: 'PASSED' } }),
this.safeCount(inventoryRepo, { where: { status: 'STORED' } }),
this.safeCount(inventoryRepo, { where: { status: 'RESERVED' } }),
this.safeCount(inventoryRepo, { where: { status: 'READY_FOR_LOADING' } }),
this.safeCount(inventoryRepo, { where: { status: 'LOADED' } }),
this.safeCount(inventoryRepo, { where: { status: 'DISPATCHED' } }),
this.safeCount(inventoryRepo, { where: { status: 'READY_FOR_PICKUP' } }),
this.safeCount(inventoryRepo, { where: { status: 'DELIVERED' } }),
this.safeReceivedToday(startOfToday),
this.safeCount(inventoryRepo, { where: { ...scope } }),
this.safeCount(inventoryRepo, { where: { ...scope, status: 'RECEIVED', inspectionStatus: IsNull() } }),
this.safeCount(inventoryRepo, { where: { ...scope, inspectionStatus: 'PASSED' } }),
this.safeCount(inventoryRepo, { where: { ...scope, status: 'STORED' } }),
this.safeCount(inventoryRepo, { where: { ...scope, status: 'RESERVED' } }),
this.safeCount(inventoryRepo, { where: { ...scope, status: 'READY_FOR_LOADING' } }),
this.safeCount(inventoryRepo, { where: { ...scope, status: 'LOADED' } }),
this.safeCount(inventoryRepo, { where: { ...scope, status: 'DISPATCHED' } }),
this.safeCount(inventoryRepo, { where: { ...scope, status: 'READY_FOR_PICKUP' } }),
this.safeCount(inventoryRepo, { where: { ...scope, status: 'DELIVERED' } }),
this.safeReceived(range, warehouseId),
// ponytail: the container fleet has no literal EMPTY status (AVAILABLE | LOADED |
// IN_TRANSIT | MAINTENANCE | DAMAGED) — AVAILABLE (not on a wagon, not in transit,
// not flagged) is the closest proxy for "empty and free to use". Revisit if the
@@ -135,7 +159,7 @@ export class WarehouseDashboardService {
return {
totalWarehouses,
totalInventory,
receivedToday,
received,
awaitingInspection,
inspected,
stored,

View File

@@ -43,9 +43,20 @@ export class WarehousesController {
@Get('dashboard')
@BookingStaff(FREIGHT_PERMS.warehouseDashboard.view)
@ApiOperation({ summary: 'Warehouse dashboard metrics' })
dashboard() {
return this.dashboardService.getDashboard();
@ApiOperation({
summary: 'Warehouse dashboard metrics',
description:
'dateFrom/dateTo scope only the activity counters (currently just "received"); ' +
'status-backlog and fleet counters are always current. Omit both for "received today" ' +
'(the original default). warehouseId scopes every warehouse_inventory-derived counter; ' +
'totalWarehouses/emptyContainers/importTrains/exportTrains are never warehouse-scoped.',
})
dashboard(
@Query('dateFrom') dateFrom?: string,
@Query('dateTo') dateTo?: string,
@Query('warehouseId') warehouseId?: string,
) {
return this.dashboardService.getDashboard({ dateFrom, dateTo, warehouseId });
}
@Post()