diff --git a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts index 2f1991df2..8990e1b43 100644 --- a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts @@ -67,6 +67,7 @@ const TRIGGER_ROUTE_LABELS: Partial> = { 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', }; diff --git a/apps/edr-freight-api/src/migrations/3620000000000-SchedulePlannedWagonYards.ts b/apps/edr-freight-api/src/migrations/3620000000000-SchedulePlannedWagonYards.ts new file mode 100644 index 000000000..9313c22a5 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3620000000000-SchedulePlannedWagonYards.ts @@ -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 { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS planned_wagon_yards jsonb + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS planned_wagon_yards + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3630000000000-ClearanceChargeCustomerDecision.ts b/apps/edr-freight-api/src/migrations/3630000000000-ClearanceChargeCustomerDecision.ts new file mode 100644 index 000000000..a9cb259cd --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3630000000000-ClearanceChargeCustomerDecision.ts @@ -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 { + 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 { + 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" + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3640000000000-EthiopianCustomsClearance.ts b/apps/edr-freight-api/src/migrations/3640000000000-EthiopianCustomsClearance.ts new file mode 100644 index 000000000..50ceda3ca --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3640000000000-EthiopianCustomsClearance.ts @@ -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 { + 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 { + 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`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-clearance-charge.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-clearance-charge.service.ts index 3be119470..a1bceb844 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-clearance-charge.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-clearance-charge.service.ts @@ -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 = { MISCELLANEOUS: 'Miscellaneous charges', }; +/** Statuses the customer sees — drafts (DOC_UPLOADED / BILLED) stay GL-internal. */ +export const CUSTOMER_VISIBLE_CHARGE_STATUSES: ReadonlySet = + 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 { + return (await this.list(bookingId)).filter((c) => + CUSTOMER_VISIBLE_CHARGE_STATUSES.has(c.status), + ); + } + + private async findCharge( + bookingId: string, + chargeId: string, + ): Promise { + 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 { - 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 { - 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 { + 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 { + 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 { 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, }, }); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-clearance-charge.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-clearance-charge.spec.ts new file mode 100644 index 000000000..ac1a5a225 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-clearance-charge.spec.ts @@ -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']); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts index f457e782a..ad0e62ef3 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts @@ -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.`; diff --git a/apps/edr-freight-api/src/modules/bookings/booking-payables.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-payables.service.ts new file mode 100644 index 000000000..e815fe929 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-payables.service.ts @@ -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 { + 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(); + 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()]; + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts index 75a179b5c..99fc81c9a 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts @@ -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()); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index f7dc40a82..5e8741df2 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index e122a8800..6b1d65df8 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -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 { AdditionalChargeService } from './additional-charge.service'; import { CancelAdditionalChargeDto, CreateAdditionalChargeDto } from './dto/additional-charge.dto'; import { BookingContractService } from './booking-contract.service'; @@ -177,6 +181,7 @@ export class BookingsController { private readonly wagonCancellationService: BookingWagonCancellationService, private readonly consolidationApprovalService: ConsolidationApprovalService, private readonly clearanceChargeService: BookingClearanceChargeService, + private readonly bookingPayablesService: BookingPayablesService, private readonly clearanceEventService: ClearanceEventService, private readonly additionalChargeService: AdditionalChargeService, ) {} @@ -315,6 +320,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({ @@ -1121,15 +1141,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") @@ -1156,7 +1224,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, @@ -1176,7 +1244,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, @@ -1196,7 +1264,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, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 97128c428..e119af52e 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -42,6 +42,7 @@ import { AdditionalCharge } from './entities/additional-charge.entity'; import { AdditionalChargeRepository } from './additional-charge.repository'; import { AdditionalChargeService } from './additional-charge.service'; import { BookingClearanceChargeService } from './booking-clearance-charge.service'; +import { BookingPayablesService } from './booking-payables.service'; import { BookingClearanceEvent } from './entities/booking-clearance-event.entity'; import { ClearanceEventService } from './clearance-event.service'; import { BookingContainer } from './entities/booking-container.entity'; @@ -122,6 +123,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; BookingContractService, BookingInvoiceService, BookingClearanceChargeService, + BookingPayablesService, ClearanceEventService, AdditionalChargeRepository, AdditionalChargeService, diff --git a/apps/edr-freight-api/src/modules/bookings/dto/clearance-charge.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/clearance-charge.dto.ts index f2bf15c0e..214236c8f 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/clearance-charge.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/clearance-charge.dto.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-clearance-charge.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-clearance-charge.entity.ts index 31ae26203..1cddc7a87 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking-clearance-charge.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-clearance-charge.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts index af0a149d7..0af09a5f8 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts @@ -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. - code: `CUSTOMS_CLEARANCE_${sizeFt}FT`, - label: `Customs clearance service (${size})`, + // booking pricing looks each size up by _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, diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index 3411414e9..bfbc5675f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -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 { + return this.dataSource.getRepository(ServiceType).findOne({ where: { id: serviceTypeId } }); + } + /** Whether a service type bundles customs clearance. */ private async resolveIncludesCustoms(serviceTypeId: string): Promise { - 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, diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts index a8e030bba..9904e67fd 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts @@ -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() diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.spec.ts index 480b2c484..5902453a0 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.spec.ts @@ -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); }); diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts index 536e35527..894080e81 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts @@ -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'; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts index 5adf225ca..ee64768c2 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts @@ -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' diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts index c60d03d31..cc57e4c65 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts @@ -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 }) diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/service-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/service-type.entity.ts index b882f1a08..217cb6dea 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/service-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/service-type.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index 977a31718..6677fcce5 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -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'; diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/service-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/service-types.service.ts index 7592a6f76..1353b4d28 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/service-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/service-types.service.ts @@ -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 { - 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 { await this.findById(id); diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts index 393378740..b3cb3e821 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts @@ -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 | 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; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts index 54056404a..834b3d174 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts @@ -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({ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-wagon-yards.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-wagon-yards.dto.ts new file mode 100644 index 000000000..fa4b5e4c7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-wagon-yards.dto.ts @@ -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[]; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index 1587e2808..3c6080c36 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -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); @@ -5295,15 +5306,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 { + 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> { - 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(); @@ -5311,11 +5337,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(); const consistIsSplit = consistYards.size > 1; @@ -5327,7 +5356,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 @@ -5498,6 +5527,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, @@ -5507,6 +5537,7 @@ export class TrainSchedulingService { builtTrainId, pinnedToScheduleIds, stops, + plannedYards, ); if (unpinnable.length) { throw new BadRequestException({ @@ -5528,6 +5559,7 @@ export class TrainSchedulingService { builtTrainId, pinnedToScheduleIds, reverseWagonOrder, + plannedYards, ); if (!physical) continue; @@ -5577,6 +5609,7 @@ export class TrainSchedulingService { builtTrainId, pinnedToScheduleIds, stops, + targetSchedule?.plannedWagonYards ?? {}, ); } @@ -5609,6 +5642,7 @@ export class TrainSchedulingService { builtTrainId: string | null = null, pinnedToScheduleIds: Set = new Set(), stops: string[] = [], + plannedYards: PlannedWagonYards = {}, ): string[] { const violations: string[] = []; // One physical wagon may serve several slots whose leg spans don't overlap @@ -5627,6 +5661,8 @@ export class TrainSchedulingService { span, builtTrainId, pinnedToScheduleIds, + false, + plannedYards, ); if (!physical) { violations.push( @@ -5657,6 +5693,7 @@ export class TrainSchedulingService { builtTrainId: string | null = null, pinnedToScheduleIds: Set = 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. @@ -5689,8 +5726,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. @@ -5703,7 +5740,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) { @@ -5843,7 +5880,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))), @@ -5865,11 +5902,17 @@ export class TrainSchedulingService { return { mode: 'YARD', remainingByTypeId, codesByTypeId }; } - private async builtTrainStock(builtTrainId: string): Promise { - const wagons = await this.dataSource.getRepository(Wagon).find({ - where: { trainId: builtTrainId }, - relations: { wagonType: true }, - }); + private async builtTrainStock( + builtTrainId: string, + scheduleId?: string, + ): Promise { + const [wagons, plan] = await Promise.all([ + this.dataSource.getRepository(Wagon).find({ + where: { trainId: builtTrainId }, + relations: { wagonType: true }, + }), + this.plannedWagonYardsOf(scheduleId), + ]); const remainingByTypeId = new Map(); const codesByTypeId = new Map(); const byYardId = new Map>(); @@ -5879,10 +5922,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(); + // 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(); 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 @@ -6222,17 +6267,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 { 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) @@ -6243,23 +6293,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 }, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/utils/planned-wagon-yards.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/planned-wagon-yards.util.spec.ts new file mode 100644 index 000000000..18c5ead25 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/planned-wagon-yards.util.spec.ts @@ -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']); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/utils/planned-wagon-yards.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/planned-wagon-yards.util.ts new file mode 100644 index 000000000..ed914ece3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/planned-wagon-yards.util.ts @@ -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; + +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, + 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( + plan: PlannedWagonYards | null | undefined, + wagons: readonly T[], +): T[] { + return wagons.filter((w) => { + const planned = plan?.[w.id]; + return planned != null && planned !== w.currentYardId; + }); +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceChargesTab.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceChargesTab.tsx index f8725b03a..4a23ab8e7 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceChargesTab.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceChargesTab.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { + Alert, Badge, Box, Button, @@ -12,6 +13,7 @@ import { Select, Stack, Text, + TextInput, Tooltip, } from "@mantine/core"; import { @@ -19,9 +21,11 @@ import { Download, Eye, FileText, + Lock, Receipt, Send, Upload, + XCircle, } from "lucide-react"; import toast from "react-hot-toast"; import type { Freight } from "@edr/types"; @@ -42,11 +46,19 @@ const STATUS_META: Record< { label: string; color: string } > = { DOC_UPLOADED: { label: "Awaiting billing", color: "yellow" }, - BILLED: { label: "Ready to send", color: "blue" }, - SENT: { label: "Sent — unpaid", color: "orange" }, + BILLED: { label: "Draft — not sent", color: "blue" }, + SENT: { label: "Awaiting customer approval", color: "orange" }, + REJECTED: { label: "Rejected by customer", color: "red" }, + ACCEPTED: { label: "Accepted — invoice unpaid", color: "teal" }, PAID: { label: "Paid", color: "edr-green" }, }; +/** Once the customer accepts, the invoice exists and GL can no longer edit. */ +const isLocked = (s: Freight.ClearanceChargeStatus) => + s === "ACCEPTED" || s === "PAID"; + +type BillInput = { amount: number; currency: string; description: string }; + export interface ClearanceChargesTabProps { bookingId: string; /** DJ uploads the port document; ET bills, sends and creates miscellaneous. */ @@ -55,11 +67,12 @@ export interface ClearanceChargesTabProps { } /** - * Post-finalization charges billed to the customer, two levels: port charges - * (document from GL Djibouti, billed by GL Ethiopia) then miscellaneous - * (created whole by GL Ethiopia once the port charge is paid). Each level - * issues its own payable invoice — ETB settles through the portal gateway - * (CBE), other currencies through Finance's manual settlement. + * Post-finalization charges billed to the customer: port charges (document + * from GL Djibouti, priced by GL Ethiopia) and any number of miscellaneous + * charges. GL prices + describes a charge and sends it; the customer accepts + * (invoice issued, charge locked) or rejects with a note (GL revises and + * re-sends). ETB settles through the portal gateway (CBE), other currencies + * through Finance's manual settlement. */ export function ClearanceChargesTab({ bookingId, @@ -89,7 +102,7 @@ export function ClearanceChargesTab({ onError, }); const bill = useMutation({ - mutationFn: (p: { chargeId: string; amount: number; currency: string }) => + mutationFn: (p: BillInput & { chargeId: string }) => bookingsService.billClearanceCharge(bookingId, p.chargeId, p), onSuccess: (next) => { toast.success("Charge amount saved"); @@ -101,13 +114,13 @@ export function ClearanceChargesTab({ mutationFn: (chargeId: string) => bookingsService.sendClearanceCharge(bookingId, chargeId), onSuccess: (next) => { - toast.success("Invoice sent to the customer"); + toast.success("Sent to the customer for approval"); refresh(next); }, onError, }); const createMisc = useMutation({ - mutationFn: (p: { file: File; amount: number; currency: string }) => + mutationFn: (p: BillInput & { file: File }) => bookingsService.createMiscellaneousCharge(bookingId, p.file, p), onSuccess: (next) => { toast.success("Miscellaneous charge created"); @@ -153,9 +166,7 @@ export function ClearanceChargesTab({ : "Waiting for GL Djibouti to upload the port-charges document." } onViewFile={onViewFile} - onBill={(amount, currency) => - port && bill.mutate({ chargeId: port.id, amount, currency }) - } + onBill={(input) => port && bill.mutate({ chargeId: port.id, ...input })} onSend={() => port && send.mutate(port.id)} djUpload={ roleMode === "DJ" && (!port || port.status === "DOC_UPLOADED") ? ( @@ -196,9 +207,7 @@ export function ClearanceChargesTab({ busy={busy} emptyHint="" onViewFile={onViewFile} - onBill={(amount, currency) => - bill.mutate({ chargeId: c.id, amount, currency }) - } + onBill={(input) => bill.mutate({ chargeId: c.id, ...input })} onSend={() => send.mutate(c.id)} /> ))} @@ -211,15 +220,13 @@ export function ClearanceChargesTab({ : "Add a miscellaneous charge"} - Upload the supporting document and set the amount. You can raise as - many as the shipment needs, before or after the port charge. + Upload the supporting document, set the amount and say what it is + for. The customer sees it once you send it for approval. - createMisc.mutate({ file, amount, currency }) - } + onCreate={(file, input) => createMisc.mutate({ file, ...input })} /> )} @@ -265,7 +272,7 @@ function ChargeCard({ busy: boolean; emptyHint: string; onViewFile: (file: { name: string; url: string }) => void; - onBill: (amount: number, currency: string) => void; + onBill: (input: BillInput) => void; onSend: () => void; djUpload?: React.ReactNode; etCreate?: React.ReactNode; @@ -273,13 +280,17 @@ function ChargeCard({ const [editing, setEditing] = useState(false); const [amount, setAmount] = useState(charge?.amount ?? ""); const [currency, setCurrency] = useState(charge?.currency ?? "ETB"); + const [description, setDescription] = useState(charge?.description ?? ""); const status = charge?.status ?? null; const meta = status ? STATUS_META[status] : null; - // ET enters/revises the amount while the charge is unpaid. + const locked = status != null && isLocked(status); + const needsDescription = charge?.type === "MISCELLANEOUS"; + // ET enters/revises the price until the customer accepts it. const showBillForm = roleMode === "ET" && charge != null && + !locked && (charge.status === "DOC_UPLOADED" || editing); return ( @@ -304,6 +315,12 @@ function ChargeCard({ {formatDateTime(charge.billedAt)} )} + {charge?.status === "ACCEPTED" && charge.customerDecidedAt && ( + + Accepted by the customer · {formatDateTime(charge.customerDecidedAt)} + {charge.invoiceNumber ? ` (invoice ${charge.invoiceNumber})` : ""} + + )} {charge?.paidAt && ( Paid · {formatDateTime(charge.paidAt)} @@ -379,6 +396,32 @@ function ChargeCard({ )} + {charge?.description && !showBillForm && ( + + {charge.description} + + )} + + {charge?.status === "REJECTED" && charge.customerNote && ( + } + title="Rejected by the customer" + > + {charge.customerNote} + {charge.customerDecidedAt && ( + + {formatDateTime(charge.customerDecidedAt)} — fix the price or + description and send it again. + + )} + + )} + {!charge && ( {emptyHint} @@ -389,6 +432,15 @@ function ChargeCard({ {showBillForm && ( + setDescription(e.currentTarget.value)} + maxLength={1000} + w={320} + /> 0)} + disabled={ + busy || + !(Number(amount) > 0) || + (needsDescription && !description.trim()) + } onClick={() => { - onBill(Number(amount), currency); + onBill({ + amount: Number(amount), + currency, + description: description.trim(), + }); setEditing(false); }} > - Save amount + Save {editing && ( - {charge.status === "BILLED" && ( - + {(charge.status === "BILLED" || charge.status === "REJECTED") && ( + )} - {charge.status === "SENT" && charge.invoiceNumber && ( - - Invoice {charge.invoiceNumber} - - )} + + )} + {charge?.status === "ACCEPTED" && ( + + + + Locked — invoice {charge.invoiceNumber ?? ""} awaiting payment + )} {charge?.status === "PAID" && ( @@ -489,14 +555,25 @@ function MiscCreateForm({ onCreate, }: { busy: boolean; - onCreate: (file: File, amount: number, currency: string) => void; + onCreate: (file: File, input: BillInput) => void; }) { const [file, setFile] = useState(null); const [amount, setAmount] = useState(""); const [currency, setCurrency] = useState("ETB"); + const [description, setDescription] = useState(""); return ( + setDescription(e.currentTarget.value)} + maxLength={1000} + w={320} + /> {(props) => ( diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/detail/RequestDetailCards.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/detail/RequestDetailCards.tsx index 1469e06e3..0ecffa278 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/detail/RequestDetailCards.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/detail/RequestDetailCards.tsx @@ -199,7 +199,13 @@ export function RequestServiceTypeCard({ if (lastMile) chips.push({ label: "Last-mile delivery", color: "teal", icon: Warehouse }); if (customs) - chips.push({ label: "Customs clearance (GL)", color: "grape", icon: FileCheck }); + chips.push({ + label: st.includesEthiopianCustomsOnly + ? "Ethiopian customs clearance (GL)" + : "Customs clearance (GL)", + color: "grape", + icon: FileCheck, + }); return ( { + if (isAxiosError(error)) { + const message = error.response?.data?.message; + if (Array.isArray(message)) return message.join(", "); + if (typeof message === "string") return message; + } + return fallback; +}; + +interface Props { + scheduleId: string; + canEdit: boolean; +} + +export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) { + const { toast } = useToast(); + const query = useQuery( + api.trainScheduling.scheduleWagonYards.queryOptions({ input: { scheduleId } }), + ); + const save = useMutation(api.trainScheduling.updateScheduleWagonYards.mutationOptions()); + const data = query.data; + + /** wagonId → yardId queued but not yet saved. */ + const [pending, setPending] = useState>({}); + const [bulkType, setBulkType] = useState(null); + const [bulkFrom, setBulkFrom] = useState(null); + const [bulkTo, setBulkTo] = useState(null); + const [bulkCount, setBulkCount] = useState(1); + + const editable = Boolean(canEdit && data?.editable); + const pickupStops = useMemo(() => (data?.stops ?? []).filter((s) => s.pickup), [data]); + const yardOptions = pickupStops.map((s) => ({ value: s.yardId, label: s.label })); + const yardLabel = (id: string | null) => + (data?.stops ?? []).find((s) => s.yardId === id)?.label ?? + data?.wagons.find((w) => w.plannedYardId === id)?.plannedYardLabel ?? + data?.wagons.find((w) => w.physicalYardId === id)?.physicalYardLabel ?? + id ?? + "—"; + + const effectiveYard = (w: ScheduleWagonYardRow) => pending[w.id] ?? w.plannedYardId; + + const perStop = useMemo( + () => + (data?.stops ?? []).map((s) => ({ + ...s, + planned: (data?.wagons ?? []).filter((w) => (pending[w.id] ?? w.plannedYardId) === s.yardId) + .length, + })), + [data, pending], + ); + const typeOptions = useMemo(() => { + const seen = new Map(); + for (const w of data?.wagons ?? []) seen.set(w.wagonType.id, w.wagonType.code); + return [...seen].map(([value, label]) => ({ value, label })); + }, [data]); + + const pendingCount = Object.keys(pending).length; + + const queueBulk = () => { + if (!data || !bulkFrom || !bulkTo || bulkFrom === bulkTo) return; + const n = Number(bulkCount) || 0; + const picked = data.wagons + .filter( + (w) => + !w.locked && + effectiveYard(w) === bulkFrom && + (!bulkType || w.wagonType.id === bulkType), + ) + .slice(0, n); + if (!picked.length) { + toast({ title: "No free wagons match", variant: "destructive" }); + return; + } + setPending((prev) => { + const next = { ...prev }; + for (const w of picked) { + if (w.plannedYardId === bulkTo) delete next[w.id]; + else next[w.id] = bulkTo; + } + return next; + }); + }; + + const handleSave = async () => { + if (!pendingCount) return; + try { + const result = await save.mutateAsync({ + scheduleId, + payload: { + moves: Object.entries(pending).map(([wagonId, yardId]) => ({ wagonId, yardId })), + }, + }); + setPending({}); + toast({ + title: `Schedule yards updated — ${pendingCount} wagon(s) re-planned`, + description: result.warnings.length ? result.warnings.join(" ") : undefined, + variant: result.warnings.length ? "destructive" : undefined, + }); + } catch (err) { + toast({ + title: "Update failed", + description: parseError(err, "Could not update the schedule's wagon yards"), + variant: "destructive", + }); + } + }; + + if (query.isLoading) return ; + if (query.isError || !data) { + return ( + }> + {parseError( + query.error, + "This schedule has no wagon yard plan (not created from a built train).", + )} + + ); + } + + return ( + + } variant="light"> + Planned = where this departure boards the wagon (what customers can book per + origin). Physical = where the wagon stands now (train builder). Dispatch is blocked + until every wagon stands at its planned yard. + {data.misaligned > 0 ? ( + + {" "} + {data.misaligned} wagon(s) currently misaligned. + + ) : null} + + + + {perStop.map((s) => ( + + + + {s.label} + + {!s.pickup ? ( + + destination + + ) : null} + + + + Planned {s.planned} + + + Physical {s.physical} + + + + ))} + + + {editable ? ( + + + + + + setPending((prev) => { + const next = { ...prev }; + if (!v || v === w.plannedYardId) delete next[w.id]; + else next[w.id] = v; + return next; + }) + } + w={180} + /> + ) : ( + + {yardLabel(planned)} + {w.locked ? ( + + + + ) : null} + + )} + + + {planned === w.physicalYardId ? ( + + Aligned + + ) : ( + + Needs move + + )} + + + ); + })} + + + + {editable ? ( + + + {pendingCount} pending change(s) + + + + + ) : null} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx index 77313f69c..6ec28c27b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx @@ -59,6 +59,7 @@ import { import { DEFAULT_CONFIGURATION_SLUG, DEFAULT_RULES_SLUG, + ROUTE_SCOPED_TRIGGERS, RULE_ENGINE_CATEGORY_BASE_PATH, RULE_ENGINE_SELECT_NONE, getRuleEngineResource, @@ -120,9 +121,7 @@ const yardOptionsForLegEnd = ( // direction + route, so their yard dropdowns narrow exactly like base // freight. (appliesTo === "OTHER" && - ["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes( - String(values.trigger ?? ""), - )) + ROUTE_SCOPED_TRIGGERS.includes(String(values.trigger ?? ""))) ) { const direction = String(values.tradeDirection ?? ""); // Direction is what decides the countries, so offer nothing until it is set diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index cc899322a..c6003d8a4 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -221,6 +221,10 @@ const RATE_TRIGGERS = [ { label: "Cancellation (per wagon, per direction + cargo type)", value: "CANCELLATION" }, { label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" }, { label: "Customs clearance service fee (billed with the booking)", value: "CUSTOMS_CLEARANCE" }, + { + label: "Ethiopian customs clearance service fee (Ethiopian-side-only services)", + value: "ETHIOPIAN_CUSTOMS_CLEARANCE", + }, { label: "Fuel (per lane + cargo type)", value: "FUEL" }, ]; @@ -279,8 +283,16 @@ const SHIPPING_LINE_CARGO_KINDS = [ const isBaseFreightRate = (values: Record) => ["BULK", "CONTAINER", "INTERCITY"].includes(String(values.appliesTo ?? "")); +/** Surcharges sold per origin → destination leg (mirrors RatesService.isRouteScoped). */ +export const ROUTE_SCOPED_TRIGGERS = [ + "CUSTOMS_CLEARANCE", + "ETHIOPIAN_CUSTOMS_CLEARANCE", + "WITH_RETURN", + "FUEL", +]; + /** - * Rates priced per leg: base rail freight, plus the customs clearance fee and + * Rates priced per leg: base rail freight, plus the customs clearance fees and * the empty-container return surcharge (sold per route + container type). */ const isRouteScopedRate = (values: Record) => @@ -289,19 +301,19 @@ const isRouteScopedRate = (values: Record) => (isShippingLineRate(values) ? hasShippingLine(values) && (values.shippingLineRateKind === "BASE" || - ["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes( - String(values.trigger ?? ""), - )) + ROUTE_SCOPED_TRIGGERS.includes(String(values.trigger ?? ""))) : isBaseFreightRate(values)) || (String(values.appliesTo ?? "") === "OTHER" && - ["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(String(values.trigger ?? ""))); + ROUTE_SCOPED_TRIGGERS.includes(String(values.trigger ?? ""))); /** * Surcharges sold per cargo kind: the admin says container or bulk, then names * the container type or bulk commodity the fee covers. */ const isCargoKindTrigger = (values: Record) => - ["CUSTOMS_CLEARANCE", "CANCELLATION"].includes(String(values.trigger ?? "")); + ["CUSTOMS_CLEARANCE", "ETHIOPIAN_CUSTOMS_CLEARANCE", "CANCELLATION"].includes( + String(values.trigger ?? ""), + ); const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value }); @@ -345,6 +357,7 @@ const unitsForShape = ( // Wagon cancellation fee — scales with the cancelled wagons only. return ["PER_WAGON"]; case "CUSTOMS_CLEARANCE": + case "ETHIOPIAN_CUSTOMS_CLEARANCE": // Per cargo kind: container fees per box/wagon, bulk per ton/wagon. return cargoKind === "BULK" ? ["PER_TON", "PER_WAGON"] @@ -876,6 +889,14 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ { name: "includesFirstMile", label: "Includes first mile", type: "boolean" }, { name: "includesLastMile", label: "Includes last mile", type: "boolean" }, { name: "includesCustoms", label: "Includes customs", type: "boolean" }, + { + name: "includesEthiopianCustomsOnly", + label: "Ethiopian customs only", + type: "boolean", + description: + "EDR clears customs on the Ethiopian side only. Same clearance flow; contracts and bookings price off the Ethiopian customs clearance rate instead of the standard one.", + showIf: (v) => v.includesCustoms === true, + }, { name: "isActive", label: "Active", type: "boolean" }, ], }, @@ -1081,6 +1102,11 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ label: "Customs clearance", filters: { trigger: "CUSTOMS_CLEARANCE", isShippingLineRate: "false" }, }, + { + key: "ethiopian-customs", + label: "Ethiopian customs", + filters: { trigger: "ETHIOPIAN_CUSTOMS_CLEARANCE", isShippingLineRate: "false" }, + }, { key: "return", label: "Container return", @@ -1235,6 +1261,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ (String(v.appliesTo ?? "") === "OTHER" && [ "CUSTOMS_CLEARANCE", + "ETHIOPIAN_CUSTOMS_CLEARANCE", "CANCELLATION", "WITH_RETURN", "LASHING", diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index c2a72146d..5d9616cec 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -41,6 +41,7 @@ import { Train, Weight, Workflow as WorkflowIcon, + Warehouse, } from "lucide-react"; import { DateTimePicker } from "@mantine/dates"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -59,6 +60,7 @@ import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPl import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary"; import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel"; import { LegCapacityPanel } from "@/components/trainScheduling/LegCapacityPanel"; +import { ScheduleWagonYardPanel } from "@/components/trainScheduling/ScheduleWagonYardPanel"; import { LegLoadBoardPanel } from "@/components/trainScheduling/LegLoadBoardPanel"; import { LoadEmptyContainersModal } from "@/components/trainScheduling/LoadEmptyContainersModal"; import MergeScheduleTrainModal from "@/components/trainScheduling/MergeScheduleTrainModal"; @@ -1287,6 +1289,9 @@ export default function TrainScheduleV2DetailPage() { }> Leg board + }> + Schedule yards + }> History @@ -1382,6 +1387,15 @@ export default function TrainScheduleV2DetailPage() { /> + + {scheduleId ? ( + + ) : null} + + {scheduleId ? : null} diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index 5d70e26fc..7e9c64cea 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -232,6 +232,9 @@ import { type BuiltTrainListFilters, type BuiltTrainListResponse, type ScheduleConsist, + type ScheduleWagonYards, + type UpdateScheduleWagonYardsPayload, + type UpdateScheduleWagonYardsResult, type ScheduleHistoryEntry, type TrainComposition, type UpdateTrainDetailsPayload, @@ -416,6 +419,30 @@ export const api = { ], ), + scheduleWagonYards: endpoint<{ scheduleId: string }, ScheduleWagonYards>( + "train-scheduling", + "schedule-wagon-yards", + ({ scheduleId }) => + trainBuilderService.scheduleWagonYards(scheduleId).then((r) => r.data), + ({ scheduleId }) => [ + ...QUERY_KEYS.TRAIN_SCHEDULING.ROOT, + "wagon-yards", + scheduleId, + ], + ), + + updateScheduleWagonYards: endpoint< + { scheduleId: string; payload: UpdateScheduleWagonYardsPayload }, + UpdateScheduleWagonYardsResult + >( + "train-scheduling", + "update-schedule-wagon-yards", + ({ scheduleId, payload }) => + trainBuilderService.updateScheduleWagonYards(scheduleId, payload).then((r) => r.data), + undefined, + () => TRAIN_SCHEDULING_INVALIDATIONS, + ), + adjustConsist: endpoint< { scheduleId: string; payload: AdjustConsistPayload }, AdjustConsistResult diff --git a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts index a59e38e1e..1a52d21c4 100644 --- a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts @@ -466,11 +466,11 @@ export const bookingsService = { return unwrap(response.data) as Freight.ClearanceCharge[]; }, - /** GL Ethiopia sets or revises a charge's amount + currency. */ + /** GL Ethiopia sets or revises a charge's amount, currency and description. */ billClearanceCharge: async ( id: string, chargeId: string, - payload: { amount: number; currency: string }, + payload: { amount: number; currency: string; description?: string }, ): Promise => { const response = await client.patch( `/bookings/${id}/clearance/charges/${chargeId}/bill`, @@ -479,7 +479,7 @@ export const bookingsService = { return unwrap(response.data) as Freight.ClearanceCharge[]; }, - /** GL Ethiopia issues the charge's payable invoice to the customer. */ + /** GL Ethiopia sends the priced charge to the customer for approval. */ sendClearanceCharge: async ( id: string, chargeId: string, @@ -490,16 +490,17 @@ export const bookingsService = { return unwrap(response.data) as Freight.ClearanceCharge[]; }, - /** GL Ethiopia creates the miscellaneous charge (document + amount + currency). */ + /** GL Ethiopia creates a miscellaneous charge (document + amount + currency + description). */ createMiscellaneousCharge: async ( id: string, file: File, - payload: { amount: number; currency: string }, + payload: { amount: number; currency: string; description: string }, ): Promise => { const form = new FormData(); form.append("file", file); form.append("amount", String(payload.amount)); form.append("currency", payload.currency); + form.append("description", payload.description); const response = await client.post( `/bookings/${id}/clearance/charges/miscellaneous`, form, diff --git a/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts b/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts index 3254051d3..7f61cd1bd 100644 --- a/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts @@ -300,6 +300,45 @@ export interface ScheduleHistoryEntry { /** Adjust response: fresh consist + schedule-impact warnings to surface. */ export type AdjustConsistResult = ScheduleConsist & { warnings: string[] }; +/** One consist wagon in the schedule-yards tab: where this departure plans it vs where it stands. */ +export interface ScheduleWagonYardRow { + id: string; + wagonNumber: string; + sequenceNumber: number | null; + wagonType: { id: string; code: string; name: string }; + physicalYardId: string | null; + physicalYardLabel: string | null; + plannedYardId: string | null; + plannedYardLabel: string | null; + aligned: boolean; + locked: boolean; + lockReason: string | null; +} + +export interface ScheduleWagonYardStop { + yardId: string; + label: string; + /** Origin or intermediate stop — wagons can board here. The destination cannot. */ + pickup: boolean; + planned: number; + physical: number; +} + +export interface ScheduleWagonYards { + scheduleId: string; + train: { id: string; code: string }; + editable: boolean; + stops: ScheduleWagonYardStop[]; + wagons: ScheduleWagonYardRow[]; + misaligned: number; +} + +export interface UpdateScheduleWagonYardsPayload { + moves: Array<{ wagonId: string; yardId: string }>; +} + +export type UpdateScheduleWagonYardsResult = ScheduleWagonYards & { warnings: string[] }; + export const trainBuilderService = { list: (filters: BuiltTrainListFilters = {}) => apiClient.get(`${BASE}${toQuery(filters)}`), @@ -350,6 +389,15 @@ export const trainBuilderService = { `/train-scheduling/schedules/${scheduleId}/adjust-consist`, payload, ), + /** Schedule-only wagon yard plan (where THIS departure boards each wagon). */ + scheduleWagonYards: (scheduleId: string) => + apiClient.get(`/train-scheduling/schedules/${scheduleId}/wagon-yards`), + /** Re-plan boarding yards for this schedule; physical wagon yards untouched. */ + updateScheduleWagonYards: (scheduleId: string, payload: UpdateScheduleWagonYardsPayload) => + apiClient.patch( + `/train-scheduling/schedules/${scheduleId}/wagon-yards`, + payload, + ), /** Unified wagon/booking change history for the schedule's History tab. */ scheduleHistory: (scheduleId: string) => apiClient.get( diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/BookingRow.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/BookingRow.tsx index 20f148f3d..b399cdf6a 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/BookingRow.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/BookingRow.tsx @@ -2,8 +2,8 @@ import { Box, Group, Stack, Text } from "@mantine/core"; import { memo } from "react"; import { ACTION_PROPS, STATUS_CONFIG, cv } from "../constants"; import { Stepper } from "./Stepper"; -import { PayNowButton } from "@/pages/bookings/payments/PayNowButton"; -import { payWindowState } from "@/pages/bookings/payments/payment-drain"; +import { PayButton } from "@/pages/bookings/payments/PayButton"; +import { useMyPayables } from "@/pages/bookings/payments/useMyPayables"; import { BookingActionButton } from "@/pages/bookings/clearance/BookingActionButton"; import { bookingHasInlineAction } from "@/pages/bookings/clearance/bookingNextAction"; import { @@ -28,21 +28,9 @@ export const BookingRow = memo(function BookingRow({ const Icon = cfg.icon; const AIcon = cfg.action.icon; const ap = ACTION_PROPS[cfg.action.kind]; - // Payable bookings get an inline "Pay now" that opens the payment modal - // instead of navigating to the detail page. A general contract is payable as - // soon as it's FULLY_EXECUTED (signed); a one-time booking only after it's - // SELECTED_FOR_BATCH — same rule as the bookings list's PrimaryAction. - const payableStatus = - booking.bookingType === "GENERAL_CONTRACT" - ? "FULLY_EXECUTED" - : "SELECTED_FOR_BATCH"; - // A fully-closed pay window (deadline + drain both elapsed) has nothing to pay - // against, so the row falls back to its normal action instead of an empty slot. - // The drain itself still routes here — PayNowButton renders the wait notice. - const canPay = - booking.status === payableStatus && - booking.paymentStatus !== "PAID" && - payWindowState(booking).phase !== "closed"; + // Anything outstanding (freight, clearance charge, duty slip, cancellation + // fee) → "Pay" jumps to the booking's Payments tab. One shared query. + const payable = useMyPayables().get(booking.id); // Clearance/operation steps + changes-requested resubmit can be done in place // via a modal on the row. const hasInlineAction = bookingHasInlineAction(booking); @@ -113,8 +101,8 @@ export const BookingRow = memo(function BookingRow({ {cfg.badgeLabel} - {canPay ? ( - + {payable ? ( + ) : canSign ? ( ) : canApproveDelivery ? ( diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingClearanceWorkflowBanner.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingClearanceWorkflowBanner.tsx index ad188a206..35d3f174a 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingClearanceWorkflowBanner.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingClearanceWorkflowBanner.tsx @@ -1,11 +1,11 @@ -import { useState } from "react"; +import { + useState, +} from "react"; import { Alert, Anchor, Badge, - Box, Button, - FileInput, Group, Paper, Stack, @@ -15,19 +15,20 @@ import { import { AlertTriangle, Check, - Download, Eye, FileBadge, MessageSquareWarning, Receipt, - Upload, } from "lucide-react"; -import { useQuery } from "@tanstack/react-query"; +import { + useQuery, +} from "@tanstack/react-query"; import toast from "react-hot-toast"; import type { Freight } from "@edr/types"; -import { bookingsService } from "@/services/bookings.service"; -import { contractsService } from "@/services/contracts.service"; +import { + bookingsService, +} from "@/services/bookings.service"; import { downloadStoredFile } from "@/services/files.service"; import { ClearancePhaseStepper } from "../contracts/ClearancePhaseStepper"; import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel"; @@ -36,29 +37,6 @@ import { GREEN, INK } from "../contracts/contract-ui"; const BORDER = "#E6ECF2"; -// Customer-facing labels for an invoice status (Freight.InvoiceStatus). -const INVOICE_STATUS_LABELS: Record = { - DRAFT: "Draft", - ISSUED: "Issued", - PENDING: "Due", - PAYMENT_PROCESSING: "Payment processing", - PARTIALLY_PAID: "Partially paid", - PAID: "Paid", - OVERDUE: "Overdue", - CANCELLED: "Cancelled", - REFUNDED: "Refunded", - EXPIRED: "Expired", -}; - -function invoiceStatusLabel(status: string): string { - return ( - INVOICE_STATUS_LABELS[status] ?? - status - .replace(/_/g, " ") - .toLowerCase() - .replace(/\b\w/g, (m) => m.toUpperCase()) - ); -} @@ -81,13 +59,9 @@ export function BookingClearanceWorkflowBanner({ if (!isPhased || !clearance) return null; - const dutyPaid = clearance.milestones?.some( - (m) => m.milestoneCode === "DUTY_TAX_PAID" && m.status === "COMPLETED", - ); - const dutyPending = - clearance.dutyRequired && - clearance.dutyAdvice && - !dutyPaid; + // Duty / tax, additional duty and the final invoice are paid from the + // booking's Payments tab (CustomsPaymentsCard); this banner keeps the + // progress, the draft declaration and the documents. // A change request clears the draft while it's open — show the "waiting on // GL" state instead of the review panel until GL sends a corrected draft. const draftDeclarationChangeRequestPending = Boolean( @@ -131,14 +105,6 @@ export function BookingClearanceWorkflowBanner({ /> ) : null} - {dutyPending && clearance.dutyAdvice ? ( - void refetch()} - /> - ) : null} - {clearance.riskLevel ? ( @@ -160,24 +126,6 @@ export function BookingClearanceWorkflowBanner({ ) : null} - {clearance.secondDuty?.advised ? ( - view(f)} - onChanged={() => void refetch()} - /> - ) : null} - - {clearance.finalInvoice ? ( - view(f)} - onChanged={() => void refetch()} - /> - ) : null} - {clearance.operationReady ? ( Clearance is complete. You may proceed to request your operation date. @@ -197,76 +145,6 @@ export function BookingClearanceWorkflowBanner({ ); } -function DutyAdvicePanel({ - dutyAdvice, - bookingId, - onChanged, -}: { - dutyAdvice: NonNullable; - bookingId: string; - onChanged: () => void; -}) { - const [file, setFile] = useState(null); - const [loading, setLoading] = useState(false); - const noticeFile = dutyAdvice.noticeFile; - - return ( - - - - - Amount due:{" "} - - {dutyAdvice.amount.toLocaleString()} {dutyAdvice.currency} - - {dutyAdvice.declarationSerial - ? ` · Payment code: ${dutyAdvice.declarationSerial}` - : null} - - {noticeFile ? ( - void downloadStoredFile(noticeFile.id, noticeFile.name)} - size="sm" - > - - - Download duty notice ({noticeFile.name}) - - - ) : null} - - Pay the amount above, then upload your payment slip so clearance can continue. - - - - - - - ); -} - /** * GL Ethiopia sent a draft customs declaration — an estimated price + files * the customer must accept before the real declaration is filed, or send back @@ -446,328 +324,8 @@ function GroupLabel({ icon: Icon, text }: { icon: typeof Receipt; text: string } ); } -/** - * Post-offload final invoice from GL Djibouti (export): shows the due amount + - * invoice document; the customer pays offline and attaches the payment slip - * here, then GL confirms and the badge flips to PAID. - */ -function FinalInvoiceDueCard({ - invoice, - bookingId, - onView, - onChanged, -}: { - invoice: NonNullable; - bookingId: string; - onView: (file: { name: string; url: string }) => void; - onChanged: () => void; -}) { - const [slip, setSlip] = useState(null); - const [uploading, setUploading] = useState(false); - const [approving, setApproving] = useState(false); - const paid = invoice.status === "PAID"; - // GL Djibouti raises it as a draft: nothing is payable until the customer - // reviews the attached invoice and approves it. - const approved = Boolean(invoice.approvedAt); - - return ( - - - -
- - - - {paid - ? "Final invoice paid" - : approved - ? "Final invoice due" - : "Final invoice — your approval needed"}{" "} - — {invoice.invoiceNumber} - - - {approved - ? invoiceStatusLabel(invoice.status) - : "Awaiting your approval"} - - - - {invoice.totalAmount.toLocaleString()} {invoice.currency} - - {invoice.description ? ( - - {invoice.description} - - ) : null} - {!paid ? ( - - {approved - ? "Pay the amount above and attach your payment slip — Global Logistics will confirm the payment." - : "Review the invoice document from Global Logistics Djibouti and approve it to proceed with payment."} - - ) : null} -
- - - {invoice.invoiceFile ? ( - - ) : null} - {invoice.slipFile ? ( - - ) : null} - {!paid && !approved ? ( - - ) : null} - {!paid && approved ? ( - <> - - - - ) : null} - -
-
- ); -} - - const CUSTOMS_RISK_COLOR: Record = { GREEN: "green", YELLOW: "yellow", RED: "red", }; - - -/** - * Post-arrival additional duty/tax round (import): GL advises an extra amount - * with a notice; the customer pays offline and attaches another slip here. - */ -function SecondDutyDueCard({ - duty, - bookingId, - onView, - onChanged, -}: { - duty: NonNullable; - bookingId: string; - onView: (file: { name: string; url: string }) => void; - onChanged: () => void; -}) { - const [slip, setSlip] = useState(null); - const [uploading, setUploading] = useState(false); - const paid = duty.paid; - - return ( - - - -
- - - - {paid ? "Additional duty & tax paid" : "Additional duty & tax due"} - - - {paid ? "PAID" : "DUE"} - - - - {(duty.amount ?? 0).toLocaleString()} {duty.currency ?? ""} - - {duty.declarationSerial ? ( - - Payment code: {duty.declarationSerial} - - ) : null} - {!paid ? ( - - Customs advised additional duty/tax after arrival. Pay the amount - above and attach your payment slip. - - ) : null} -
- - - {duty.noticeFile ? ( - - ) : null} - {duty.slipFile ? ( - - ) : null} - {!paid ? ( - <> - - - - ) : null} - -
-
- ); -} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx index 0f691d6ea..08ec4b10a 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx @@ -252,9 +252,9 @@ export function BookingPaymentPanel({ }; return ( - + - Payment + Freight payment = { + PORT_CHARGES: "Port charges", + MISCELLANEOUS: "Miscellaneous charge", +}; + +const STATUS: Record< + Freight.ClearanceChargeStatus, + { label: string; bg: string; fg: string } +> = { + DOC_UPLOADED: { label: "DRAFT", bg: "#EEF2F6", fg: "#64748B" }, + BILLED: { label: "DRAFT", bg: "#EEF2F6", fg: "#64748B" }, + SENT: { label: "NEEDS YOUR APPROVAL", bg: "#FEF3E2", fg: "#B45309" }, + REJECTED: { label: "REJECTED", bg: "#FEE2E2", fg: "#B91C1C" }, + ACCEPTED: { label: "ACCEPTED — UNPAID", bg: "#E0F2FE", fg: "#0369A1" }, + PAID: { label: "PAID", bg: "#E6F7EF", fg: "#0A6F4D" }, +}; + +const money = (c: Freight.ClearanceCharge) => + `${formatAmount(c.amount)} ${c.currency ?? ""}`; + +/** + * Clearance charges Global Logistics proposed for this shipment. The customer + * accepts a price (its invoice is then issued and payable here) or rejects it + * with a note so GL can revise. Renders nothing until GL sends a charge. + */ +export function ClearanceChargesSection({ bookingId }: { bookingId: string }) { + const qc = useQueryClient(); + const key = ["booking-clearance-charges", bookingId]; + const { data: charges = [] } = useQuery({ + queryKey: key, + queryFn: () => bookingsService.getClearanceCharges(bookingId), + }); + const [rejecting, setRejecting] = useState(null); + const [note, setNote] = useState(""); + const [payCharge, setPayCharge] = useState(null); + const pay = useInvoicePayment(); + + const onError = (e: unknown) => + toast.error(e instanceof Error ? e.message : "Could not update the charge"); + const accept = useMutation({ + mutationFn: (chargeId: string) => + bookingsService.acceptClearanceCharge(bookingId, chargeId), + onSuccess: (next) => { + qc.setQueryData(key, next); + toast.success("Accepted — your invoice is ready to pay"); + }, + onError, + }); + const reject = useMutation({ + mutationFn: (p: { chargeId: string; note: string }) => + bookingsService.rejectClearanceCharge(bookingId, p.chargeId, p.note), + onSuccess: (next) => { + qc.setQueryData(key, next); + setRejecting(null); + setNote(""); + toast.success("Sent back to Global Logistics"); + }, + onError, + }); + const busy = accept.isPending || reject.isPending; + + if (charges.length === 0) return null; + + return ( + + + Clearance charges + + {charges.length} {charges.length === 1 ? "charge" : "charges"} + + + + {charges.map((c) => { + const st = STATUS[c.status]; + return ( + + + + + + {LABEL[c.type]} + + + {st.label} + + + {c.description && ( + + {c.description} + + )} + {c.invoiceNumber && c.invoiceId && ( + + Invoice{" "} + + {c.invoiceNumber} + + + )} + + + {money(c)} + + + + {c.status === "REJECTED" && c.customerNote && ( + + + You rejected this price: “{c.customerNote}”. Global Logistics + will revise it and send it again. + + + )} + + {c.status === "SENT" && + (rejecting === c.id ? ( + +