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

This commit is contained in:
Nathnael
2026-07-10 12:22:41 +00:00
41 changed files with 3067 additions and 677 deletions

View File

@@ -143,6 +143,20 @@ export function htmlToText(html: string): string {
.trim();
}
/**
* Large rotated light-gray copy label (e.g. "Copy 1: Port Operations Copy"),
* drawn FIRST so the page content sits on top of it. 30-degree rotation via a
* text matrix; roughly centered on the page.
*/
export function watermarkOp(text: string, page: { width: number; height: number }): string {
const label = clipText(text, 46);
const size = 34;
const w = textWidth(label, size);
const x = page.width / 2 - (w * 0.866) / 2;
const y = page.height / 2 - (w * 0.5) / 2;
return `q BT 0.93 0.93 0.93 rg /F2 ${size} Tf 0.866 0.5 -0.5 0.866 ${x.toFixed(1)} ${y.toFixed(1)} Tm (${escapePdfText(label)}) Tj ET Q`;
}
/**
* Parse a "summary tiles + one <table> + notice + signature lines" document (the
* marshalling / load-list layout the train-scheduling builders emit) and draw it as a
@@ -150,11 +164,26 @@ export function htmlToText(html: string): string {
* document, not a flat text dump. Switches to landscape when the table is wide.
*/
export function buildTabularFallbackPdf(html: string): Buffer {
// Documents printed in duplicate wrap each copy in <section class="copy">
// (freight order: Port Operations copy + Gate Security copy). Render one
// page per copy, each with its own watermark and tile set — parsing the
// whole HTML at once would merge both copies' tiles and drop the watermarks.
const copies = [...html.matchAll(/<section class="copy">([\s\S]*?)<\/section>/gi)].map((m) => m[1]);
const fragments = copies.length ? copies : [html];
return assemblePdf(fragments.flatMap((fragment) => buildTabularPageOps(fragment)));
}
function buildTabularPageOps(
html: string,
): Array<{ ops: string[]; page: { width: number; height: number } }> {
const pick = (re: RegExp) => html.match(re)?.[1];
const title = htmlToText(pick(/<h1[^>]*>([\s\S]*?)<\/h1>/i) ?? "Document");
const subtitle = htmlToText(pick(/class="subtitle"[^>]*>([\s\S]*?)<\/div>/i) ?? "");
const metaRef = htmlToText(pick(/class="meta"[\s\S]*?<strong>([\s\S]*?)<\/strong>/i) ?? "");
const metaLabel =
htmlToText(pick(/class="meta"[^>]*>([\s\S]*?)<strong/i) ?? "").toUpperCase() || "REFERENCE";
const generated = htmlToText(pick(/Generated:\s*([^<]+)/i) ?? "");
const watermark = htmlToText(pick(/class="watermark"[^>]*>([\s\S]*?)<\/div>/i) ?? "");
const tiles: Array<[string, string]> = [];
for (const m of html.matchAll(
@@ -180,24 +209,49 @@ export function buildTabularFallbackPdf(html: string): Buffer {
const M = 32;
const contentW = page.width - M * 2;
const right = page.width - M;
const ops: string[] = [];
const MAX_PAGES = 12;
// Header
ops.push(lineOp(M, page.height - 28, right, page.height - 28, PdfColor.teal, 2.4));
ops.push(textOp("ETHIO-DJIBOUTI RAILWAY S.C.", M, page.height - 44, 8.5, "F2", PdfColor.gray));
ops.push(textOp(clipText(title, landscape ? 82 : 52), M, page.height - 68, 19, "F2", PdfColor.dark));
if (subtitle) ops.push(textOp(clipText(subtitle, 96), M, page.height - 82, 9, "F1", PdfColor.gray));
if (metaRef) {
ops.push(textOpRight("TRAIN / SCHEDULE", right, page.height - 42, 7.5, "F2", PdfColor.gray));
ops.push(textOpRight(clipText(metaRef, 28), right, page.height - 58, 12, "F2", PdfColor.dark));
}
if (generated) {
ops.push(textOpRight(clipText(`Generated ${generated}`, 40), right, page.height - 72, 8, "F1", PdfColor.gray));
}
ops.push(lineOp(M, page.height - 92, right, page.height - 92, PdfColor.line, 1));
const pagesOut: Array<{ ops: string[]; page: { width: number; height: number } }> = [];
let ops: string[] = [];
let y = 0;
// Summary tiles
let y = page.height - 100;
const drawFullHeader = () => {
ops.push(lineOp(M, page.height - 28, right, page.height - 28, PdfColor.teal, 2.4));
ops.push(textOp("ETHIO-DJIBOUTI RAILWAY S.C.", M, page.height - 44, 8.5, "F2", PdfColor.gray));
ops.push(textOp(clipText(title, landscape ? 82 : 52), M, page.height - 68, 19, "F2", PdfColor.dark));
if (subtitle) ops.push(textOp(clipText(subtitle, 96), M, page.height - 82, 9, "F1", PdfColor.gray));
if (metaRef) {
ops.push(textOpRight(clipText(metaLabel, 26), right, page.height - 42, 7.5, "F2", PdfColor.gray));
ops.push(textOpRight(clipText(metaRef, 28), right, page.height - 58, 12, "F2", PdfColor.dark));
}
if (generated) {
ops.push(textOpRight(clipText(`Generated ${generated}`, 40), right, page.height - 72, 8, "F1", PdfColor.gray));
}
ops.push(lineOp(M, page.height - 92, right, page.height - 92, PdfColor.line, 1));
y = page.height - 100;
};
const drawContinuationHeader = (pageNo: number) => {
ops.push(lineOp(M, page.height - 24, right, page.height - 24, PdfColor.teal, 1.6));
ops.push(
textOp(clipText(`${title} (continued — page ${pageNo})`, landscape ? 100 : 68), M, page.height - 42, 11, "F2", PdfColor.dark),
);
if (metaRef) ops.push(textOpRight(clipText(metaRef, 28), right, page.height - 42, 10, "F2", PdfColor.gray));
y = page.height - 54;
};
const startPage = (first: boolean) => {
ops = [];
if (watermark) ops.push(watermarkOp(watermark, page));
if (first) drawFullHeader();
else drawContinuationHeader(pagesOut.length + 1);
};
const finishPage = () => pagesOut.push({ ops, page });
startPage(true);
// Summary tiles (first page only)
if (tiles.length) {
const cols = landscape ? 6 : 4;
const tileW = contentW / cols;
@@ -213,21 +267,34 @@ export function buildTabularFallbackPdf(html: string): Buffer {
y -= tileH + 12;
}
// Table
// Table, paginated across as many pages as the rows need.
if (headers.length) {
const colW = contentW / headers.length;
const headerH = 16;
const rowH = 14;
const cellChars = Math.max(4, Math.floor(colW / 3.9));
ops.push(rectOp(M, y - headerH, contentW, headerH, PdfColor.tint, PdfColor.line, 0.6));
headers.forEach((h, c) =>
ops.push(textOp(clipText(h, cellChars), M + c * colW + 4, y - 11, 7, "F2", PdfColor.teal)),
);
y -= headerH;
const bottomReserve = 46; // keep clear of the page edge on row-only pages
let shown = 0;
for (const row of rows) {
if (y < 96) break;
const drawTableHeader = () => {
ops.push(rectOp(M, y - headerH, contentW, headerH, PdfColor.tint, PdfColor.line, 0.6));
headers.forEach((h, c) =>
ops.push(textOp(clipText(h, cellChars), M + c * colW + 4, y - 11, 7, "F2", PdfColor.teal)),
);
y -= headerH;
};
drawTableHeader();
let truncated = 0;
for (const [index, row] of rows.entries()) {
if (y - rowH < bottomReserve) {
if (pagesOut.length + 1 >= MAX_PAGES) {
truncated = rows.length - index;
break;
}
finishPage();
startPage(false);
drawTableHeader();
}
ops.push(rectOp(M, y - rowH, contentW, rowH, "1 1 1", PdfColor.line, 0.4));
headers.forEach((_h, c) => {
if (c > 0) ops.push(lineOp(M + c * colW, y - rowH, M + c * colW, y, PdfColor.line, 0.3));
@@ -235,30 +302,33 @@ export function buildTabularFallbackPdf(html: string): Buffer {
if (cell) ops.push(textOp(clipText(cell, cellChars), M + c * colW + 4, y - 10, 6.8, "F1", PdfColor.dark));
});
y -= rowH;
shown += 1;
}
if (shown < rows.length) {
ops.push(textOp(`... ${rows.length - shown} more row(s) not shown`, M, y - 10, 7, "F1", PdfColor.gray));
if (truncated > 0) {
ops.push(textOp(`... ${truncated} more row(s) not shown`, M, y - 10, 7, "F1", PdfColor.gray));
}
}
// Notice (verification clause)
// Notice + signatures live on the final page; give them a fresh page when the
// rows ran too deep for the fixed bottom band.
if (y < 110 && (notice || signatures.length)) {
finishPage();
startPage(false);
}
if (notice) {
ops.push(lineOp(M, 78, M, 54, PdfColor.teal, 2));
wrapText(notice, landscape ? 155 : 104)
.slice(0, 2)
.forEach((ln, i) => ops.push(textOp(ln, M + 8, 72 - i * 11, 7.5, "F1", PdfColor.gray)));
}
// Signatures
const sigW = contentW / signatures.length;
signatures.forEach((s, i) => {
signatures.forEach((sig, i) => {
const x = M + i * sigW;
ops.push(lineOp(x, 40, x + sigW - 18, 40, PdfColor.dark, 0.7));
ops.push(textOp(clipText(s, Math.floor((sigW - 18) / 3.6)), x, 30, 7, "F1", PdfColor.gray));
ops.push(textOp(clipText(sig, Math.floor((sigW - 18) / 3.6)), x, 30, 7, "F1", PdfColor.gray));
});
finishPage();
return assembleSinglePagePdf(ops, page);
return pagesOut;
}
/** Greedy word-wrap to a maximum character width. */
@@ -320,3 +390,41 @@ export function assembleSinglePagePdf(
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
return Buffer.from(pdf, "latin1");
}
/** Assemble a multi-page PDF; one content stream per page, shared Helvetica fonts. */
export function assemblePdf(
pages: Array<{ ops: string[]; page: { width: number; height: number } }>,
): Buffer {
const kids = pages.map((_, i) => `${5 + i * 2} 0 R`).join(" ");
const objects: string[] = [
"<< /Type /Catalog /Pages 2 0 R >>",
`<< /Type /Pages /Kids [${kids}] /Count ${pages.length} >>`,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>",
];
for (const [i, p] of pages.entries()) {
const stream = p.ops.join("\n");
objects.push(
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${p.page.width} ${p.page.height}] /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents ${6 + i * 2} 0 R >>`,
);
objects.push(`<< /Length ${Buffer.byteLength(stream, "latin1")} >>\nstream\n${stream}\nendstream`);
}
let pdf = "%PDF-1.4\n";
const offsets: number[] = [0];
objects.forEach((object, index) => {
offsets.push(Buffer.byteLength(pdf, "latin1"));
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
});
while (Buffer.byteLength(pdf, "latin1") < MIN_VALID_PDF_BYTES) {
pdf += "% fallback padding\n";
}
const xrefOffset = Buffer.byteLength(pdf, "latin1");
pdf += `xref\n0 ${objects.length + 1}\n`;
pdf += "0000000000 65535 f \n";
for (const offset of offsets.slice(1)) {
pdf += `${String(offset).padStart(10, "0")} 00000 n \n`;
}
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
return Buffer.from(pdf, "latin1");
}

View File

@@ -22,12 +22,15 @@ export class BookingRequestRepository extends BaseRepository<BookingRequest> {
});
}
/** GL queue: pending requests across all contracts, oldest first. */
async findPending(): Promise<BookingRequest[]> {
/**
* GL queue: every request across all contracts, newest first. The queue page
* filters by status client-side (pending work vs accepted/rejected history),
* and surfaces the customer — so the contract's company rides along.
*/
async findQueue(): Promise<BookingRequest[]> {
return this.repository.find({
where: { status: 'PENDING' },
order: { createdAt: 'ASC' },
relations: { contract: true },
order: { createdAt: 'DESC' },
relations: { contract: { company: true } },
});
}

View File

@@ -139,7 +139,7 @@ export class BookingRequestService {
}
queue(): Promise<BookingRequest[]> {
return this.repo.findPending();
return this.repo.findQueue();
}
private async findPending(requestId: string): Promise<BookingRequest> {

View File

@@ -218,7 +218,7 @@ export class ContractBookingService {
contractType: 'NEW',
customsClearingEnabled: contract.customsClearingEnabled,
customsClearingAgent: contract.customsClearingAgent ?? null,
equipmentReturn: contract.equipmentReturn ?? 'WITHOUT_RETURN',
equipmentReturn: dto.equipmentReturn ?? contract.equipmentReturn ?? 'WITHOUT_RETURN',
originYardId: route?.originYardId ?? null,
destinationYardId: route?.destinationYardId ?? null,
tradeDirection: contract.tradeDirection,

View File

@@ -107,7 +107,7 @@ export class ContractsController {
@Get('booking-requests/queue')
@BookingStaff(FREIGHT_PERMS.contracts.createBooking)
@ApiOperation({ summary: 'GL queue: pending shipment requests across contracts' })
@ApiOperation({ summary: 'GL queue: shipment requests across contracts (all statuses, newest first)' })
bookingRequestQueue() {
return this.bookingRequestService.queue();
}

View File

@@ -4,6 +4,7 @@ import {
IsArray,
IsBoolean,
IsDateString,
IsIn,
IsInt,
IsNumber,
IsOptional,
@@ -14,6 +15,9 @@ import {
ValidateNested,
} from 'class-validator';
/** Per-shipment equipment return — "NA" stays contract-level only. */
const SHIPMENT_EQUIPMENT_RETURNS = ['WITH_RETURN', 'WITHOUT_RETURN'] as const;
/** One physical container under a booking line — entered at booking time. */
export class CreateContainerUnitDto {
@ApiProperty({ description: 'ISO 6346 container number, e.g. ABCD1234567' })
@@ -134,6 +138,15 @@ export class CreateBookingUnderContractDto {
@IsDateString()
scheduledDate?: string;
@ApiPropertyOptional({
enum: SHIPMENT_EQUIPMENT_RETURNS,
description:
'Per-shipment equipment return override; omitted → the contract default applies.',
})
@IsOptional()
@IsIn([...SHIPMENT_EQUIPMENT_RETURNS])
equipmentReturn?: string;
@ApiPropertyOptional({ type: [CreateBookingContainerLineDto] })
@IsOptional()
@IsArray()

View File

@@ -43,11 +43,26 @@ export class Route extends BaseEntity {
* Human-readable route label: yard names, not yard codes — "Addis Ababa → Dire Dawa",
* not "ADDIS_ABABA → DIRE_DAWA". A yard's display name is its `label`; `code` is the
* machine identifier and is only a fallback for a yard missing one.
*
* When the route's milestones are loaded (with their yards), the label is the FULL
* ordered corridor — "Addis Ababa → Adama → Dire Dawa" — since milestones already
* include the origin (first) and destination (last). Without milestones it falls
* back to origin → destination.
*/
export function formatRouteLabel(route: {
originYard?: { code?: string; label?: string } | null;
destinationYard?: { code?: string; label?: string } | null;
milestones?: Array<{
sequenceNo: number;
yard?: { code?: string; label?: string } | null;
}> | null;
}): string {
const stops = [...(route.milestones ?? [])]
.sort((a, b) => a.sequenceNo - b.sequenceNo)
.map((m) => m.yard?.label ?? m.yard?.code)
.filter((name): name is string => Boolean(name));
if (stops.length >= 2) return stops.join(' → ');
const origin = route.originYard?.label ?? route.originYard?.code ?? 'Origin';
const dest = route.destinationYard?.label ?? route.destinationYard?.code ?? 'Destination';
return `${origin}${dest}`;

View File

@@ -23,8 +23,9 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
where: { id },
relations: {
// Yards carry the route's display name; without them formatRouteLabel
// degrades to the literal "Origin → Destination".
route: { originYard: true, destinationYard: true },
// degrades to the literal "Origin → Destination". Milestones (with
// their yards) give it the full corridor path.
route: { originYard: true, destinationYard: true, milestones: { yard: true } },
trainSet: {
locomotive: true,
locomotives: { locomotive: true },

View File

@@ -31,7 +31,6 @@ import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
import { BookingNotifierService } from './booking-notifier.service';
import { TrainSchedulingService } from './train-scheduling.service';
import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util';
@@ -568,7 +567,6 @@ export class BookingBatchService implements OnModuleInit {
);
}
const rules = await this.loadGlobalRules();
const wagonDims = await this.loadWagonDims();
const required = need ?? this.needFor(booking, wagonDims);
let corridorMatched = false;
@@ -578,7 +576,7 @@ export class BookingBatchService implements OnModuleInit {
);
const locomotive = schedule?.trainSet?.locomotive;
if (!schedule || !locomotive) continue;
const limits = await this.capacityLimits(locomotive, rules);
const limits = await this.capacityLimits(locomotive);
const budget = await this.remainingBudget(schedule, limits, wagonDims);
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
if (!leg) continue; // this train's route doesn't carry the booking's leg
@@ -748,8 +746,9 @@ export class BookingBatchService implements OnModuleInit {
trainSet: { locomotive: true },
originStation: true,
destinationStation: true,
// Yards supply the route's display name for `routeName` below.
route: { originYard: true, destinationYard: true },
// Yards supply the route's display name for `routeName` below;
// milestones (with yards) give it the full corridor path.
route: { originYard: true, destinationYard: true, milestones: { yard: true } },
},
order: { [sortBy]: sortOrder } as never,
skip: (page - 1) * pageSize,
@@ -757,7 +756,6 @@ export class BookingBatchService implements OnModuleInit {
});
const wagonDims = await this.loadWagonDims();
const rules = await this.loadGlobalRules();
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
const board: BatchBoardSchedule[] = [];
@@ -787,7 +785,7 @@ export class BookingBatchService implements OnModuleInit {
};
});
board.push(this.buildScheduleSummary(s, items, rules));
board.push(this.buildScheduleSummary(s, items));
}
return {
@@ -817,7 +815,6 @@ export class BookingBatchService implements OnModuleInit {
}
const wagonDims = await this.loadWagonDims();
const rules = await this.loadGlobalRules();
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
const links = await linkRepo.find({ where: { trainScheduleId: s.id } });
const linkedIds = new Set(links.map((l) => l.bookingId));
@@ -1011,7 +1008,7 @@ export class BookingBatchService implements OnModuleInit {
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
}
: null,
capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null, rules),
capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null),
counts: {
allocated: items.filter((i) => i.state === "ALLOCATED").length,
selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH")
@@ -1045,9 +1042,10 @@ export class BookingBatchService implements OnModuleInit {
/**
* Board capacity figures. `usedWeightTons` is GROSS (each item's weight already
* includes the tare of the wagons it occupies), so the ceiling it is measured
* against must be the same one the fill loop spends from: the locomotive floored
* by the global rule caps and widened by its overage tolerance. Reading the raw
* `loco.maxPullWeightTons` here showed staff a ceiling the batch engine did not use.
* against must be the same one the fill loop spends from: the locomotive's own
* limits widened by its overage tolerance (global rule caps do not apply, same
* as {@link capacityLimits}). Reading the raw `loco.maxPullWeightTons` here
* showed staff a ceiling the batch engine did not use.
*/
private computeBoardCapacity(
items: Array<{
@@ -1058,29 +1056,18 @@ export class BookingBatchService implements OnModuleInit {
}>,
loco: Locomotive | null,
maxWagons: number | null,
rules: TrainSchedulingGlobalRules | null,
): BatchBoardSchedule["capacity"] {
const allocated = items.filter((i) => i.state === "ALLOCATED");
const committed = items.filter(
(i) => i.state === "ALLOCATED" || i.state === "SELECTED_FOR_BATCH",
);
const caps = loco
? trainHardCaps(
{
maxPullWeightTons: Number(loco.maxPullWeightTons),
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
overageToleranceTons: Number(loco.overageToleranceTons) || 0,
overageToleranceMeters: Number(loco.overageToleranceMeters) || 0,
},
{
maxTrainWeightTons: rules?.maxTrainWeightTons
? Number(rules.maxTrainWeightTons)
: undefined,
maxTrainLengthMeters: rules?.maxTrainLengthMeters
? Number(rules.maxTrainLengthMeters)
: undefined,
},
)
? trainHardCaps({
maxPullWeightTons: Number(loco.maxPullWeightTons),
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
overageToleranceTons: Number(loco.overageToleranceTons) || 0,
overageToleranceMeters: Number(loco.overageToleranceMeters) || 0,
})
: null;
const round2 = (value: number) => Math.round(value * 100) / 100;
@@ -1099,7 +1086,6 @@ export class BookingBatchService implements OnModuleInit {
private buildScheduleSummary(
s: TrainSchedule,
items: BatchBoardBooking[],
rules: TrainSchedulingGlobalRules | null,
): BatchBoardSchedule {
const loco = s.trainSet?.locomotive ?? null;
@@ -1134,7 +1120,7 @@ export class BookingBatchService implements OnModuleInit {
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
}
: null,
capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null, rules),
capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null),
counts: {
allocated: items.filter((i) => i.state === "ALLOCATED").length,
selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH")
@@ -1203,10 +1189,9 @@ export class BookingBatchService implements OnModuleInit {
return 0;
}
const rules = await this.loadGlobalRules();
const wagonDims = await this.loadWagonDims();
const limits = await this.capacityLimits(locomotive, rules);
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
const limits = await this.capacityLimits(locomotive);
await this.syncScheduleMaxWagons(schedule, locomotive);
const budget = await this.remainingBudget(schedule, limits, wagonDims);
const minPerWagon = this.minPerWagonNeed(wagonDims);
if (budget.isExhausted(minPerWagon)) {
@@ -1405,7 +1390,6 @@ export class BookingBatchService implements OnModuleInit {
return { scheduleIds: [], commercialReserved: 0 };
}
const rules = await this.loadGlobalRules();
const wagonDims = await this.loadWagonDims();
// Live per-schedule corridor budget + arm flag, in departure order.
@@ -1420,8 +1404,8 @@ export class BookingBatchService implements OnModuleInit {
);
continue;
}
const limits = await this.capacityLimits(locomotive, rules);
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
const limits = await this.capacityLimits(locomotive);
await this.syncScheduleMaxWagons(schedule, locomotive);
const budget = await this.remainingBudget(schedule, limits, wagonDims);
trains.push({ id, budget, armed: false });
}
@@ -1973,9 +1957,8 @@ export class BookingBatchService implements OnModuleInit {
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
const locomotive = schedule?.trainSet?.locomotive;
if (!schedule || !locomotive) return null;
const rules = await this.loadGlobalRules();
const wagonDims = await this.loadWagonDims();
const limits = await this.capacityLimits(locomotive, rules);
const limits = await this.capacityLimits(locomotive);
const budget = await this.remainingBudget(schedule, limits, wagonDims);
return { budget, needFor: (booking) => this.needFor(booking, wagonDims) };
}
@@ -2520,11 +2503,12 @@ export class BookingBatchService implements OnModuleInit {
* `base` via {@link needFor}, whose weight axis is gross. The locomotive's
* overage tolerance is returned separately — the corridor budget spends it
* only to admit a booking whole, never to size a split.
*
* Limits come from the LOCOMOTIVE ALONE — the global-rules weight/length
* caps deliberately do not apply here (a mis-set global row once capped
* every train at 14m and no export booking could board).
*/
private async capacityLimits(
locomotive: Locomotive,
rules: TrainSchedulingGlobalRules | null,
): Promise<TrainLimits> {
private async capacityLimits(locomotive: Locomotive): Promise<TrainLimits> {
const wagonTypes = await this.loadWagonTypeDimensions();
const derived = deriveTrainCapacityFromLocomotive(
{
@@ -2534,14 +2518,6 @@ export class BookingBatchService implements OnModuleInit {
overageToleranceMeters: Number(locomotive.overageToleranceMeters) || 0,
},
wagonTypes,
{
maxTrainWeightTons: rules?.maxTrainWeightTons
? Number(rules.maxTrainWeightTons)
: undefined,
maxTrainLengthMeters: rules?.maxTrainLengthMeters
? Number(rules.maxTrainLengthMeters)
: undefined,
},
);
return {
base: {
@@ -2556,18 +2532,23 @@ export class BookingBatchService implements OnModuleInit {
};
}
/** Keep schedule.max_wagons aligned with locomotive physical limits. */
/**
* Keep schedule.max_wagons aligned with the train's boarding limit: the
* locomotive's length-derived slot count. The physical wagons currently in
* the train set do NOT cap this — bookings are admitted on length/weight
* alone and yard staff attach the wagons manually before departure.
*/
private async syncScheduleMaxWagons(
schedule: TrainSchedule,
locomotive: Locomotive,
rules: TrainSchedulingGlobalRules | null,
): Promise<void> {
const limits = await this.capacityLimits(locomotive, rules);
if ((schedule.maxWagons ?? 0) !== limits.base.wagons) {
const limits = await this.capacityLimits(locomotive);
const maxWagons = limits.base.wagons;
if ((schedule.maxWagons ?? 0) !== maxWagons) {
await this.dataSource
.getRepository(TrainSchedule)
.update(schedule.id, { maxWagons: limits.base.wagons });
schedule.maxWagons = limits.base.wagons;
.update(schedule.id, { maxWagons });
schedule.maxWagons = maxWagons;
}
}
@@ -2655,12 +2636,6 @@ export class BookingBatchService implements OnModuleInit {
};
}
private async loadGlobalRules(): Promise<TrainSchedulingGlobalRules | null> {
return this.dataSource
.getRepository(TrainSchedulingGlobalRules)
.findOne({ where: {} });
}
/**
* Ordered stop yards of the schedule's route (origin → milestones →
* destination); the legacy two-stop pseudo-route when milestones are absent.
@@ -2684,6 +2659,11 @@ export class BookingBatchService implements OnModuleInit {
* Remaining capacity per corridor edge = hard caps minus what allocated +
* reserved bookings already use ON THEIR OWN LEGS. A booking riding only
* Dire→Djibouti leaves the Addis→Dire edges untouched.
*
* The wagon axis is the locomotive's length-derived slot count only — the
* physical wagons currently marshalled in the train set do NOT cap it.
* Bookings are admitted on length/weight capacity and yard staff attach
* the missing wagons manually before wagon assignment.
*/
private async remainingBudget(
schedule: TrainSchedule,
@@ -2795,9 +2775,8 @@ export class BookingBatchService implements OnModuleInit {
if ((await this.remainingWagons(schedule)) <= 0) return true;
const locomotive = schedule.trainSet?.locomotive;
if (!locomotive) return false; // no weight/length limits to bind against
const rules = await this.loadGlobalRules();
const wagonDims = await this.loadWagonDims();
const limits = await this.capacityLimits(locomotive, rules);
const limits = await this.capacityLimits(locomotive);
const budget = await this.remainingBudget(schedule, limits, wagonDims);
return budget.isExhausted(this.minPerWagonNeed(wagonDims));
}

View File

@@ -1,6 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import {
NotificationAudience,
NotificationPriority,
NotificationType,
NotifyInput,
} from '@edr/types';
@@ -114,11 +115,15 @@ export class BookingNotifierService {
const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' });
const msg =
`Only ${offeredWagons} of ${totalWagons} wagons fit the train for booking ${b.reference ?? b.id}. ` +
`Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to accept and ship ${offeredWagons} wagon${offeredWagons === 1 ? '' : 's'} now ` +
`(the rest returns to your contract to book later). If you do not pay, the booking stays whole and you can rebook in the next window. Deadline: ${eat} EAT.`;
`Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to accept and ship ${offeredWagons} wagon${offeredWagons === 1 ? '' : 's'} now. ` +
`The remaining ${totalWagons - offeredWagons} return${totalWagons - offeredWagons === 1 ? 's' : ''} to your contract — book them yourself in a later window. ` +
`If you do not pay, the booking stays whole and you can rebook in the next window. Deadline: ${eat} EAT.`;
await this.notifyContact(b, msg, 'PAY NOW (PARTIAL)');
// HIGH: a split is a change to what the customer ordered AND a live payment
// deadline — it must reach email/SMS, not just the portal inbox.
this.inApp(b, 'Partial allocation offer', msg, {
type: NotificationType.INVOICE_ISSUED,
priority: NotificationPriority.HIGH,
});
}

View File

@@ -2645,8 +2645,9 @@ export class TrainSchedulingService {
const schedules = await this.trainSchedulesRepository.findAll({
relations: {
trainSet: { locomotive: true, locomotives: { locomotive: true } },
// Yards carry the route's display name used by mapScheduleListItem.
route: { originYard: true, destinationYard: true },
// Yards carry the route's display name used by mapScheduleListItem;
// milestones (with yards) let it show the full corridor path.
route: { originYard: true, destinationYard: true, milestones: { yard: true } },
originStation: true,
destinationStation: true,
scheduleBookings: { booking: true },
@@ -3111,6 +3112,10 @@ export class TrainSchedulingService {
const wagonTypes = await this.loadSchedulingWagonTypeDimensions();
if (locomotive) {
// With a locomotive assigned its own limits are the single source of
// truth — global-rules / env caps do not floor them (a mis-set global
// row once capped every train at 14m). Only an explicit per-request dto
// override still applies.
const derived = deriveTrainCapacityFromLocomotive(
{
maxPullWeightTons: Number(locomotive.maxPullWeightTons),
@@ -3120,8 +3125,8 @@ export class TrainSchedulingService {
},
wagonTypes,
{
maxTrainWeightTons: ruleWeightCap,
maxTrainLengthMeters: ruleLengthCap,
maxTrainWeightTons: dto?.maxTrainWeightTons,
maxTrainLengthMeters: dto?.maxTrainLengthMeters,
},
);
return {

View File

@@ -6,6 +6,11 @@ export class LoadInventoryDto {
@IsUUID()
wagonId!: string;
@ApiPropertyOptional({ format: 'uuid', description: 'Train schedule this load belongs to (recorded on the loading).' })
@IsOptional()
@IsUUID()
trainScheduleId?: string;
@ApiPropertyOptional({ description: 'Weight loaded onto the wagon (t)' })
@IsOptional()
@IsNumber()

View File

@@ -28,9 +28,17 @@ export class WarehouseLoading extends BaseEntity {
@JoinColumn({ name: 'booking_id' })
booking?: Booking | null;
/** Physical wagon the item was loaded onto. References freight.wagons (read-only link). */
@Column({ name: 'wagon_id', type: 'uuid' })
wagonId!: string;
/**
* Physical wagon the item was loaded onto. References freight.wagons
* (read-only link). Nullable: a schedule-level auto-load may not resolve to
* one wagon — the train association then lives in trainScheduleId.
*/
@Column({ name: 'wagon_id', type: 'uuid', nullable: true })
wagonId?: string | null;
/** Train schedule the item was loaded onto (read-only link to scheduling). */
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
trainScheduleId?: string | null;
@Column({ name: 'loaded_at', type: 'timestamptz' })
loadedAt!: Date;

View File

@@ -77,11 +77,6 @@ export class WarehouseInventoryController {
return this.inventoryService.bulkReceive(dto);
}
@Post('load-passed-export')
@ApiOperation({ summary: 'Bulk-load all EXPORT inventory that passed inspection (READY_FOR_LOADING)' })
loadPassedExport(@Body('performedBy') performedBy?: string) {
return this.inventoryService.loadPassedExport(performedBy);
}
@Get('ready-to-load-export')
@ApiOperation({ summary: 'EXPORT inventory that passed inspection and is READY_FOR_LOADING' })

View File

@@ -243,11 +243,6 @@ export interface BulkReceiveResult {
results: { bookingId: string; status: string; inventoryId?: string; grnNumber?: string; reason?: string }[];
}
export interface LoadPassedExportResult {
loadedCount: number;
skippedCount: number;
results: { inventoryId: string; status: string; reason?: string }[];
}
export interface BulkInspectResult {
inspectedCount: number;
@@ -1098,46 +1093,6 @@ export class WarehouseInventoryService {
return result;
}
/** Bulk-load all EXPORT inventory that passed inspection and is READY_FOR_LOADING. */
async loadPassedExport(performedBy?: string): Promise<LoadPassedExportResult> {
const ready = await this.inventoryRepository.findAll({ where: { status: 'READY_FOR_LOADING' } });
const result: LoadPassedExportResult = { loadedCount: 0, skippedCount: 0, results: [] };
for (const item of ready) {
const skip = (reason: string) => {
result.skippedCount += 1;
result.results.push({ inventoryId: item.id, status: 'SKIPPED', reason });
};
if (item.inspectionStatus !== 'PASSED') { skip('Inspection not PASSED'); continue; }
const direction = item.bookingId ? await this.getBookingDirection(item.bookingId) : null;
if (direction !== 'EXPORT') { skip('Not an EXPORT item'); continue; }
const bookingStatus = item.bookingId ? await this.getBookingStatus(item.bookingId) : null;
if (bookingStatus !== 'PAID') { skip('Booking not PAID'); continue; }
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(WarehouseInventory).update(item.id, {
status: 'LOADED',
loadedAt: new Date(),
});
await this.activityLog.record(
{
activityType: 'INVENTORY_LOADED',
inventoryId: item.id,
warehouseId: item.warehouseId,
description: 'Bulk loaded (passed export)',
performedBy,
},
manager,
);
});
result.loadedCount += 1;
result.results.push({ inventoryId: item.id, status: 'LOADED' });
}
return result;
}
/** EXPORT inventory rows at a given status (route-derived direction), with booking detail. */
private async exportInventoryByStatus(
@@ -1319,6 +1274,29 @@ export class WarehouseInventoryService {
performedBy?: string,
): Promise<TrainLoadResult> {
const result: TrainLoadResult = { loadedCount: 0, skippedCount: 0, results: [] };
const [schedule]: Array<{
trainNumber: string | null;
origin: string | null;
destination: string | null;
departure: string | null;
}> = await this.dataSource.query(
`SELECT ts.train_number AS "trainNumber",
COALESCE(oy.label, oy.code) AS "origin",
COALESCE(dy.label, dy.code) AS "destination",
ts.scheduled_departure_date AS "departure"
FROM freight.train_schedules ts
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
WHERE ts.id = $1 AND ts.deleted_at IS NULL`,
[scheduleId],
);
const trainNote = schedule
? `Loaded onto train ${schedule.trainNumber ?? scheduleId.slice(0, 8)}` +
(schedule.origin || schedule.destination
? ` (${schedule.origin ?? '?'} -> ${schedule.destination ?? '?'})`
: '') +
(schedule.departure ? `, departure ${new Date(schedule.departure).toISOString()}` : '')
: undefined;
const items = await this.trainLoadableItems(scheduleId);
const byId = new Map(items.map((i) => [i.id, i]));
const affectedBookingIds = new Set<string>();
@@ -1335,7 +1313,12 @@ export class WarehouseInventoryService {
if (!item.wagonId) { skip('No wagon allocated — allocate a wagon first'); continue; }
try {
await this.load(inventoryId, { wagonId: item.wagonId, loadedBy: performedBy });
await this.load(inventoryId, {
wagonId: item.wagonId,
loadedBy: performedBy,
trainScheduleId: scheduleId,
notes: trainNote,
});
result.loadedCount += 1;
result.results.push({ inventoryId, status: 'LOADED' });
if (item.bookingId) affectedBookingIds.add(item.bookingId);
@@ -1614,6 +1597,8 @@ export class WarehouseInventoryService {
status: 'UNLOADED',
unloadedAt: now,
arrivedAt: existing.arrivedAt ?? now,
// Import GRN is issued automatically at train unload.
...(existing.grnNumber ? {} : { grnNumber: this.generateGrnNumber('IMPORT', booking.id, now) }),
});
await this.activityLog.record({
activityType: 'INVENTORY_UNLOADED',
@@ -1647,6 +1632,7 @@ export class WarehouseInventoryService {
quantity: 1,
weight: Number(booking.weight) || 0,
status: 'UNLOADED',
grnNumber: this.generateGrnNumber('IMPORT', booking.id, now),
arrivedAt: now,
unloadedAt: now,
notes: allocated?.rule ? `Unloaded → ${allocated.path}` : 'Unloaded from arrived import train',
@@ -2774,11 +2760,12 @@ export class WarehouseInventoryService {
const rows: Array<{ containerNumber: string; weightTons: string }> =
await this.dataSource.query(
`SELECT bcu.container_number AS "containerNumber",
COALESCE(bcu.vgm_tons, 0) AS "weightTons"
MAX(COALESCE(bcu.vgm_tons, 0)) AS "weightTons"
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL
GROUP BY bcu.container_number
ORDER BY bcu.container_number`,
[bookingId],
);
@@ -3403,6 +3390,8 @@ export class WarehouseInventoryService {
warehouseInventoryId: id,
bookingId: item.bookingId ?? null,
wagonId: dto.wagonId,
// Which train this load belongs to — durable even if wagons reshuffle.
trainScheduleId: dto.trainScheduleId ?? null,
loadedAt: now,
loadedBy: dto.loadedBy ?? null,
loadedWeight,
@@ -3441,7 +3430,7 @@ export class WarehouseInventoryService {
});
// Enrich with wagon numbers (read-only lookup into the scheduling domain).
const wagonIds = [...new Set(loadings.map((l) => l.wagonId))];
const wagonIds = [...new Set(loadings.map((l) => l.wagonId).filter((id): id is string => Boolean(id)))];
const wagonNumbers = new Map<string, string>();
if (wagonIds.length > 0) {
const rows: Array<{ id: string; wagon_number: string }> = await this.dataSource.query(
@@ -3452,7 +3441,7 @@ export class WarehouseInventoryService {
}
return loadings.map((loading) =>
Object.assign(loading, { wagonNumber: wagonNumbers.get(loading.wagonId) ?? null }),
Object.assign(loading, { wagonNumber: (loading.wagonId && wagonNumbers.get(loading.wagonId)) ?? null }),
);
}