mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' into freight/nati-2
This commit is contained in:
@@ -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"],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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.`;
|
||||
|
||||
@@ -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()];
|
||||
}
|
||||
}
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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_")
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 (contract–booking 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (contract–booking 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()}.`,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: "" } });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 }]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" })
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user