mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
[200~feat: add CustomsPaymentsCard and PaymentsTab components for handling customs payments and payment summaries
This commit is contained in:
@@ -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',
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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.`;
|
||||
|
||||
@@ -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()];
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -376,12 +376,21 @@ export class ContractPricingService {
|
||||
// own container-type rate), bulk contracts freeze the route's bulk fee.
|
||||
// A customs contract may not proceed without the fee(s) configured.
|
||||
if (contract.customsClearingEnabled) {
|
||||
// An Ethiopian-side-only customs service prices off its own rate; the
|
||||
// snapshot codes carry the same prefix so booking pricing finds them.
|
||||
const customsType = contract.serviceType?.includesEthiopianCustomsOnly
|
||||
? 'ETHIOPIAN_CUSTOMS_CLEARANCE'
|
||||
: 'CUSTOMS_CLEARANCE';
|
||||
const customsLabel =
|
||||
customsType === 'ETHIOPIAN_CUSTOMS_CLEARANCE'
|
||||
? 'Ethiopian customs clearance service'
|
||||
: 'Customs clearance service';
|
||||
// Strict, no route-less fallback.
|
||||
// ponytail: multi-route contracts bill the first lane's fee; per-lane fees need per-route snapshots.
|
||||
const onLeg = route
|
||||
? liveRates.filter(
|
||||
(r) =>
|
||||
r.rateType === 'CUSTOMS_CLEARANCE' &&
|
||||
r.rateType === customsType &&
|
||||
r.currency === 'USD' &&
|
||||
r.tradeDirection === contract.tradeDirection &&
|
||||
r.originYardId === route.originYardId &&
|
||||
@@ -406,14 +415,14 @@ export class ContractPricingService {
|
||||
);
|
||||
if (!rate || Number(rate.rateValue) <= 0) {
|
||||
throw new UnprocessableEntityException(
|
||||
`No customs clearance service fee is configured for ${size} containers on this direction and route. Ask the rates team to set a live CUSTOMS_CLEARANCE rate for this container type and origin → destination.`,
|
||||
`No customs clearance service fee is configured for ${size} containers on this direction and route. Ask the rates team to set a live ${customsType} rate for this container type and origin → destination.`,
|
||||
);
|
||||
}
|
||||
lineItems.push({
|
||||
// Distinct code per size so the frozen snapshots don't collide —
|
||||
// booking pricing looks each size up by CUSTOMS_CLEARANCE_<FT>FT.
|
||||
code: `CUSTOMS_CLEARANCE_${sizeFt}FT`,
|
||||
label: `Customs clearance service (${size})`,
|
||||
// booking pricing looks each size up by <customsType>_<FT>FT.
|
||||
code: `${customsType}_${sizeFt}FT`,
|
||||
label: `${customsLabel} (${size})`,
|
||||
unit: toContractUnit(rate.rateUnit),
|
||||
unitPrice: convert(Number(rate.rateValue)),
|
||||
containerSize: size,
|
||||
@@ -432,12 +441,12 @@ export class ContractPricingService {
|
||||
: undefined) ?? onLeg.find((r) => !r.containerTypeId && !r.cargoTypeId);
|
||||
if (!rate || Number(rate.rateValue) <= 0) {
|
||||
throw new UnprocessableEntityException(
|
||||
'No bulk customs clearance service fee is configured for this cargo type on this direction and route. Ask the rates team to set a live bulk CUSTOMS_CLEARANCE rate for this commodity and origin → destination.',
|
||||
`No bulk customs clearance service fee is configured for this cargo type on this direction and route. Ask the rates team to set a live bulk ${customsType} rate for this commodity and origin → destination.`,
|
||||
);
|
||||
}
|
||||
lineItems.push({
|
||||
code: 'CUSTOMS_CLEARANCE',
|
||||
label: `Customs clearance service (${scope?.cargoType?.cargoTypeName ?? 'bulk'})`,
|
||||
code: customsType,
|
||||
label: `${customsLabel} (${scope?.cargoType?.cargoTypeName ?? 'bulk'})`,
|
||||
unit: toContractUnit(rate.rateUnit),
|
||||
unitPrice: convert(Number(rate.rateValue)),
|
||||
cargoTypeCode: scope?.cargoType?.code ?? null,
|
||||
|
||||
@@ -124,11 +124,14 @@ export class ContractsService {
|
||||
return `CTR-${year}-${String(seq + 1).padStart(5, '0')}`;
|
||||
}
|
||||
|
||||
/** The service type a contract is sold under (null when the id is unknown). */
|
||||
private resolveServiceType(serviceTypeId: string): Promise<ServiceType | null> {
|
||||
return this.dataSource.getRepository(ServiceType).findOne({ where: { id: serviceTypeId } });
|
||||
}
|
||||
|
||||
/** Whether a service type bundles customs clearance. */
|
||||
private async resolveIncludesCustoms(serviceTypeId: string): Promise<boolean> {
|
||||
const serviceType = await this.dataSource
|
||||
.getRepository(ServiceType)
|
||||
.findOne({ where: { id: serviceTypeId } });
|
||||
const serviceType = await this.resolveServiceType(serviceTypeId);
|
||||
return serviceType?.includesCustoms ?? false;
|
||||
}
|
||||
|
||||
@@ -324,7 +327,8 @@ export class ContractsService {
|
||||
}
|
||||
|
||||
// Customs clearing is owned by the service type, not the customer.
|
||||
const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId);
|
||||
const serviceType = await this.resolveServiceType(dto.serviceTypeId);
|
||||
const includesCustoms = serviceType?.includesCustoms ?? false;
|
||||
// Intercity never crosses a border, so a customs-including service type is
|
||||
// a contradiction — the wizard hides them, the API enforces it.
|
||||
if (dto.tradeDirection === 'DOMESTIC' && includesCustoms) {
|
||||
@@ -344,6 +348,8 @@ export class ContractsService {
|
||||
freightType: dto.freightType,
|
||||
paymentCurrency: 'USD',
|
||||
customsClearingEnabled: includesCustoms,
|
||||
// Decides which customs fee the probe looks up (Ethiopian-only vs full).
|
||||
serviceType,
|
||||
isHazardous: dto.isHazardous ?? false,
|
||||
isReefer: dto.isReefer ?? false,
|
||||
equipmentReturn: dto.equipmentReturn ?? null,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -16,6 +16,7 @@ describe('deriveRateType — surcharge triggers', () => {
|
||||
['DEMURRAGE', 'DEMURRAGE'],
|
||||
['PIL_EXTRA_FEE', 'PIL_EXTRA_FEE'],
|
||||
['CUSTOMS_CLEARANCE', 'CUSTOMS_CLEARANCE'],
|
||||
['ETHIOPIAN_CUSTOMS_CLEARANCE', 'ETHIOPIAN_CUSTOMS_CLEARANCE'],
|
||||
] as const)('maps trigger %s to %s', (trigger, expected) => {
|
||||
expect(deriveRateType({ appliesTo: 'OTHER', trigger })).toBe(expected);
|
||||
});
|
||||
|
||||
@@ -46,6 +46,8 @@ export function deriveRateType(input: {
|
||||
return 'PIL_EXTRA_FEE';
|
||||
case 'CUSTOMS_CLEARANCE':
|
||||
return 'CUSTOMS_CLEARANCE';
|
||||
case 'ETHIOPIAN_CUSTOMS_CLEARANCE':
|
||||
return 'ETHIOPIAN_CUSTOMS_CLEARANCE';
|
||||
case 'FUEL':
|
||||
return 'FUEL_SURCHARGE';
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export const isBulkQuantityUnit = (unit: string): boolean =>
|
||||
export function allowedRateUnits(input: {
|
||||
appliesTo: RateAppliesTo;
|
||||
trigger: RateTrigger;
|
||||
/** CUSTOMS_CLEARANCE / CANCELLATION only: which cargo kind the fee covers. */
|
||||
/** Customs clearance / CANCELLATION only: which cargo kind the fee covers. */
|
||||
cargoKind?: 'CONTAINER' | 'BULK' | null;
|
||||
/** Unit of measure of the bulk commodity the rate is scoped to, when any. */
|
||||
cargoUnitOfMeasure?: CargoUom;
|
||||
@@ -67,6 +67,7 @@ function unitsForShape(input: {
|
||||
// per wagon is the only unit the wagon-cancel flow can apply.
|
||||
return ['PER_WAGON'];
|
||||
case 'CUSTOMS_CLEARANCE':
|
||||
case 'ETHIOPIAN_CUSTOMS_CLEARANCE':
|
||||
// Sold per cargo kind: container fees bill per box or per wagon, bulk
|
||||
// fees per ton or per wagon. Billed on the booking invoice.
|
||||
return input.cargoKind === 'BULK'
|
||||
|
||||
@@ -25,6 +25,7 @@ export const RATE_TYPES = [
|
||||
'RETURN_SURCHARGE',
|
||||
'PIL_EXTRA_FEE',
|
||||
'CUSTOMS_CLEARANCE',
|
||||
'ETHIOPIAN_CUSTOMS_CLEARANCE',
|
||||
'FUEL_SURCHARGE',
|
||||
] as const;
|
||||
|
||||
@@ -96,12 +97,22 @@ export const RATE_TRIGGERS = [
|
||||
// Customs clearance service fee — billed up front via a clearance invoice,
|
||||
// never auto-applied to booking pricing (matchesTrigger returns false).
|
||||
'CUSTOMS_CLEARANCE',
|
||||
// Same shape as CUSTOMS_CLEARANCE; priced instead of it when the booking's
|
||||
// service type has includesEthiopianCustomsOnly (Ethiopian-side clearance).
|
||||
'ETHIOPIAN_CUSTOMS_CLEARANCE',
|
||||
// Fuel surcharge — fires when the booking's cargo type has hasFuel = true,
|
||||
// billed off the lane-scoped rate (direction + route + cargo type).
|
||||
'FUEL',
|
||||
] as const;
|
||||
export type RateTrigger = typeof RATE_TRIGGERS[number];
|
||||
|
||||
/**
|
||||
* The two customs clearance service fees share one rate shape (per direction +
|
||||
* route + cargo kind); only which one a booking prices off differs.
|
||||
*/
|
||||
export const isCustomsClearanceTrigger = (trigger: string): boolean =>
|
||||
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'ETHIOPIAN_CUSTOMS_CLEARANCE';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'rates' })
|
||||
@Index(['rateType'])
|
||||
@Index(['status'])
|
||||
@@ -117,7 +128,7 @@ export class Rate extends BaseEntity {
|
||||
@Column({ name: 'applies_to', type: 'varchar', length: 20, default: 'OTHER' })
|
||||
appliesTo!: RateAppliesTo;
|
||||
|
||||
@Column({ name: 'trigger', type: 'varchar', length: 20, default: 'ALWAYS' })
|
||||
@Column({ name: 'trigger', type: 'varchar', length: 30, default: 'ALWAYS' })
|
||||
trigger!: RateTrigger;
|
||||
|
||||
@Column({ name: 'container_type_id', type: 'uuid', nullable: true })
|
||||
|
||||
@@ -27,6 +27,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;
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { ShippingLineCompaniesService } from '../../shipping-lines/shipping-line
|
||||
import { CreateRateDto } from '../dto/create-rate.dto';
|
||||
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { UpdateRateDto } from '../dto/update-rate.dto';
|
||||
import { Rate } from '../entities/rate.entity';
|
||||
import { Rate, isCustomsClearanceTrigger } from '../entities/rate.entity';
|
||||
import { deriveRateType } from '../entities/rate-type.util';
|
||||
import { CargoUom, allowedRateUnits, isRateUnitAllowed } from '../entities/rate-unit.util';
|
||||
import {
|
||||
@@ -29,10 +29,15 @@ const BASE_FREIGHT_CATEGORIES: readonly Rate['appliesTo'][] = ['BULK', 'CONTAINE
|
||||
* Surcharges sold per cargo kind: the admin says container or bulk, a
|
||||
* container fee then names its container type and a bulk fee its commodity.
|
||||
*/
|
||||
const CARGO_KIND_TRIGGERS: readonly Rate['trigger'][] = ['CUSTOMS_CLEARANCE', 'CANCELLATION'];
|
||||
const CARGO_KIND_TRIGGERS: readonly Rate['trigger'][] = [
|
||||
'CUSTOMS_CLEARANCE',
|
||||
'ETHIOPIAN_CUSTOMS_CLEARANCE',
|
||||
'CANCELLATION',
|
||||
];
|
||||
/** Surcharges that keep a trade direction (everything else is direction-agnostic). */
|
||||
const DIRECTED_SURCHARGE_TRIGGERS: readonly Rate['trigger'][] = [
|
||||
'CUSTOMS_CLEARANCE',
|
||||
'ETHIOPIAN_CUSTOMS_CLEARANCE',
|
||||
'CANCELLATION',
|
||||
'WITH_RETURN',
|
||||
'LASHING',
|
||||
@@ -144,7 +149,7 @@ export class RatesService {
|
||||
private isRouteScoped(appliesTo: Rate['appliesTo'], trigger: Rate['trigger']): boolean {
|
||||
return (
|
||||
this.isBaseFreight(appliesTo, trigger) ||
|
||||
trigger === 'CUSTOMS_CLEARANCE' ||
|
||||
isCustomsClearanceTrigger(trigger) ||
|
||||
trigger === 'WITH_RETURN' ||
|
||||
trigger === 'FUEL'
|
||||
);
|
||||
@@ -261,7 +266,7 @@ export class RatesService {
|
||||
}): void {
|
||||
const { appliesTo, trigger, tradeDirection, intercityKind, cargoKind } = input;
|
||||
const { containerTypeId, cargoTypeId } = input;
|
||||
if (trigger === 'CUSTOMS_CLEARANCE' || trigger === 'CANCELLATION') {
|
||||
if (isCustomsClearanceTrigger(trigger) || trigger === 'CANCELLATION') {
|
||||
// Both fees are sold per direction + cargo kind + type: customs clearance
|
||||
// per lane, the wagon cancellation fee per direction only.
|
||||
const fee = trigger === 'CANCELLATION' ? 'cancellation fee' : 'customs clearance';
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { generateCode } from '../../../common/utils/generate-code.util';
|
||||
import { CreateServiceTypeDto } from '../dto/create-service-type.dto';
|
||||
import { ListServiceTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
@@ -43,6 +49,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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
defaultPlannedWagonYards,
|
||||
misalignedWagons,
|
||||
scheduleYardOf,
|
||||
} from './planned-wagon-yards.util';
|
||||
|
||||
const w = (id: string, currentYardId: string | null) => ({ id, currentYardId });
|
||||
|
||||
describe('planned-wagon-yards.util', () => {
|
||||
it('scheduleYardOf prefers the plan and falls back to the physical yard', () => {
|
||||
expect(scheduleYardOf({ w1: 'B' }, w('w1', 'C'))).toBe('B');
|
||||
expect(scheduleYardOf({ w1: 'B' }, w('w2', 'C'))).toBe('C');
|
||||
expect(scheduleYardOf(null, w('w2', null))).toBeNull();
|
||||
});
|
||||
|
||||
it('defaultPlannedWagonYards snapshots on-route yards and rehomes the rest to origin', () => {
|
||||
const { plan, rehomed } = defaultPlannedWagonYards(
|
||||
[w('a', 'A'), w('b', 'B'), w('x', 'X'), w('n', null)],
|
||||
new Set(['A', 'B', 'C']),
|
||||
'A',
|
||||
);
|
||||
expect(plan).toEqual({ a: 'A', b: 'B', x: 'A', n: 'A' });
|
||||
expect(rehomed.map((r) => r.id)).toEqual(['x', 'n']);
|
||||
});
|
||||
|
||||
it('misalignedWagons lists only planned wagons standing elsewhere', () => {
|
||||
const out = misalignedWagons({ a: 'A', b: 'B' }, [w('a', 'A'), w('b', 'C'), w('z', 'Z')]);
|
||||
expect(out.map((r) => r.id)).toEqual(['b']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Per-schedule wagon yard plan: `{ wagonId: yardId }` — where THIS departure
|
||||
* boards each consist wagon, independent of where the steel physically stands
|
||||
* (`wagons.current_yard_id`, one fact shared by every schedule of the train).
|
||||
*/
|
||||
export type PlannedWagonYards = Record<string, string>;
|
||||
|
||||
type YardedWagon = { id: string; currentYardId: string | null };
|
||||
|
||||
/** Yard a schedule boards a wagon from: its own plan first, the physical yard otherwise. */
|
||||
export function scheduleYardOf(
|
||||
plan: PlannedWagonYards | null | undefined,
|
||||
wagon: YardedWagon,
|
||||
): string | null {
|
||||
return plan?.[wagon.id] ?? wagon.currentYardId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default plan when a schedule is created from a built train: snapshot every
|
||||
* wagon's physical yard (so later physical moves never shift this departure's
|
||||
* capacity); a wagon standing off the route's pickup stops — or nowhere — is
|
||||
* planned at the origin instead, and reported back so staff can redistribute.
|
||||
*/
|
||||
export function defaultPlannedWagonYards(
|
||||
wagons: readonly YardedWagon[],
|
||||
pickupYardIds: ReadonlySet<string>,
|
||||
originYardId: string,
|
||||
): { plan: PlannedWagonYards; rehomed: YardedWagon[] } {
|
||||
const plan: PlannedWagonYards = {};
|
||||
const rehomed: YardedWagon[] = [];
|
||||
for (const wagon of wagons) {
|
||||
if (wagon.currentYardId && pickupYardIds.has(wagon.currentYardId)) {
|
||||
plan[wagon.id] = wagon.currentYardId;
|
||||
} else {
|
||||
plan[wagon.id] = originYardId;
|
||||
rehomed.push(wagon);
|
||||
}
|
||||
}
|
||||
return { plan, rehomed };
|
||||
}
|
||||
|
||||
/** Wagons whose planned yard disagrees with where they physically stand. */
|
||||
export function misalignedWagons<T extends YardedWagon>(
|
||||
plan: PlannedWagonYards | null | undefined,
|
||||
wagons: readonly T[],
|
||||
): T[] {
|
||||
return wagons.filter((w) => {
|
||||
const planned = plan?.[w.id];
|
||||
return planned != null && planned !== w.currentYardId;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user