[200~feat: add CustomsPaymentsCard and PaymentsTab components for handling customs payments and payment summaries

This commit is contained in:
Marshal
2026-08-20 15:45:42 +00:00
parent 8d7551bb8e
commit 9e1d5ee9f2
58 changed files with 3324 additions and 810 deletions

View File

@@ -67,6 +67,7 @@ const TRIGGER_ROUTE_LABELS: Partial<Record<Rate['trigger'], string>> = {
DEMURRAGE: 'Demurrage / wagon detention',
PIL_EXTRA_FEE: 'PIL shipping line extra fee',
CUSTOMS_CLEARANCE: 'Customs clearance service',
ETHIOPIAN_CUSTOMS_CLEARANCE: 'Ethiopian customs clearance service',
FUEL: 'Fuel surcharge',
};

View File

@@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Per-schedule wagon yard plan — where THIS departure expects each consist
* wagon to board, independent of where the wagon physically stands today.
*
* `wagons.current_yard_id` is one physical fact shared by every schedule of a
* built train, so a train standing in Mojo could not be sold from Dire for a
* departure next week. The plan is a sparse jsonb map `{ wagonId: yardId }`
* on the schedule: a wagon missing from the map boards from its physical yard.
* Booking capacity, fleet availability and wagon pinning all read the plan;
* dispatch refuses to leave until the plan and the physical yards agree.
*/
export class SchedulePlannedWagonYards3620000000000 implements MigrationInterface {
name = 'SchedulePlannedWagonYards3620000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
ADD COLUMN IF NOT EXISTS planned_wagon_yards jsonb
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS planned_wagon_yards
`);
}
}

View File

@@ -0,0 +1,49 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* The customer now approves a clearance charge before it becomes an invoice:
* GL describes the price, SENDs it, the customer ACCEPTs (invoice issued, charge
* locked) or REJECTs with a note (GL revises and re-sends). Charges that were
* already sent as invoices under the old flow are carried over as ACCEPTED so
* their invoices stay payable.
*/
export class ClearanceChargeCustomerDecision3630000000000
implements MigrationInterface
{
name = 'ClearanceChargeCustomerDecision3630000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "freight"."booking_clearance_charge"
ADD COLUMN IF NOT EXISTS "description" text,
ADD COLUMN IF NOT EXISTS "customer_note" text,
ADD COLUMN IF NOT EXISTS "customer_decided_at" timestamptz,
ADD COLUMN IF NOT EXISTS "customer_decided_by" uuid
`);
await queryRunner.query(`
UPDATE "freight"."booking_clearance_charge"
SET "status" = 'ACCEPTED'
WHERE "status" = 'SENT' AND "invoice_id" IS NOT NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE "freight"."booking_clearance_charge"
SET "status" = 'SENT'
WHERE "status" = 'ACCEPTED'
`);
await queryRunner.query(`
UPDATE "freight"."booking_clearance_charge"
SET "status" = 'BILLED'
WHERE "status" = 'REJECTED'
`);
await queryRunner.query(`
ALTER TABLE "freight"."booking_clearance_charge"
DROP COLUMN IF EXISTS "description",
DROP COLUMN IF EXISTS "customer_note",
DROP COLUMN IF EXISTS "customer_decided_at",
DROP COLUMN IF EXISTS "customer_decided_by"
`);
}
}

View File

@@ -0,0 +1,69 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Ethiopian-side-only customs clearance:
*
* - service_types.includes_ethiopian_customs_only marks a customs service that
* EDR clears on the Ethiopian side only. Same clearance flow; only the fee
* differs — pricing looks up the ETHIOPIAN_CUSTOMS_CLEARANCE rate instead of
* CUSTOMS_CLEARANCE.
* - rates.trigger widens to 30 chars to fit the new trigger value.
* - CK_rates_yard_scope gains ETHIOPIAN_CUSTOMS_CLEARANCE in its yard-carrying
* branch: it is priced per origin → destination leg like customs clearance.
*/
export class EthiopianCustomsClearance3640000000000 implements MigrationInterface {
name = 'EthiopianCustomsClearance3640000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.service_types
ADD COLUMN IF NOT EXISTS includes_ethiopian_customs_only boolean NOT NULL DEFAULT false
`);
await queryRunner.query(
`ALTER TABLE freight.rates ALTER COLUMN trigger TYPE varchar(30)`,
);
await queryRunner.query(
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`,
);
await queryRunner.query(`
ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
CASE
WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'))
OR trigger IN ('CUSTOMS_CLEARANCE', 'ETHIOPIAN_CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL')
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
END
)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`,
);
await queryRunner.query(`
ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
CASE
WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'))
OR trigger IN ('CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL')
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
END
)
`);
// Rows on the new trigger would not fit varchar(20) — drop them first.
await queryRunner.query(
`DELETE FROM freight.rates WHERE trigger = 'ETHIOPIAN_CUSTOMS_CLEARANCE'`,
);
await queryRunner.query(
`ALTER TABLE freight.rates ALTER COLUMN trigger TYPE varchar(20)`,
);
await queryRunner.query(
`ALTER TABLE freight.service_types DROP COLUMN IF EXISTS includes_ethiopian_customs_only`,
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -388,6 +388,27 @@ 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;
const service = makeService({ liveRates: [containerFee20, ethiopianFee] });
const result = await service.computePriceForBooking(
containerBooking({
serviceType: { includesCustoms: true, includesEthiopianCustomsOnly: true },
} as never),
);
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());

View File

@@ -1060,9 +1060,18 @@ 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.
const customsType = booking.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 +1079,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 +1115,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 +1133,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 +1150,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 +1181,8 @@ export class BookingPricingService {
const amount = unit === 'FLAT' ? unitAmount : unitAmount * billedQty;
if (amount > 0) {
lineItems.push({
code: 'CUSTOMS_CLEARANCE',
description: 'Customs clearance service (bulk)',
code: customsType,
description: `${customsLabel} (bulk)`,
amount,
unitAmount,
unit,

View File

@@ -40,8 +40,12 @@ import {
import type { Response } from "express";
import { BookingClearanceChargeService } from './booking-clearance-charge.service';
import { BookingPayablesService } from './booking-payables.service';
import { ClearanceEventService } from './clearance-event.service';
import { BillClearanceChargeDto } from './dto/clearance-charge.dto';
import {
BillClearanceChargeDto,
RejectClearanceChargeDto,
} from './dto/clearance-charge.dto';
import { BookingContractService } from './booking-contract.service';
import { BookingPricingService } from './booking-pricing.service';
import { BookingTransitionService } from './booking-transition.service';
@@ -175,6 +179,7 @@ export class BookingsController {
private readonly wagonCancellationService: BookingWagonCancellationService,
private readonly consolidationApprovalService: ConsolidationApprovalService,
private readonly clearanceChargeService: BookingClearanceChargeService,
private readonly bookingPayablesService: BookingPayablesService,
private readonly clearanceEventService: ClearanceEventService,
) {}
@@ -312,6 +317,21 @@ export class BookingsController {
return this.bookingsService.getListSummary(filter);
}
@Get("my-payables")
@PortalCustomer()
@ApiOperation({
summary:
"Outstanding customer payments per booking — invoices to pay, prices to accept, duty slips to upload",
})
async findMyPayables(@CurrentUser() user: AuthUserPayload) {
const companyId = await this.bookingsService.resolveCustomerCompanyId(
resolveAuthUserId(user),
);
return companyId
? this.bookingPayablesService.summarizeForCompany(companyId)
: [];
}
@Get("my")
@PortalCustomer()
@ApiOperation({
@@ -1118,15 +1138,63 @@ export class BookingsController {
// ── Clearance charges (post-finalization customer billing) ────────────────
@Get(":id/clearance/charges")
@BookingStaff([
@MixedAudience([
FREIGHT_PERMS.contracts.clearanceEtActions,
FREIGHT_PERMS.contracts.clearanceDjActions,
])
@ApiOperation({
summary: "Clearance charges billed to the customer (port + miscellaneous)",
summary:
"Clearance charges billed to the customer (port + miscellaneous); customers see only the charges sent to them",
})
getClearanceCharges(@Param("id", ParseUUIDPipe) id: string) {
return this.clearanceChargeService.list(id);
async getClearanceCharges(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const isStaff =
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) ||
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions);
if (isStaff) return this.clearanceChargeService.list(id);
const booking = await this.bookingsService.findById(id);
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
return this.clearanceChargeService.listForCustomer(id);
}
@Post(":id/clearance/charges/:chargeId/accept")
@PortalCustomer()
@ApiOperation({
summary:
"Customer accepts a proposed clearance charge — issues the payable invoice and locks the charge",
})
acceptClearanceCharge(
@Param("id", ParseUUIDPipe) id: string,
@Param("chargeId", ParseUUIDPipe) chargeId: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceChargeService.customerAccept(
id,
chargeId,
resolveAuthUserId(user),
);
}
@Post(":id/clearance/charges/:chargeId/reject")
@PortalCustomer()
@ApiOperation({
summary:
"Customer rejects a proposed clearance charge with a reason — GL Ethiopia revises and re-sends",
})
rejectClearanceCharge(
@Param("id", ParseUUIDPipe) id: string,
@Param("chargeId", ParseUUIDPipe) chargeId: string,
@Body() dto: RejectClearanceChargeDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceChargeService.customerReject(
id,
chargeId,
dto.note,
resolveAuthUserId(user),
);
}
@Post(":id/clearance/charges/port-document")
@@ -1153,7 +1221,7 @@ export class BookingsController {
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({
summary:
"GL Ethiopia sets or revises the charge's amount + currency (revising a sent charge cancels its unpaid invoice)",
"GL Ethiopia sets or revises the charge's amount, currency and description (locked once the customer accepts)",
})
billClearanceCharge(
@Param("id", ParseUUIDPipe) id: string,
@@ -1173,7 +1241,7 @@ export class BookingsController {
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({
summary:
"GL Ethiopia issues the charge's payable invoice to the customer (ETB pays via gateway, other currencies via manual settlement)",
"GL Ethiopia sends the priced charge to the customer for approval (the invoice is issued when they accept)",
})
sendClearanceCharge(
@Param("id", ParseUUIDPipe) id: string,
@@ -1193,7 +1261,7 @@ export class BookingsController {
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary:
"GL Ethiopia creates the miscellaneous charge (document + amount + currency); unlocked once the port charge is paid",
"GL Ethiopia creates a miscellaneous charge (document + amount + currency + description) as a draft to send",
})
createMiscellaneousCharge(
@Param("id", ParseUUIDPipe) id: string,

View File

@@ -39,6 +39,7 @@ 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 { 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';
@@ -118,6 +119,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
BookingContractService,
BookingInvoiceService,
BookingClearanceChargeService,
BookingPayablesService,
ClearanceEventService,
ContractTemplateResolver,
ContractViewModelBuilder,

View File

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

View File

@@ -9,6 +9,8 @@ export const CLEARANCE_CHARGE_STATUSES = [
'DOC_UPLOADED',
'BILLED',
'SENT',
'REJECTED',
'ACCEPTED',
'PAID',
] as const;
export type ClearanceChargeStatus = (typeof CLEARANCE_CHARGE_STATUSES)[number];
@@ -17,9 +19,11 @@ export type ClearanceChargeStatus = (typeof CLEARANCE_CHARGE_STATUSES)[number];
* 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 (BILLED) and issues the invoice (SENT); the billing
* `clearance_charge.invoice.paid` event marks it PAID. The two levels are
* independent — either may be raised first.
* 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'])
@@ -47,6 +51,20 @@ export class BookingClearanceCharge extends BaseEntity {
@Column({ name: 'currency', type: 'varchar', length: 8, nullable: true })
currency?: string | null;
/** What the price is for, written by GL. */
@Column({ name: 'description', type: 'text', nullable: true })
description?: string | null;
/** Customer's reason when REJECTED; cleared when GL revises. */
@Column({ name: 'customer_note', type: 'text', nullable: true })
customerNote?: string | null;
@Column({ name: 'customer_decided_at', type: 'timestamptz', nullable: true })
customerDecidedAt?: Date | null;
@Column({ name: 'customer_decided_by', type: 'uuid', nullable: true })
customerDecidedBy?: string | null;
/** The payable invoice issued for this charge (null until SENT). */
@Column({ name: 'invoice_id', type: 'uuid', nullable: true })
invoiceId?: string | null;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,6 +27,14 @@ export class ServiceType extends BaseEntity {
@Column({ name: 'includes_customs', type: 'boolean', default: false })
includesCustoms!: boolean;
/**
* EDR clears customs on the Ethiopian side only. Requires includesCustoms —
* the clearance flow (GL review, duty) is identical; only the fee differs:
* pricing looks up ETHIOPIAN_CUSTOMS_CLEARANCE instead of CUSTOMS_CLEARANCE.
*/
@Column({ name: 'includes_ethiopian_customs_only', type: 'boolean', default: false })
includesEthiopianCustomsOnly!: boolean;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;

View File

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

View File

@@ -1,5 +1,11 @@
import { PaginatedResponse } from '@edr/types';
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
ConflictException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { generateCode } from '../../../common/utils/generate-code.util';
import { CreateServiceTypeDto } from '../dto/create-service-type.dto';
import { ListServiceTypesQueryDto } from '../dto/list-rule-engine-query.dto';
@@ -43,6 +49,7 @@ 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}"`);
this.assertCustomsFlags(dto.includesCustoms ?? false, dto.includesEthiopianCustomsOnly ?? false);
const displayOrder = await this.displayOrder.resolveCreateOrder(ServiceType, 'displayOrder', {
explicitOrder: dto.displayOrder,
insertAfterId: dto.insertAfterId,
@@ -56,6 +63,7 @@ export class ServiceTypesService {
includesFirstMile: dto.includesFirstMile ?? false,
includesLastMile: dto.includesLastMile ?? false,
includesCustoms: dto.includesCustoms ?? false,
includesEthiopianCustomsOnly: dto.includesEthiopianCustomsOnly ?? false,
isActive: dto.isActive ?? true,
displayOrder,
});
@@ -63,13 +71,26 @@ export class ServiceTypesService {
/** Update an existing service type. */
async update(id: string, dto: UpdateServiceTypeDto): Promise<ServiceType> {
await this.findById(id);
const existing = await this.findById(id);
this.assertCustomsFlags(
dto.includesCustoms ?? existing.includesCustoms,
dto.includesEthiopianCustomsOnly ?? existing.includesEthiopianCustomsOnly,
);
const { ...patch } = dto;
const updated = await this.repository.update(id, patch);
if (!updated) throw new NotFoundException(`Service type ${id} not found`);
return updated;
}
/** "Ethiopian customs only" narrows a customs service — it cannot stand alone. */
private assertCustomsFlags(includesCustoms: boolean, ethiopianOnly: boolean): void {
if (ethiopianOnly && !includesCustoms) {
throw new BadRequestException(
'"Ethiopian customs only" requires "Includes customs" to be enabled.',
);
}
}
/** Soft-delete a service type. */
async remove(id: string): Promise<void> {
await this.findById(id);

View File

@@ -120,6 +120,16 @@ 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;
/** OPEN = accepting/holding bookings; FULL = train filled; CLOSED = manually closed. Orthogonal to `status`. */
@Column({ name: 'booking_window_status', type: 'varchar', length: 10, default: 'OPEN' })
bookingWindowStatus!: string;

View File

@@ -48,6 +48,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 +202,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 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 (schedule-only; physical yards untouched, dispatch requires alignment)",
})
updateScheduleWagonYards(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateScheduleWagonYardsDto,
) {
return this.trainSchedulingService.updateScheduleWagonYards(id, dto.moves);
}
@Post("schedules/:id/adjust-consist")
@TrainSchedulingUpdate()
@ApiOperation({

View File

@@ -0,0 +1,26 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { ArrayMaxSize, IsArray, IsUUID, ValidateNested } from 'class-validator';
export class ScheduleWagonYardMoveDto {
@ApiProperty({ format: 'uuid', description: "Wagon coupled to the schedule's built train." })
@IsUUID()
wagonId!: string;
@ApiProperty({ format: 'uuid', description: 'Pickup stop of the route this departure boards the wagon from.' })
@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.',
})
@IsArray()
@ArrayMaxSize(500)
@ValidateNested({ each: true })
@Type(() => ScheduleWagonYardMoveDto)
moves!: ScheduleWagonYardMoveDto[];
}

View File

@@ -138,6 +138,12 @@ import {
import { CorridorBudget } from '../corridor-capacity.util';
import { deriveScheduleDirection } from '../utils/derive-schedule-direction.util';
import { computeScheduleWagonUsage } from '../utils/schedule-wagon-usage.util';
import {
defaultPlannedWagonYards,
misalignedWagons,
type PlannedWagonYards,
scheduleYardOf,
} from '../utils/planned-wagon-yards.util';
import { pickLowestFreeNumber, pickTrainNumberPool } from '../train-number.util';
import {
bookingCargoTons,
@@ -1557,6 +1563,7 @@ export class TrainSchedulingService {
// and yard follow the schedule.
let builtTrain: Train | null = null;
let locomotiveIds: string[];
let plannedWagonYards: PlannedWagonYards | null = null;
if (dto.trainId) {
builtTrain = await this.dataSource.getRepository(Train).findOne({
where: { id: dto.trainId },
@@ -1589,7 +1596,7 @@ export class TrainSchedulingService {
`Train ${builtTrain.code} is not at the origin yard yet; it must arrive before this departure dispatches`,
);
}
await this.assertRouteCoversWagonYards(builtTrain, route);
plannedWagonYards = await this.defaultPlannedWagonYardsFor(builtTrain, route, scheduleWarnings);
const conflict = await this.findTrainRouteDayConflict(
builtTrain.id,
route.id,
@@ -1844,6 +1851,7 @@ export class TrainSchedulingService {
direction,
trainNumber: pairTrainNumber ?? undefined,
maxWagons,
plannedWagonYards,
reverseWagonOrder: dto.reverseWagonOrder ?? false,
shippingLineCompanyId: dto.shippingLineCompanyId ?? null,
...windowFields,
@@ -2742,6 +2750,9 @@ export class TrainSchedulingService {
);
}
}
// The yard plan this departure was SOLD against must match where the steel
// actually stands: a wagon sold from Dire but still in Mojo cannot board.
await this.assertPlannedYardsAligned(schedule);
await this.dataSource.transaction(async (manager) => {
const trainNumber = await this.assignTrainNumber(manager, schedule);
@@ -5288,15 +5299,30 @@ export class TrainSchedulingService {
return rows[0]?.train_id ?? null;
}
/** `{ wagonId: yardId }` this schedule boards each wagon from; `{}` when unset. */
private async plannedWagonYardsOf(
scheduleId: string | undefined,
manager?: EntityManager,
): Promise<PlannedWagonYards> {
if (!scheduleId) return {};
const runner = manager ?? this.dataSource;
const rows: { planned_wagon_yards: PlannedWagonYards | null }[] = await runner.query(
`SELECT planned_wagon_yards FROM freight.train_schedules WHERE id = $1`,
[scheduleId],
);
return rows[0]?.planned_wagon_yards ?? {};
}
private async countFleetAvailability(
originYardId: string,
targetScheduleId?: string,
): Promise<Array<{ wagonTypeId: string; wagonTypeCode: string; available: number }>> {
const [wagons, wagonTypes, builtTrainId, pinnedToTargetIds] = await Promise.all([
const [wagons, wagonTypes, builtTrainId, pinnedToTargetIds, plan] = await Promise.all([
this.dataSource.getRepository(Wagon).find(),
this.dataSource.getRepository(WagonType).find(),
this.builtTrainIdOfSchedule(targetScheduleId),
this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId),
this.plannedWagonYardsOf(targetScheduleId),
]);
const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code]));
const counts = new Map<string, { code: string; available: number }>();
@@ -5304,11 +5330,14 @@ export class TrainSchedulingService {
// A built consist spread across several yards can only offer, at each yard,
// the wagons standing there. A single-yard consist keeps the original
// behaviour: the whole train counts wherever it currently sits.
// Yards are the SCHEDULE's plan (falling back to the physical yard), so a
// departure sold from Dire counts its Dire wagons even while they still
// stand in Mojo — dispatch is what demands the two agree.
const consistYards = builtTrainId
? new Set(
wagons
.filter((w) => w.trainId === builtTrainId && w.currentYardId)
.map((w) => w.currentYardId as string),
.filter((w) => w.trainId === builtTrainId && scheduleYardOf(plan, w))
.map((w) => scheduleYardOf(plan, w) as string),
)
: new Set<string>();
const consistIsSplit = consistYards.size > 1;
@@ -5320,7 +5349,7 @@ export class TrainSchedulingService {
// counted at the yard each wagon actually stands in.
if (builtTrainId) {
if (wagon.trainId !== builtTrainId) continue;
if (consistIsSplit && wagon.currentYardId !== originYardId) continue;
if (consistIsSplit && scheduleYardOf(plan, wagon) !== originYardId) continue;
} else {
// Schedule-scoped availability: pins held by OTHER schedules never
// consume a wagon here — the same physical wagon may serve the July 17
@@ -5491,6 +5520,7 @@ export class TrainSchedulingService {
const pinSchedule = await this.trainSchedulesRepository.findById(scheduleId);
const stops = pinSchedule ? await this.stopYardsForSchedule(pinSchedule) : [];
const plannedYards = pinSchedule?.plannedWagonYards ?? {};
const unpinnable = this.findUnpinnableWagonSlots(
planSlots,
@@ -5500,6 +5530,7 @@ export class TrainSchedulingService {
builtTrainId,
pinnedToScheduleIds,
stops,
plannedYards,
);
if (unpinnable.length) {
throw new BadRequestException({
@@ -5521,6 +5552,7 @@ export class TrainSchedulingService {
builtTrainId,
pinnedToScheduleIds,
reverseWagonOrder,
plannedYards,
);
if (!physical) continue;
@@ -5570,6 +5602,7 @@ export class TrainSchedulingService {
builtTrainId,
pinnedToScheduleIds,
stops,
targetSchedule?.plannedWagonYards ?? {},
);
}
@@ -5602,6 +5635,7 @@ export class TrainSchedulingService {
builtTrainId: string | null = null,
pinnedToScheduleIds: Set<string> = new Set(),
stops: string[] = [],
plannedYards: PlannedWagonYards = {},
): string[] {
const violations: string[] = [];
// One physical wagon may serve several slots whose leg spans don't overlap
@@ -5620,6 +5654,8 @@ export class TrainSchedulingService {
span,
builtTrainId,
pinnedToScheduleIds,
false,
plannedYards,
);
if (!physical) {
violations.push(
@@ -5650,6 +5686,7 @@ export class TrainSchedulingService {
builtTrainId: string | null = null,
pinnedToScheduleIds: Set<string> = new Set(),
reverseWagonOrder = false,
plannedYards: PlannedWagonYards = {},
): Wagon | undefined {
// Free for this slot = no already-assigned span on this wagon overlaps the
// slot's own leg. Disjoint legs (alight before board) share the wagon.
@@ -5682,8 +5719,8 @@ export class TrainSchedulingService {
// takes slot #1). Unsequenced wagons sort after every sequenced one.
const consistYards = new Set(
wagons
.filter((w) => w.trainId === builtTrainId && w.currentYardId)
.map((w) => w.currentYardId as string),
.filter((w) => w.trainId === builtTrainId && scheduleYardOf(plannedYards, w))
.map((w) => scheduleYardOf(plannedYards, w) as string),
);
// Split consist: a slot boarding at a given yard must take a wagon that
// physically stands there — the train cannot load a Mojo wagon at Dire.
@@ -5696,7 +5733,7 @@ export class TrainSchedulingService {
w.trainId === builtTrainId &&
w.wagonTypeId === slot.wagonTypeId &&
spanFree(w.id) &&
(!requiredYardId || w.currentYardId === requiredYardId),
(!requiredYardId || scheduleYardOf(plannedYards, w) === requiredYardId),
)
.sort((a, b) => {
if (a.sequenceNumber == null || b.sequenceNumber == null) {
@@ -5836,7 +5873,7 @@ export class TrainSchedulingService {
preloadedBuiltTrainId !== undefined
? preloadedBuiltTrainId
: await this.builtTrainIdOfSchedule(scheduleId);
if (builtTrainId) return this.builtTrainStock(builtTrainId);
if (builtTrainId) return this.builtTrainStock(builtTrainId, scheduleId);
const boardYardIds = [
...new Set([originYardId, ...boardingYardIds].filter((id): id is string => Boolean(id))),
@@ -5858,11 +5895,17 @@ export class TrainSchedulingService {
return { mode: 'YARD', remainingByTypeId, codesByTypeId };
}
private async builtTrainStock(builtTrainId: string): Promise<WagonStock> {
const wagons = await this.dataSource.getRepository(Wagon).find({
where: { trainId: builtTrainId },
relations: { wagonType: true },
});
private async builtTrainStock(
builtTrainId: string,
scheduleId?: string,
): Promise<WagonStock> {
const [wagons, plan] = await Promise.all([
this.dataSource.getRepository(Wagon).find({
where: { trainId: builtTrainId },
relations: { wagonType: true },
}),
this.plannedWagonYardsOf(scheduleId),
]);
const remainingByTypeId = new Map<string, number>();
const codesByTypeId = new Map<string, string>();
const byYardId = new Map<string, Map<string, number>>();
@@ -5872,10 +5915,12 @@ export class TrainSchedulingService {
(remainingByTypeId.get(wagon.wagonTypeId) ?? 0) + 1,
);
if (wagon.wagonType) codesByTypeId.set(wagon.wagonTypeId, wagon.wagonType.code);
if (wagon.currentYardId) {
const perType = byYardId.get(wagon.currentYardId) ?? new Map<string, number>();
// The schedule's own yard plan, not the physical yard — see plannedWagonYards.
const yardId = scheduleYardOf(plan, wagon);
if (yardId) {
const perType = byYardId.get(yardId) ?? new Map<string, number>();
perType.set(wagon.wagonTypeId, (perType.get(wagon.wagonTypeId) ?? 0) + 1);
byYardId.set(wagon.currentYardId, perType);
byYardId.set(yardId, perType);
}
}
// Single-yard consist (the overwhelming majority): the whole train is
@@ -6215,17 +6260,22 @@ export class TrainSchedulingService {
}
/**
* A built train's wagons may stand in several yards. The route must pass
* through every one of them as origin or an intermediate stop — never only
* as the final destination (the train has to pick the wagons up en route).
* Default yard plan for a schedule created from a built train: every wagon
* keeps the yard it physically stands in when that yard is a pickup stop of
* the route (origin or intermediate — never only the destination, the train
* has to collect it en route); the rest are planned at the origin and
* reported as a warning so staff can redistribute in the schedule-yards tab.
*/
private async assertRouteCoversWagonYards(train: Train, route: Route) {
private async defaultPlannedWagonYardsFor(
train: Train,
route: Route,
warnings: string[],
): Promise<PlannedWagonYards | null> {
const wagons = await this.dataSource.getRepository(Wagon).find({
where: { trainId: train.id },
select: { id: true, currentYardId: true },
select: { id: true, currentYardId: true, wagonNumber: true },
});
const wagonYards = [...new Set(wagons.map((w) => w.currentYardId).filter((y): y is string => !!y))];
if (!wagonYards.length) return;
if (!wagons.length) return null;
const milestones = await this.dataSource
.getRepository(RouteMilestone)
@@ -6236,23 +6286,203 @@ export class TrainSchedulingService {
// Every stop except the last one is a pickup point.
const pickupYards = new Set(stops.slice(0, -1));
const uncovered = wagonYards.filter((y) => !pickupYards.has(y));
if (!uncovered.length) return;
const { plan, rehomed } = defaultPlannedWagonYards(wagons, pickupYards, route.originYardId);
if (rehomed.length) {
const labels = await this.yardLabelMap([
...new Set(rehomed.map((w) => w.currentYardId).filter((y): y is string => !!y)),
]);
const origin = labels.get(route.originYardId) ?? route.originYard?.label ?? 'the origin';
const where = [...new Set(rehomed.map((w) => (w.currentYardId ? labels.get(w.currentYardId) ?? w.currentYardId : 'no yard')))].join(', ');
warnings.push(
`${rehomed.length} wagon(s) of train ${train.code} stand off this route (${where}) and were planned at ${origin}; ` +
'they must be moved there before dispatch — adjust in the schedule yards tab if they should board elsewhere',
);
}
return plan;
}
const labels = await this.yardLabelMap(uncovered);
const destination = stops[stops.length - 1];
const detail = uncovered
.map((y) =>
y === destination
? `${labels.get(y) ?? y} (only as the destination)`
: `${labels.get(y) ?? y} (not on route)`,
)
/** Dispatch gate: every planned wagon must physically stand at its planned yard. */
private async assertPlannedYardsAligned(schedule: TrainSchedule) {
const builtTrainId = schedule.trainSet?.trainId;
if (!builtTrainId) return;
const wagons = await this.dataSource.getRepository(Wagon).find({
where: { trainId: builtTrainId },
select: { id: true, currentYardId: true, wagonNumber: true },
});
const off = misalignedWagons(schedule.plannedWagonYards, wagons);
if (!off.length) return;
const plan = schedule.plannedWagonYards ?? {};
const labels = await this.yardLabelMap([
...new Set(off.flatMap((w) => [plan[w.id], w.currentYardId]).filter((y): y is string => !!y)),
]);
const detail = off
.slice(0, 5)
.map((w) => `${w.wagonNumber} (planned ${labels.get(plan[w.id]) ?? plan[w.id]}, at ${w.currentYardId ? labels.get(w.currentYardId) ?? w.currentYardId : 'no yard'})`)
.join(', ');
throw new BadRequestException(
`Route ${formatRouteLabel(route)} does not pass through every yard where train ${train.code}'s wagons stand: ${detail}`,
throw new ConflictException(
`Cannot dispatch: ${off.length} wagon(s) are not at the yard this schedule planned them for — ${detail}` +
(off.length > 5 ? ', …' : '') +
'. Move them in the train builder or re-plan them in the schedule yards tab.',
);
}
/**
* Schedule-yards tab: where THIS departure boards each consist wagon vs
* where it physically stands, per stop totals, and which wagons are locked
* (already carrying this schedule's cargo).
*/
async getScheduleWagonYards(scheduleId: string) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
const builtTrain = schedule.trainSet?.train;
if (!builtTrain) {
throw new BadRequestException(
'This schedule was not created from a built train — it has no wagon yard plan',
);
}
const stops = this.mapScheduleStops(schedule);
const pickupYardIds = new Set(stops.slice(0, -1).map((s) => s.yardId));
const plan = schedule.plannedWagonYards ?? {};
const wagons = await this.dataSource.getRepository(Wagon).find({
where: { trainId: builtTrain.id },
relations: { wagonType: true, currentYard: true },
order: { sequenceNumber: 'ASC' },
});
const lockedIds = new Set(
(schedule.trainSet?.wagons ?? [])
.filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0)
.map((slot) => slot.physicalWagonId as string),
);
const offRouteYardIds = [
...new Set(
wagons
.flatMap((w) => [scheduleYardOf(plan, w), w.currentYardId])
.filter((y): y is string => !!y && !stops.some((s) => s.yardId === y)),
),
];
const labels = new Map([
...stops.map((s) => [s.yardId, s.label] as const),
...(await this.yardLabelMap(offRouteYardIds)),
]);
const editable =
schedule.status === TrainScheduleStatusEnum.Draft ||
schedule.status === TrainScheduleStatusEnum.Scheduled;
const rows = wagons.map((w) => {
const plannedYardId = scheduleYardOf(plan, w);
const locked = lockedIds.has(w.id);
return {
id: w.id,
wagonNumber: w.wagonNumber,
sequenceNumber: w.sequenceNumber,
wagonType: w.wagonType
? { id: w.wagonType.id, code: w.wagonType.code, name: w.wagonType.name }
: { id: w.wagonTypeId, code: w.wagonTypeId, name: w.wagonTypeId },
physicalYardId: w.currentYardId,
physicalYardLabel: w.currentYardId ? labels.get(w.currentYardId) ?? w.currentYardId : null,
plannedYardId,
plannedYardLabel: plannedYardId ? labels.get(plannedYardId) ?? plannedYardId : null,
aligned: plannedYardId === w.currentYardId,
locked,
lockReason: locked ? 'Carries cargo booked on this schedule' : null,
};
});
const perStop = stops.map((s) => ({
yardId: s.yardId,
label: s.label,
pickup: pickupYardIds.has(s.yardId),
planned: rows.filter((r) => r.plannedYardId === s.yardId).length,
physical: rows.filter((r) => r.physicalYardId === s.yardId).length,
}));
return {
scheduleId,
train: { id: builtTrain.id, code: builtTrain.code },
editable,
stops: perStop,
wagons: rows,
misaligned: rows.filter((r) => !r.aligned).length,
};
}
/**
* Re-plan which yard this departure boards wagons from. Only DRAFT/SCHEDULED
* schedules, only the train's own wagons, only pickup stops of the route,
* never a wagon already carrying this schedule's cargo. Physical yards are
* untouched — the train builder owns those.
*/
async updateScheduleWagonYards(
scheduleId: string,
moves: Array<{ wagonId: string; yardId: string }>,
) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
const builtTrain = schedule.trainSet?.train;
if (!builtTrain) {
throw new BadRequestException(
'This schedule was not created from a built train — it has no wagon yard plan',
);
}
if (
schedule.status !== TrainScheduleStatusEnum.Draft &&
schedule.status !== TrainScheduleStatusEnum.Scheduled
) {
throw new ConflictException(
`Wagon yards can only be re-planned before departure (schedule is ${schedule.status})`,
);
}
const stops = this.mapScheduleStops(schedule);
const pickupYardIds = new Set(stops.slice(0, -1).map((s) => s.yardId));
const wagons = await this.dataSource.getRepository(Wagon).find({
where: { trainId: builtTrain.id },
select: { id: true, currentYardId: true, wagonNumber: true },
});
const wagonById = new Map(wagons.map((w) => [w.id, w]));
const lockedIds = new Set(
(schedule.trainSet?.wagons ?? [])
.filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0)
.map((slot) => slot.physicalWagonId as string),
);
const plan: PlannedWagonYards = { ...(schedule.plannedWagonYards ?? {}) };
for (const move of moves) {
const wagon = wagonById.get(move.wagonId);
if (!wagon) {
throw new BadRequestException(`Wagon ${move.wagonId} is not coupled to train ${builtTrain.code}`);
}
if (!pickupYardIds.has(move.yardId)) {
throw new BadRequestException(
`Yard ${move.yardId} is not a pickup stop of this schedule's route`,
);
}
if (lockedIds.has(wagon.id) && scheduleYardOf(plan, wagon) !== move.yardId) {
throw new ConflictException(
`Wagon ${wagon.wagonNumber} already carries cargo booked on this schedule and cannot change yard`,
);
}
plan[wagon.id] = move.yardId;
}
await this.dataSource
.getRepository(TrainSchedule)
.update(scheduleId, { plannedWagonYards: plan });
// ponytail: per-stop over-booking check counts bookings boarding at the
// stop against wagons planned there, ignoring leg sharing — a warning, not
// a gate; switch to CorridorBudget per stop if staff need exact numbers.
const warnings: string[] = [];
for (const stop of stops.slice(0, -1)) {
const planned = wagons.filter((w) => scheduleYardOf(plan, w) === stop.yardId).length;
const booked = (schedule.scheduleBookings ?? [])
.filter((sb) => sb.booking?.originYardId === stop.yardId)
.reduce((sum, sb) => sum + (sb.booking ? this.effectiveWagonsRequired(sb.booking) : 0), 0);
if (booked > planned) {
warnings.push(
`${stop.label}: bookings boarding here need ${booked} wagon(s) but only ${planned} are planned at this yard`,
);
}
}
return { ...(await this.getScheduleWagonYards(scheduleId)), warnings };
}
private async getSchedulableRoute(routeId: string) {
const route = await this.dataSource.getRepository(Route).findOne({
where: { id: routeId },

View File

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

View File

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