mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-03 19:03:40 +00:00
Merge pull request #1479 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -96,6 +96,7 @@ import { TrainsModule } from "./modules/trains/trains.module";
|
||||
import { VerifaydaModule } from "./modules/verifayda/verifayda.module";
|
||||
import { EimsModule } from "./modules/eims/eims.module";
|
||||
import { FleetHistoryModule } from "./modules/fleet-history/fleet-history.module";
|
||||
import { WagonHistoryModule } from "./modules/wagon-history/wagon-history.module";
|
||||
import { WagonsModule } from "./modules/wagons/wagons.module";
|
||||
import { ContainersModule } from "./modules/container-management/containers.module";
|
||||
import { CargoesModule } from "./modules/cargoes/cargoes.module";
|
||||
@@ -265,6 +266,7 @@ if (!process.env.APPLICATION_NAME) {
|
||||
VerifaydaModule,
|
||||
EimsModule,
|
||||
FleetHistoryModule,
|
||||
WagonHistoryModule,
|
||||
AiModule,
|
||||
AuditModule,
|
||||
ChatModule,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Unified per-wagon history ledger. One append-only row per transition
|
||||
* (yard move, coupling, schedule pin/dispatch/release, status flip, cargo
|
||||
* load/unload, container placement, lifecycle edits), written in the same
|
||||
* transaction as the change. No foreign keys: history must survive the wagon,
|
||||
* train, schedule or booking it points at. The two composite indexes back
|
||||
* keyset pagination of a single wagon's timeline (optionally per category);
|
||||
* the partial ones answer "what happened on this schedule / booking".
|
||||
*/
|
||||
export class WagonEvents3820000000000 implements MigrationInterface {
|
||||
name = 'WagonEvents3820000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.wagon_events (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
wagon_id uuid NOT NULL,
|
||||
wagon_number varchar,
|
||||
event_type varchar(40) NOT NULL,
|
||||
category varchar(20) NOT NULL,
|
||||
occurred_at timestamptz NOT NULL DEFAULT now(),
|
||||
actor_user_id uuid,
|
||||
from_yard_id uuid,
|
||||
to_yard_id uuid,
|
||||
train_id uuid,
|
||||
train_schedule_id uuid,
|
||||
booking_id uuid,
|
||||
from_value varchar(120),
|
||||
to_value varchar(120),
|
||||
reason text,
|
||||
metadata jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_wagon_events_wagon_time
|
||||
ON freight.wagon_events (wagon_id, occurred_at DESC, id DESC)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_wagon_events_wagon_cat_time
|
||||
ON freight.wagon_events (wagon_id, category, occurred_at DESC, id DESC)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_wagon_events_schedule
|
||||
ON freight.wagon_events (train_schedule_id)
|
||||
WHERE train_schedule_id IS NOT NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_wagon_events_booking
|
||||
ON freight.wagon_events (booking_id)
|
||||
WHERE booking_id IS NOT NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_events`);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ import { InvoiceLineRepository } from "./invoice-line.repository";
|
||||
import { PaymentModule } from "../payment/payment.module";
|
||||
import { CompaniesModule } from "../companies/companies.module";
|
||||
import { FilesModule } from "../files/files.module";
|
||||
import { NotificationsModule } from "../notifications/notifications.module";
|
||||
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -24,6 +26,10 @@ import { FilesModule } from "../files/files.module";
|
||||
DocumentsModule,
|
||||
UserTradeAccessModule,
|
||||
FilesModule,
|
||||
// Customer notice when Finance confirms a manual payment. The inbox module
|
||||
// reaches this one back through CompaniesModule, hence forwardRef.
|
||||
NotificationsModule,
|
||||
forwardRef(() => NotificationInboxModule),
|
||||
],
|
||||
controllers: [BillingController, PortalBillingController, PaymentController],
|
||||
providers: [BillingService, InvoiceRepository, InvoiceLineRepository],
|
||||
|
||||
@@ -83,6 +83,8 @@ describe("BillingService.generateInvoice", () => {
|
||||
{} as never, // files
|
||||
{ get: () => undefined } as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
{ directSend: jest.fn() } as never, // notifications
|
||||
{ notify: jest.fn() } as never, // inbox
|
||||
);
|
||||
});
|
||||
|
||||
@@ -166,6 +168,8 @@ describe("BillingService.issueMemo", () => {
|
||||
{} as never,
|
||||
{ get: () => undefined } as never,
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
{ directSend: jest.fn() } as never, // notifications
|
||||
{ notify: jest.fn() } as never, // inbox
|
||||
);
|
||||
return { service, manager, savedLines };
|
||||
}
|
||||
@@ -301,6 +305,8 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
||||
{} as never, // files
|
||||
{ get: () => undefined } as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
{ directSend: jest.fn() } as never, // notifications
|
||||
{ notify: jest.fn() } as never, // inbox
|
||||
);
|
||||
|
||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||
@@ -357,6 +363,8 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
||||
{} as never, // files
|
||||
{ get: () => undefined } as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
{ directSend: jest.fn() } as never, // notifications
|
||||
{ notify: jest.fn() } as never, // inbox
|
||||
);
|
||||
|
||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||
@@ -403,6 +411,8 @@ describe("BillingService.settleByPaymentId", () => {
|
||||
{} as never, // files
|
||||
{ get: () => undefined } as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
{ directSend: jest.fn() } as never, // notifications
|
||||
{ notify: jest.fn() } as never, // inbox
|
||||
);
|
||||
return { service, mg, events };
|
||||
}
|
||||
@@ -517,6 +527,8 @@ describe("BillingService.recordPayment", () => {
|
||||
{} as never, // files
|
||||
{ get: () => undefined } as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
{ directSend: jest.fn() } as never, // notifications
|
||||
{ notify: jest.fn() } as never, // inbox
|
||||
);
|
||||
return { service, mg, events };
|
||||
}
|
||||
@@ -635,6 +647,8 @@ describe("BillingService.expirePayable — locked write runs in a transaction",
|
||||
{} as never,
|
||||
{} as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
{ directSend: jest.fn() } as never, // notifications
|
||||
{ notify: jest.fn() } as never, // inbox
|
||||
);
|
||||
return { service, defaultManager, txManager, transaction };
|
||||
};
|
||||
@@ -709,6 +723,8 @@ describe("BillingService.issuePayable", () => {
|
||||
{} as never,
|
||||
{} as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
{ directSend: jest.fn() } as never, // notifications
|
||||
{ notify: jest.fn() } as never, // inbox
|
||||
);
|
||||
return { service, manager };
|
||||
};
|
||||
@@ -801,6 +817,8 @@ describe("BillingService — CAC Bank (OTP debit)", () => {
|
||||
{} as never,
|
||||
{} as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
{ directSend: jest.fn() } as never, // notifications
|
||||
{ notify: jest.fn() } as never, // inbox
|
||||
);
|
||||
return { service, repo };
|
||||
};
|
||||
@@ -885,6 +903,8 @@ describe("BillingService — CBE bill amounts carry cents, never rounded", () =>
|
||||
{} as never,
|
||||
{} as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
{ directSend: jest.fn() } as never, // notifications
|
||||
{ notify: jest.fn() } as never, // inbox
|
||||
);
|
||||
return { service, repo };
|
||||
};
|
||||
@@ -959,6 +979,8 @@ describe("BillingService.document", () => {
|
||||
: undefined,
|
||||
} as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
{ directSend: jest.fn() } as never, // notifications
|
||||
{ notify: jest.fn() } as never, // inbox
|
||||
);
|
||||
return { service, render, renderThermal };
|
||||
};
|
||||
@@ -1079,6 +1101,8 @@ describe("BillingService.confirmOfflinePayment pay-window guard", () => {
|
||||
function makeService(invoiceType: string) {
|
||||
const invoice = {
|
||||
id: "inv-1",
|
||||
invoiceNumber: "INV-001",
|
||||
companyId: "company-1",
|
||||
source: Freight.InvoiceSource.Booking,
|
||||
sourceId: "booking-1",
|
||||
type: invoiceType,
|
||||
@@ -1089,9 +1113,16 @@ describe("BillingService.confirmOfflinePayment pay-window guard", () => {
|
||||
const recordPayment = jest.fn().mockResolvedValue(invoice);
|
||||
const dataSource = {
|
||||
getRepository: () => ({
|
||||
findOne: async () => ({ id: "booking-1", paymentDeadline: PAST }),
|
||||
findOne: async () => ({
|
||||
id: "booking-1",
|
||||
reference: "BK-001",
|
||||
paymentDeadline: PAST,
|
||||
}),
|
||||
}),
|
||||
query: async () => [{ phone: "+251900000000", email: "c@x.com" }],
|
||||
};
|
||||
const directSend = jest.fn().mockResolvedValue(undefined);
|
||||
const notify = jest.fn().mockResolvedValue(undefined);
|
||||
const service = new BillingService(
|
||||
dataSource as never,
|
||||
{ findById: async () => invoice } as never,
|
||||
@@ -1103,10 +1134,12 @@ describe("BillingService.confirmOfflinePayment pay-window guard", () => {
|
||||
{ upload: async () => ({ id: "file-1", name: "slip.pdf" }) } as never,
|
||||
{ get: () => undefined } as never,
|
||||
{ isEnabled: async () => true } as never,
|
||||
{ directSend } as never,
|
||||
{ notify } as never,
|
||||
);
|
||||
(service as unknown as { recordPayment: unknown }).recordPayment =
|
||||
recordPayment;
|
||||
return { service, recordPayment };
|
||||
return { service, recordPayment, directSend, notify };
|
||||
}
|
||||
|
||||
const slip = { originalname: "slip.pdf" } as never;
|
||||
@@ -1129,6 +1162,42 @@ describe("BillingService.confirmOfflinePayment pay-window guard", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("notifies the customer (inbox + SMS + email) once the payment is confirmed", async () => {
|
||||
const { service, notify, directSend } = makeService(
|
||||
WAGON_CANCEL_FEE_INVOICE_TYPE,
|
||||
);
|
||||
await service.confirmOfflinePayment("inv-1", slip, {});
|
||||
expect(notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
recipients: { companyId: "company-1" },
|
||||
type: "PAYMENT_RECEIVED",
|
||||
link: "/billing/inv-1",
|
||||
body: expect.stringMatching(/500 ETB .*INV-001 \(booking BK-001\)/),
|
||||
}),
|
||||
);
|
||||
expect(directSend).toHaveBeenCalledWith(
|
||||
"sms",
|
||||
"+251900000000",
|
||||
expect.stringContaining("INV-001"),
|
||||
);
|
||||
expect(directSend).toHaveBeenCalledWith(
|
||||
"email",
|
||||
"c@x.com",
|
||||
expect.stringContaining("INV-001"),
|
||||
);
|
||||
});
|
||||
|
||||
it("still settles when the customer notice fails", async () => {
|
||||
const { service, notify, recordPayment } = makeService(
|
||||
WAGON_CANCEL_FEE_INVOICE_TYPE,
|
||||
);
|
||||
notify.mockRejectedValueOnce(new Error("inbox down"));
|
||||
await expect(
|
||||
service.confirmOfflinePayment("inv-1", slip, {}),
|
||||
).resolves.toBeDefined();
|
||||
expect(recordPayment).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still requires the bank slip for a cancellation fee", async () => {
|
||||
const { service } = makeService(WAGON_CANCEL_FEE_INVOICE_TYPE);
|
||||
await expect(
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { Freight, PaymentReferenceType } from "@edr/types";
|
||||
import {
|
||||
Freight,
|
||||
NotificationAudience,
|
||||
NotificationType,
|
||||
PaymentReferenceType,
|
||||
} from "@edr/types";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import {
|
||||
BadRequestException,
|
||||
@@ -20,6 +25,10 @@ import { WAGON_CANCEL_FEE_INVOICE_TYPE } from "../bookings/entities/booking-wago
|
||||
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
|
||||
import { ShippingLineCredit } from "../shipping-lines/entities/shipping-line-credit.entity";
|
||||
import { ManualPaymentSettingsService } from "../payment-settings/manual-payment-settings.service";
|
||||
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
|
||||
import { NotificationsService } from "../notifications/notifications.service";
|
||||
import { sendCompanyChannels } from "../notifications/notify-company.util";
|
||||
import { resolveShippingLineNotifyTarget } from "../notifications/resolve-shipping-line-contact.util";
|
||||
import { EimsConfig } from "../../config/eims.config";
|
||||
import { CompaniesService } from "../companies/companies.service";
|
||||
import { EimsInvoiceStatus } from "../eims/eims-registration.types";
|
||||
@@ -273,7 +282,9 @@ export class BillingService {
|
||||
private readonly files: FilesService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly manualPaymentSettings: ManualPaymentSettingsService,
|
||||
) {}
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
) { }
|
||||
|
||||
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -832,8 +843,9 @@ export class BillingService {
|
||||
uploadedByName: input.userName ?? null,
|
||||
});
|
||||
|
||||
return this.recordPayment(invoiceId, {
|
||||
amount: Number(invoice.balanceAmount),
|
||||
const amount = Number(invoice.balanceAmount);
|
||||
const paid = await this.recordPayment(invoiceId, {
|
||||
amount,
|
||||
method: "BANK_TRANSFER",
|
||||
reference: input.reference || slip.name,
|
||||
metadata: {
|
||||
@@ -843,6 +855,104 @@ export class BillingService {
|
||||
confirmedByName: input.userName ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
// The customer did not pay through the portal, so nothing else tells them
|
||||
// Finance has settled their invoice — this is their only confirmation.
|
||||
await this.notifyCustomerManualPaymentConfirmed(paid, amount);
|
||||
return paid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the customer Finance confirmed their manual (bank transfer / counter)
|
||||
* payment: portal inbox entry plus SMS and email to the company's contact
|
||||
* (or the shipping line's own contact for a credit invoice). Best-effort —
|
||||
* a notification failure never undoes the settlement, it is only logged.
|
||||
*/
|
||||
private async notifyCustomerManualPaymentConfirmed(
|
||||
invoice: Invoice,
|
||||
amount: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const bookingRef =
|
||||
invoice.source === Freight.InvoiceSource.Booking
|
||||
? await this.bookingReferenceFor(invoice.sourceId)
|
||||
: null;
|
||||
const body =
|
||||
`Your payment of ${round2(amount)} ${invoice.currency} for invoice ${invoice.invoiceNumber}` +
|
||||
(bookingRef ? ` (booking ${bookingRef})` : "") +
|
||||
` has been received and confirmed. Thank you.`;
|
||||
const title = "Payment confirmed";
|
||||
const data = {
|
||||
invoiceId: invoice.id,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
bookingId: bookingRef ? invoice.sourceId : null,
|
||||
};
|
||||
|
||||
if (invoice.companyId || invoice.companyProfileId) {
|
||||
await this.inbox.notify({
|
||||
recipients: invoice.companyId
|
||||
? { companyId: invoice.companyId }
|
||||
: { companyProfileId: invoice.companyProfileId! },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.PAYMENT_RECEIVED,
|
||||
title,
|
||||
body,
|
||||
link: `/billing/${invoice.id}`,
|
||||
data,
|
||||
});
|
||||
if (invoice.companyId) {
|
||||
await sendCompanyChannels(
|
||||
this.dataSource,
|
||||
this.notifications,
|
||||
invoice.companyId,
|
||||
body,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (invoice.shippingLineCompanyId) {
|
||||
const target = await resolveShippingLineNotifyTarget(
|
||||
this.dataSource,
|
||||
invoice.shippingLineCompanyId,
|
||||
);
|
||||
if (target.userId) {
|
||||
await this.inbox.notify({
|
||||
recipients: { userIds: [target.userId] },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.PAYMENT_RECEIVED,
|
||||
title,
|
||||
body,
|
||||
link: `/shipping-line/invoices/${invoice.id}`,
|
||||
data,
|
||||
});
|
||||
}
|
||||
for (const [method, to] of [
|
||||
["sms", target.phone],
|
||||
["email", target.email],
|
||||
] as const) {
|
||||
if (!to) continue;
|
||||
try {
|
||||
await this.notifications.directSend(method, to, body);
|
||||
} catch {
|
||||
/* best-effort: provider unavailable */
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Manual payment confirmed notify failed for invoice ${invoice.id}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Booking reference for a booking id, or null when the booking is gone. */
|
||||
private async bookingReferenceFor(bookingId: string): Promise<string | null> {
|
||||
const booking = await this.dataSource.getRepository(Booking).findOne({
|
||||
where: { id: bookingId },
|
||||
select: ["id", "reference"],
|
||||
});
|
||||
return booking?.reference ?? null;
|
||||
}
|
||||
|
||||
/** Invoice header plus its line items. */
|
||||
|
||||
@@ -370,7 +370,21 @@ export class BookingTransitionService {
|
||||
|
||||
async startTransit(bookingId: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ["PAID"]);
|
||||
// Paid is read from the PAYMENT status only; the booking status merely
|
||||
// guards against re-entering transit from a later stage.
|
||||
if (booking.paymentStatus !== "PAID") {
|
||||
throw new ConflictException(
|
||||
`Booking must be paid before it can start transit (payment status "${booking.paymentStatus ?? "PENDING"}")`,
|
||||
);
|
||||
}
|
||||
assertBookingStatus(booking, [
|
||||
"PAID",
|
||||
"FULLY_EXECUTED",
|
||||
"PNR_GENERATED",
|
||||
"WAGON_ASSIGNED",
|
||||
"READY_FOR_ASSIGNMENT",
|
||||
"APPROVED",
|
||||
]);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: "IN_TRANSIT",
|
||||
@@ -1739,6 +1753,7 @@ export class BookingTransitionService {
|
||||
// (portal and backoffice). Degrades to null like every fragile field here.
|
||||
let trainSchedule: {
|
||||
trainNumber: string | null;
|
||||
voyageNumber: string | null;
|
||||
reference: string | null;
|
||||
scheduledDepartureDate: Date | null;
|
||||
} | null = null;
|
||||
@@ -1750,6 +1765,8 @@ export class BookingTransitionService {
|
||||
if (s) {
|
||||
trainSchedule = {
|
||||
trainNumber: s.trainNumber ?? null,
|
||||
// The schedule's own voyage (sailing) number shown to the customer.
|
||||
voyageNumber: s.voyageNumber ?? null,
|
||||
reference: s.reference ?? null,
|
||||
scheduledDepartureDate: s.scheduledDepartureDate ?? null,
|
||||
};
|
||||
|
||||
@@ -53,6 +53,8 @@ import {
|
||||
CancelledUnitSnapshot,
|
||||
WAGON_CANCEL_FEE_INVOICE_TYPE,
|
||||
} from './entities/booking-wagon-cancellation.entity';
|
||||
import { WagonEventType } from '@edr/types';
|
||||
import { WagonHistoryService } from '../wagon-history/wagon-history.service';
|
||||
|
||||
export { WAGON_CANCEL_FEE_INVOICE_TYPE };
|
||||
|
||||
@@ -134,6 +136,7 @@ export class BookingWagonCancellationService {
|
||||
private readonly firstMile: FirstMileService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
private readonly events: EventEmitter2,
|
||||
private readonly wagonHistory: WagonHistoryService,
|
||||
) {}
|
||||
|
||||
// ── T1: request ────────────────────────────────────────────────────────────
|
||||
@@ -1847,6 +1850,7 @@ export class BookingWagonCancellationService {
|
||||
.getRepository(WagonAllocationContainerItem)
|
||||
.delete(cut.map((i) => i.id));
|
||||
if (cut.length === items.length) {
|
||||
await this.recordAllocationRelease(manager, [alloc.id], bookingId, 'Containers cancelled from booking');
|
||||
await manager.getRepository(WagonBookingAllocation).delete(alloc.id);
|
||||
} else {
|
||||
const cutWeight = cut.reduce((s, i) => s + Number(i.grossWeightTons ?? 0), 0);
|
||||
@@ -1893,9 +1897,66 @@ export class BookingWagonCancellationService {
|
||||
await manager
|
||||
.getRepository(WagonAllocationBulkLoad)
|
||||
.delete({ wagonBookingAllocationId: In(ids) });
|
||||
await this.recordAllocationRelease(manager, ids, bookingId, 'Wagons cancelled from booking');
|
||||
await manager.getRepository(WagonBookingAllocation).delete(ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* BOOKING_CANCELLED history row for every physical wagon behind the released
|
||||
* allocations — resolved through the slot BEFORE the allocation rows go, one
|
||||
* query for the whole batch. Slots with no wagon pinned yet leave no row.
|
||||
*/
|
||||
private async recordAllocationRelease(
|
||||
manager: EntityManager,
|
||||
allocationIds: string[],
|
||||
bookingId: string,
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
if (!allocationIds.length) return;
|
||||
const rows: Array<{
|
||||
allocationId: string;
|
||||
wagonId: string;
|
||||
wagonNumber: string;
|
||||
yardId: string | null;
|
||||
trainId: string | null;
|
||||
scheduleId: string | null;
|
||||
weightTons: string | null;
|
||||
loadType: string | null;
|
||||
}> = await manager.query(
|
||||
`SELECT a.id AS "allocationId",
|
||||
w.id AS "wagonId",
|
||||
w.wagon_number AS "wagonNumber",
|
||||
w.current_yard_id AS "yardId",
|
||||
w.train_id AS "trainId",
|
||||
w.current_train_schedule_id AS "scheduleId",
|
||||
a.allocated_weight_tons AS "weightTons",
|
||||
a.load_type AS "loadType"
|
||||
FROM freight.wagon_booking_allocations a
|
||||
JOIN freight.train_set_wagons tsw ON tsw.id = a.train_set_wagon_id
|
||||
JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
|
||||
WHERE a.id = ANY($1::uuid[])`,
|
||||
[allocationIds],
|
||||
);
|
||||
await this.wagonHistory.record(
|
||||
manager,
|
||||
rows.map((r) => ({
|
||||
wagonId: r.wagonId,
|
||||
wagonNumber: r.wagonNumber,
|
||||
type: WagonEventType.BookingCancelled,
|
||||
fromYardId: r.yardId,
|
||||
trainId: r.trainId,
|
||||
trainScheduleId: r.scheduleId,
|
||||
bookingId,
|
||||
reason,
|
||||
metadata: {
|
||||
allocationId: r.allocationId,
|
||||
loadType: r.loadType,
|
||||
weightTons: r.weightTons == null ? null : Number(r.weightTons),
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/** Pre-reduction quantities snapshot (only when the booking was never split before). */
|
||||
private async currentQuantities(
|
||||
manager: EntityManager,
|
||||
|
||||
@@ -665,9 +665,11 @@ export class BookingsController {
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
// GL (createBooking) rebooks credits and must see the ledger for that.
|
||||
const staff =
|
||||
hasFreightPermission(user, FREIGHT_PERMS.bookings.view) ||
|
||||
hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationView);
|
||||
hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationView) ||
|
||||
hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking);
|
||||
if (!staff) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(
|
||||
user?.id,
|
||||
@@ -790,6 +792,14 @@ export class BookingsController {
|
||||
staffPermission: string,
|
||||
): Promise<void> {
|
||||
if (hasFreightPermission(user, staffPermission)) return;
|
||||
// Rebooking a credit creates a booking under the contract — GL's booking
|
||||
// creation key covers it even where the dedicated rebook key was never granted.
|
||||
if (
|
||||
staffPermission === FREIGHT_PERMS.bookings.wagonCancellationRebook &&
|
||||
hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const row = await this.wagonCancellationService.findById(cancellationId);
|
||||
const booking = await this.bookingsService.findById(row.bookingId);
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(
|
||||
|
||||
@@ -8,6 +8,8 @@ import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto';
|
||||
import { Container } from './entities/container.entity';
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||
import { WagonEventType } from '@edr/types';
|
||||
import { WagonHistoryService } from '../wagon-history/wagon-history.service';
|
||||
|
||||
@Injectable()
|
||||
export class ContainersService {
|
||||
@@ -19,6 +21,7 @@ export class ContainersService {
|
||||
@InjectRepository(ContainerType)
|
||||
private readonly containerTypeRepo: Repository<ContainerType>,
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly wagonHistory: WagonHistoryService,
|
||||
) {}
|
||||
|
||||
async create(dto: CreateContainerDto): Promise<Container> {
|
||||
@@ -150,7 +153,16 @@ export class ContainersService {
|
||||
// Placing a container on a wagon does not make it AVAILABLE. The status enum
|
||||
// (AVAILABLE, LOADED, IN_TRANSIT, MAINTENANCE, DAMAGED) has no ASSIGNED/ON_WAGON
|
||||
// state, so leave the existing status unchanged rather than forcing AVAILABLE.
|
||||
return containerRepo.save(container);
|
||||
const saved = await containerRepo.save(container);
|
||||
await this.wagonHistory.record(manager, {
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.ContainerPlaced,
|
||||
toYardId: wagon.currentYardId ?? null,
|
||||
toValue: container.containerNumber,
|
||||
metadata: { containerId: container.id, position },
|
||||
});
|
||||
return saved;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -159,9 +171,23 @@ export class ContainersService {
|
||||
if (container.status === 'LOADED') {
|
||||
throw new ConflictException('Cannot unassign a loaded container');
|
||||
}
|
||||
const previousWagonId = container.wagonId;
|
||||
const previousPosition = container.position ?? null;
|
||||
container.wagonId = null;
|
||||
container.position = null;
|
||||
container.status = 'AVAILABLE';
|
||||
return this.containerRepo.save(container);
|
||||
const saved = await this.containerRepo.save(container);
|
||||
if (previousWagonId) {
|
||||
const wagon = await this.wagonRepo.findOne({ where: { id: previousWagonId } });
|
||||
await this.wagonHistory.record(null, {
|
||||
wagonId: previousWagonId,
|
||||
wagonNumber: wagon?.wagonNumber ?? null,
|
||||
type: WagonEventType.ContainerRemoved,
|
||||
fromYardId: wagon?.currentYardId ?? null,
|
||||
fromValue: container.containerNumber,
|
||||
metadata: { containerId: container.id, position: previousPosition },
|
||||
});
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,7 +270,7 @@ export class SchedulingRescheduleService {
|
||||
// M12: only announce a new departure when the date actually moved —
|
||||
// `newDeparture` is null when the date was unchanged, so retained customers
|
||||
// are not falsely told the train was rescheduled.
|
||||
await this.notifyRescheduleOutcome(dto, newDeparture);
|
||||
await this.notifyRescheduleOutcome(scheduleId, dto, newDeparture);
|
||||
if (newDeparture) void this.trainSchedulingService.emitWindowState(scheduleId);
|
||||
|
||||
return { plan, schedule: assignResult };
|
||||
@@ -283,6 +283,7 @@ export class SchedulingRescheduleService {
|
||||
* company so the notifier has a phone/email to reach.
|
||||
*/
|
||||
private async notifyRescheduleOutcome(
|
||||
scheduleId: string,
|
||||
dto: ExecuteRescheduleDto,
|
||||
newDeparture: Date | null,
|
||||
): Promise<void> {
|
||||
@@ -294,9 +295,9 @@ export class SchedulingRescheduleService {
|
||||
const booking = await this.loadBookingForNotify(bookingId);
|
||||
if (!booking) continue;
|
||||
if (isMaintenance) {
|
||||
this.notifier.maintenanceMoved(booking, newDeparture);
|
||||
this.notifier.maintenanceMoved(booking, newDeparture, scheduleId, dto.reason);
|
||||
} else {
|
||||
this.notifier.rescheduled(booking, newDeparture);
|
||||
this.notifier.rescheduled(booking, newDeparture, scheduleId, dto.reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -307,7 +308,8 @@ export class SchedulingRescheduleService {
|
||||
for (const bookingId of dto.displacedBookingIds) {
|
||||
const booking = await this.loadBookingForNotify(bookingId);
|
||||
if (!booking) continue;
|
||||
this.notifier.removedFromTrain(booking);
|
||||
// Displaced bookings no longer point at the schedule — pass it explicitly.
|
||||
this.notifier.removedFromTrain(booking, scheduleId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { trainRunLabel } from './train-run-label.util';
|
||||
|
||||
describe('trainRunLabel', () => {
|
||||
it('names the departure by the schedule train number and voyage number', () => {
|
||||
expect(trainRunLabel({ trainNumber: '8001', voyageNumber: 'V-117' })).toBe(
|
||||
'train 8001 (voyage V-117)',
|
||||
);
|
||||
});
|
||||
|
||||
it('drops the voyage bracket when the schedule has no voyage number', () => {
|
||||
expect(trainRunLabel({ trainNumber: '8001', voyageNumber: null })).toBe('train 8001');
|
||||
expect(trainRunLabel({ trainNumber: '8001', voyageNumber: ' ' })).toBe('train 8001');
|
||||
});
|
||||
|
||||
it('still quotes the voyage when the pool train number is not assigned yet', () => {
|
||||
expect(trainRunLabel({ trainNumber: null, voyageNumber: 'V-117' })).toBe(
|
||||
'train (voyage V-117)',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null when neither number is known so callers can fall back', () => {
|
||||
expect(trainRunLabel({ trainNumber: null, voyageNumber: null })).toBeNull();
|
||||
expect(trainRunLabel(null)).toBeNull();
|
||||
expect(trainRunLabel(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('capitalizes for sentence starts on request', () => {
|
||||
expect(
|
||||
trainRunLabel({ trainNumber: '8001', voyageNumber: 'V-117' }, { capitalize: true }),
|
||||
).toBe('Train 8001 (voyage V-117)');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { TrainSchedule } from './entities/train-schedule.entity';
|
||||
|
||||
export type TrainRunSource = Pick<TrainSchedule, 'trainNumber' | 'voyageNumber'>;
|
||||
|
||||
/**
|
||||
* How a departure is named in every customer-facing SMS / email:
|
||||
*
|
||||
* "train 8001 (voyage V-2026-117)"
|
||||
*
|
||||
* Both identifiers are the SCHEDULE's own columns — `train_schedules.train_number`
|
||||
* and `train_schedules.voyage_number`. The built train (`freight.trains`) carries
|
||||
* a `train_name` that the build form labels "voyage number"; that is a different
|
||||
* identifier and must never be quoted to customers. Always pass the schedule.
|
||||
*
|
||||
* Returns null when the schedule has neither number (older rows, or an unbuilt
|
||||
* departure whose pool number is assigned at dispatch) so callers can fall back
|
||||
* to a generic phrase instead of printing "train (voyage)".
|
||||
*/
|
||||
export function trainRunLabel(
|
||||
schedule: TrainRunSource | null | undefined,
|
||||
opts: { capitalize?: boolean } = {},
|
||||
): string | null {
|
||||
if (!schedule) return null;
|
||||
const train = schedule.trainNumber?.trim() || null;
|
||||
const voyage = schedule.voyageNumber?.trim() || null;
|
||||
if (!train && !voyage) return null;
|
||||
const head = train ? `train ${train}` : 'train';
|
||||
const label = voyage ? `${head} (voyage ${voyage})` : head;
|
||||
return opts.capitalize ? label.charAt(0).toUpperCase() + label.slice(1) : label;
|
||||
}
|
||||
@@ -607,7 +607,6 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const isBatchPaid =
|
||||
booking.status === "SELECTED_FOR_BATCH" ||
|
||||
booking.status === "AWAITING_PAYMENT" ||
|
||||
booking.status === "PAID" ||
|
||||
booking.paymentStatus === "PAID";
|
||||
if (!isBatchPaid) return;
|
||||
|
||||
@@ -786,7 +785,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
`SELECT id FROM freight.bookings
|
||||
WHERE deleted_at IS NULL
|
||||
AND train_schedule_id IS NULL
|
||||
AND (payment_status = 'PAID' OR status = 'PAID')
|
||||
AND payment_status = 'PAID'
|
||||
AND scheduled_date IS NOT NULL
|
||||
AND DATE(scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = $1`,
|
||||
[day],
|
||||
@@ -3476,7 +3475,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
schedule?.scheduledDepartureDate &&
|
||||
eatDay(schedule.scheduledDepartureDate) !== previousDay
|
||||
) {
|
||||
this.notifier.allocatedOtherDay(fresh, schedule.scheduledDepartureDate);
|
||||
this.notifier.allocatedOtherDay(fresh, schedule.scheduledDepartureDate, schedule);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3819,7 +3818,6 @@ export class BookingBatchService implements OnModuleInit {
|
||||
fresh.trainScheduleId === scheduleId &&
|
||||
(fresh.status === "SELECTED_FOR_BATCH" ||
|
||||
fresh.status === "AWAITING_PAYMENT" ||
|
||||
fresh.status === "PAID" ||
|
||||
fresh.paymentStatus === "PAID")
|
||||
) {
|
||||
this.logger.debug(
|
||||
@@ -4535,7 +4533,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
manager,
|
||||
);
|
||||
});
|
||||
this.notifier.displaced(victim);
|
||||
this.notifier.displaced(victim, scheduleId);
|
||||
budget.add(this.needFor(victim, wagonDims), victimLeg);
|
||||
// Displacing frees wagons the same way an expiry does — don't leave the
|
||||
// schedule stuck at FULL.
|
||||
@@ -5489,7 +5487,6 @@ export class BookingBatchService implements OnModuleInit {
|
||||
).filter(
|
||||
(b) =>
|
||||
b.paymentStatus === "PAID" ||
|
||||
b.status === "PAID" ||
|
||||
!payWindowLapsed(b.paymentDeadline, deadlineCutoff),
|
||||
);
|
||||
// Export FCFS: a customer's pending operation request HOLDS its wagons from
|
||||
@@ -5601,7 +5598,6 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return reserved.some(
|
||||
(b) =>
|
||||
b.paymentStatus !== "PAID" &&
|
||||
b.status !== "PAID" &&
|
||||
b.paymentDeadline != null &&
|
||||
!payWindowLapsed(b.paymentDeadline, now),
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ describe('BookingJourneyService.autoPlaceOnFreedWagons', () => {
|
||||
{ emit: jest.fn() } as never, // events
|
||||
{} as never, // notifications
|
||||
{} as never, // inbox
|
||||
{ record: jest.fn() } as never, // wagonHistory
|
||||
);
|
||||
|
||||
const schedule = { id: 'sched-1', trainSetId: 'ts-1' };
|
||||
|
||||
@@ -31,12 +31,11 @@ import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
||||
import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import {
|
||||
notifyCarriageAcceptanceReady,
|
||||
notifyLoadManifest,
|
||||
} from '../notifications/notify-company.util';
|
||||
import { notifyCarriageAcceptanceReady,notifyLoadManifest } from '../notifications/notify-company.util';
|
||||
import { WagonEventInput, WagonHistoryService } from '../wagon-history/wagon-history.service';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
|
||||
|
||||
/**
|
||||
* Per-booking journey along a train's corridor — for EVERY trade direction.
|
||||
*
|
||||
@@ -65,12 +64,18 @@ export class BookingJourneyService {
|
||||
private readonly events: EventEmitter2,
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
private readonly wagonHistory: WagonHistoryService,
|
||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||
) {}
|
||||
|
||||
/** Statuses from which a booking may be loaded (gov bookings don't prepay). */
|
||||
/**
|
||||
* Whether a booking may be loaded. Paid is decided by the booking's
|
||||
* PAYMENT status only — never by `status === 'PAID'`, which lags or is
|
||||
* skipped on several flows (batch pay, manual mark-paid, gov expedite).
|
||||
* Government bookings don't prepay: APPROVED is enough for them.
|
||||
*/
|
||||
private canLoad(booking: Booking): boolean {
|
||||
if (booking.status === 'PAID') return true;
|
||||
if (booking.paymentStatus === 'PAID') return true;
|
||||
return booking.isGovernment && booking.status === 'APPROVED';
|
||||
}
|
||||
|
||||
@@ -121,6 +126,7 @@ export class BookingJourneyService {
|
||||
loadedAt: now,
|
||||
loadedByUserId: userId ?? null,
|
||||
});
|
||||
await this.wagonHistory.record(manager, this.cargoEvent(target, schedule, booking, 'LOADED', now, userId ?? null));
|
||||
if (!booking.loadingStartedAt) {
|
||||
await manager
|
||||
.getRepository(Booking)
|
||||
@@ -192,7 +198,8 @@ export class BookingJourneyService {
|
||||
}
|
||||
if (!this.canLoad(booking)) {
|
||||
throw new BadRequestException(
|
||||
`Booking must be paid before loading (currently ${booking.status})`,
|
||||
`Booking must be paid before loading (payment status ${booking.paymentStatus ?? 'PENDING'}, ` +
|
||||
`booking status ${booking.status})`,
|
||||
);
|
||||
}
|
||||
await this.assertTrainAtYard(schedule, booking.originYardId, 'origin');
|
||||
@@ -239,7 +246,12 @@ export class BookingJourneyService {
|
||||
if (booking.tradeDirection === 'DOMESTIC') {
|
||||
await this.autoPlaceOnFreedWagons(manager, schedule, booking);
|
||||
}
|
||||
await this.setAllocationStatuses(manager, scheduleId, bookingId, 'LOADED');
|
||||
await this.setAllocationStatuses(manager, scheduleId, bookingId, 'LOADED', {
|
||||
userId: userId ?? null,
|
||||
at: now,
|
||||
schedule,
|
||||
booking,
|
||||
});
|
||||
// Keep the schedule↔booking link's tracking flag in sync — the dispatch
|
||||
// readiness warnings and workspace badges read loading_status, not loadedAt.
|
||||
await manager
|
||||
@@ -346,6 +358,7 @@ export class BookingJourneyService {
|
||||
unloadedAt: now,
|
||||
unloadedByUserId: userId ?? null,
|
||||
});
|
||||
await this.wagonHistory.record(null, this.cargoEvent(target, schedule, booking, 'DEPARTED', now, userId ?? null));
|
||||
|
||||
const remaining = allocations.filter(
|
||||
(a) => a.id !== target.id && a.status !== 'DEPARTED',
|
||||
@@ -403,7 +416,12 @@ export class BookingJourneyService {
|
||||
arrivedAt: now,
|
||||
arrivedByUserId: userId ?? null,
|
||||
} as never);
|
||||
await this.setAllocationStatuses(manager, scheduleId, bookingId, 'DEPARTED');
|
||||
await this.setAllocationStatuses(manager, scheduleId, bookingId, 'DEPARTED', {
|
||||
userId: userId ?? null,
|
||||
at: now,
|
||||
schedule,
|
||||
booking,
|
||||
});
|
||||
await this.settleWagonsOnUnload(manager, schedule, booking, now, userId ?? null);
|
||||
// The facility took the cargo off the train — raise its GRN. Where the
|
||||
// facility also stores cargo (Indode), the event links the storage record
|
||||
@@ -482,6 +500,7 @@ export class BookingJourneyService {
|
||||
id: b.id,
|
||||
reference: b.reference,
|
||||
status: b.status,
|
||||
paymentStatus: b.paymentStatus ?? null,
|
||||
tradeDirection: b.tradeDirection,
|
||||
isGovernment: b.isGovernment,
|
||||
customer: b.company?.name ?? 'Unknown customer',
|
||||
@@ -919,12 +938,61 @@ export class BookingJourneyService {
|
||||
scheduleId: string,
|
||||
bookingId: string,
|
||||
status: 'LOADED' | 'DEPARTED',
|
||||
ctx?: { userId: string | null; at: Date; schedule: TrainSchedule; booking: Booking },
|
||||
): Promise<void> {
|
||||
const allocations = await this.allocationsForBooking(manager, scheduleId, bookingId);
|
||||
if (!allocations.length) return;
|
||||
await manager
|
||||
.getRepository(WagonBookingAllocation)
|
||||
.update({ id: In(allocations.map((a) => a.id)) }, { status });
|
||||
if (!ctx) return;
|
||||
// Per-wagon cargo history. Allocations already at (or past) the target
|
||||
// status were logged by the per-wagon load/unload endpoint — skip them so
|
||||
// the whole-booking completion never double-writes a wagon's row.
|
||||
const pending = allocations.filter((a) =>
|
||||
status === 'LOADED'
|
||||
? a.status !== 'LOADED' && a.status !== 'DEPARTED'
|
||||
: a.status !== 'DEPARTED',
|
||||
);
|
||||
await this.wagonHistory.record(
|
||||
manager,
|
||||
pending
|
||||
.map((a) => this.cargoEvent(a, ctx.schedule, ctx.booking, status, ctx.at, ctx.userId))
|
||||
.filter((e): e is WagonEventInput => e !== null),
|
||||
);
|
||||
}
|
||||
|
||||
/** CARGO_LOADED / CARGO_UNLOADED row for one allocation's physical wagon; null when the slot has no wagon pinned. */
|
||||
private cargoEvent(
|
||||
alloc: WagonBookingAllocation & { trainSetWagon?: TrainSetWagon },
|
||||
schedule: TrainSchedule,
|
||||
booking: Booking,
|
||||
status: 'LOADED' | 'DEPARTED',
|
||||
at: Date,
|
||||
userId: string | null,
|
||||
): WagonEventInput | null {
|
||||
const slot = alloc.trainSetWagon;
|
||||
if (!slot?.physicalWagonId) return null;
|
||||
const loaded = status === 'LOADED';
|
||||
return {
|
||||
wagonId: slot.physicalWagonId,
|
||||
wagonNumber: slot.physicalWagon?.wagonNumber ?? null,
|
||||
type: loaded ? Freight.WagonEventType.CargoLoaded : Freight.WagonEventType.CargoUnloaded,
|
||||
occurredAt: at,
|
||||
actorUserId: userId,
|
||||
toYardId: loaded
|
||||
? (slot.boardYardId ?? schedule.originStationId ?? null)
|
||||
: (booking.destinationYardId ?? slot.alightYardId ?? schedule.destinationStationId ?? null),
|
||||
trainScheduleId: schedule.id,
|
||||
trainId: schedule.trainSet?.trainId ?? null,
|
||||
bookingId: booking.id,
|
||||
toValue: booking.reference ?? null,
|
||||
metadata: {
|
||||
allocationId: alloc.id,
|
||||
loadType: alloc.loadType ?? null,
|
||||
weightTons: Number(alloc.allocatedWeightTons ?? 0),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async allocationsForBooking(
|
||||
@@ -1012,6 +1080,20 @@ export class BookingJourneyService {
|
||||
? Freight.WagonStatus.Assigned
|
||||
: Freight.WagonStatus.Available,
|
||||
});
|
||||
await this.wagonHistory.record(manager, {
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: Freight.WagonEventType.ReleasedAtUnload,
|
||||
occurredAt: now,
|
||||
actorUserId: userId,
|
||||
fromYardId: boardYardId ?? null,
|
||||
toYardId: booking.destinationYardId ?? null,
|
||||
trainScheduleId: schedule.id,
|
||||
trainId: wagon.trainId ?? null,
|
||||
bookingId: booking.id,
|
||||
toValue: wagon.trainId ? Freight.WagonStatus.Assigned : Freight.WagonStatus.Available,
|
||||
metadata: { slotId: slot.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { BookingNotifierService } from './booking-notifier.service';
|
||||
|
||||
/**
|
||||
* Message wording for the schedule-related customer notices: every one must
|
||||
* quote the SCHEDULE's train + voyage numbers, and reschedules must carry the
|
||||
* staff-entered reason instead of a hard-coded "for maintenance".
|
||||
*/
|
||||
describe('BookingNotifierService messages', () => {
|
||||
const schedule = { trainNumber: '8001', voyageNumber: 'V-117' };
|
||||
const booking = { id: 'b1', reference: 'BK-2026-000928', companyId: 'c1' } as never;
|
||||
const departure = new Date('2026-09-01T05:00:00.000Z');
|
||||
|
||||
let sent: string[];
|
||||
let inbox: string[];
|
||||
let service: BookingNotifierService;
|
||||
|
||||
beforeEach(() => {
|
||||
sent = [];
|
||||
inbox = [];
|
||||
const notifications = {
|
||||
directSend: jest.fn(async (_m: string, _to: string, msg: string) => {
|
||||
sent.push(msg);
|
||||
}),
|
||||
};
|
||||
const inboxSvc = {
|
||||
notify: jest.fn(async (input: { body: string }) => {
|
||||
inbox.push(input.body);
|
||||
}),
|
||||
};
|
||||
const trainSchedules = {
|
||||
findByIdWithStations: jest.fn(async () => ({ ...schedule, reference: 'S-2026-00012' })),
|
||||
};
|
||||
// Company contact lookup goes through raw SQL; return one phone + email.
|
||||
const dataSource = {
|
||||
query: jest.fn(async () => [{ phone: '+251900000000', email: 'ops@example.com' }]),
|
||||
};
|
||||
service = new BookingNotifierService(
|
||||
notifications as never,
|
||||
inboxSvc as never,
|
||||
trainSchedules as never,
|
||||
dataSource as never,
|
||||
);
|
||||
});
|
||||
|
||||
const flush = () => new Promise((r) => setImmediate(r));
|
||||
|
||||
it('maintenance reschedule quotes train, voyage and the staff reason', async () => {
|
||||
service.maintenanceMoved(booking, departure, schedule, 'Locomotive maintenance.');
|
||||
await flush();
|
||||
expect(inbox[0]).toBe(
|
||||
'Train 8001 (voyage V-117) for booking BK-2026-000928 was rescheduled — reason: Locomotive maintenance. ' +
|
||||
'New departure date: 01/09/2026.',
|
||||
);
|
||||
});
|
||||
|
||||
it('maintenance reschedule falls back to "for maintenance" without a reason', async () => {
|
||||
service.maintenanceMoved(booking, departure, schedule, ' ');
|
||||
await flush();
|
||||
expect(inbox[0]).toContain('was rescheduled for maintenance. New departure date');
|
||||
});
|
||||
|
||||
it('plain reschedule carries the reason and the run label', async () => {
|
||||
service.rescheduled(booking, departure, schedule, 'Crew change');
|
||||
await flush();
|
||||
expect(inbox[0]).toBe(
|
||||
'Booking BK-2026-000928 on train 8001 (voyage V-117) has been rescheduled — reason: Crew change. ' +
|
||||
'New departure date: 01/09/2026.',
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves the run label from a schedule id when only the id is known', async () => {
|
||||
service.scheduleCancelled(booking, 'sched-1');
|
||||
await flush();
|
||||
expect(inbox[0]).toMatch(/^Train 8001 \(voyage V-117\) for booking BK-2026-000928 has been cancelled/);
|
||||
});
|
||||
});
|
||||
@@ -14,8 +14,21 @@ import { NotificationInboxService } from '../notification-inbox/notification-inb
|
||||
import { resolveCompanyNotifyContact } from '../notifications/resolve-company-phone.util';
|
||||
import { resolveShippingLineNotifyTarget } from '../notifications/resolve-shipping-line-contact.util';
|
||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||
import { trainRunLabel, type TrainRunSource } from '../train-schedules/train-run-label.util';
|
||||
import { BATCH_TIMEZONE } from './booking-batch.constants';
|
||||
|
||||
const capitalize = (text: string): string => text.charAt(0).toUpperCase() + text.slice(1);
|
||||
|
||||
/**
|
||||
* " — reason: Locomotive maintenance" for the staff-entered reschedule reason,
|
||||
* or '' when none was given. Trailing punctuation is trimmed so the sentence's
|
||||
* own full stop follows cleanly.
|
||||
*/
|
||||
const reasonClause = (reason?: string | null): string => {
|
||||
const text = reason?.trim().replace(/[.\s]+$/, '');
|
||||
return text ? ` — reason: ${text}` : '';
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class BookingNotifierService {
|
||||
private readonly logger = new Logger(BookingNotifierService.name);
|
||||
@@ -30,8 +43,9 @@ export class BookingNotifierService {
|
||||
|
||||
/**
|
||||
* Human-readable description of a train schedule for customer messages:
|
||||
* reference (or train number) + route + departure date. Never leaks a UUID —
|
||||
* falls back to a generic phrase when the schedule can't be loaded.
|
||||
* train number + voyage number (both the SCHEDULE's own — see trainRunLabel),
|
||||
* then reference, route and departure date. Never leaks a UUID — falls back
|
||||
* to a generic phrase when the schedule can't be loaded.
|
||||
*/
|
||||
private async scheduleLabel(scheduleId?: string | null): Promise<string> {
|
||||
const fallback = 'your selected train';
|
||||
@@ -39,8 +53,10 @@ export class BookingNotifierService {
|
||||
try {
|
||||
const s = await this.trainSchedules.findByIdWithStations(scheduleId);
|
||||
if (!s) return fallback;
|
||||
// Customers know the train by its operating number (8001), not the
|
||||
// schedule reference — lead with it and keep S-… as the secondary id.
|
||||
// Customers know the departure by its train number (8001) and voyage
|
||||
// number, not the schedule reference — lead with those and keep S-… as
|
||||
// the secondary id.
|
||||
const run = trainRunLabel(s);
|
||||
const parts = [
|
||||
s.reference,
|
||||
s.originStation?.label && s.destinationStation?.label
|
||||
@@ -59,9 +75,9 @@ export class BookingNotifierService {
|
||||
hour12: false,
|
||||
})} EAT`
|
||||
: '';
|
||||
const number = s.trainNumber ?? s.reference ?? null;
|
||||
return number
|
||||
? `train ${number}${number === s.reference ? '' : detail}${departure}`
|
||||
if (run) return `${run}${detail}${departure}`;
|
||||
return s.reference
|
||||
? `train ${s.reference}${departure}`
|
||||
: `${fallback}${detail}${departure}`;
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
@@ -71,6 +87,51 @@ export class BookingNotifierService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "train 8001 (voyage V-117)" for the departure a message is about, or null
|
||||
* when nothing is known. Accepts the schedule row itself (preferred — callers
|
||||
* that have just cancelled or detached the booking still hold it) or its id,
|
||||
* falling back to the booking's own train_schedule_id. Never throws: a label
|
||||
* lookup must not stop a notification going out.
|
||||
*/
|
||||
private async trainRun(
|
||||
b: Booking,
|
||||
schedule?: TrainRunSource | string | null,
|
||||
): Promise<string | null> {
|
||||
if (schedule && typeof schedule !== 'string') return trainRunLabel(schedule);
|
||||
const scheduleId = schedule ?? b.trainScheduleId ?? null;
|
||||
if (!scheduleId) return null;
|
||||
try {
|
||||
const s = await this.trainSchedules.findByIdWithStations(scheduleId);
|
||||
return trainRunLabel(s);
|
||||
} catch (err) {
|
||||
this.logger.warn(`trainRun(${scheduleId}) failed: ${(err as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the run label, then build and send the SMS/email + in-app item.
|
||||
* Fire-and-forget like every notifier method; `build` receives the label
|
||||
* (null when unknown) and returns the message text.
|
||||
*/
|
||||
private withRun(
|
||||
b: Booking,
|
||||
schedule: TrainRunSource | string | null | undefined,
|
||||
logLabel: string,
|
||||
title: string,
|
||||
build: (run: string | null) => string,
|
||||
opts: { contact?: boolean; inApp?: Partial<NotifyInput> } = {},
|
||||
): void {
|
||||
void (async () => {
|
||||
const msg = build(await this.trainRun(b, schedule));
|
||||
if (opts.contact !== false) await this.notifyContact(b, msg, logLabel);
|
||||
this.inApp(b, title, msg, opts.inApp);
|
||||
})().catch((err) =>
|
||||
this.logger.warn(`${logLabel} notification failed for ${this.ref(b)}: ${(err as Error).message}`),
|
||||
);
|
||||
}
|
||||
|
||||
private ref(b: Booking): string {
|
||||
return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`;
|
||||
}
|
||||
@@ -162,21 +223,30 @@ export class BookingNotifierService {
|
||||
}
|
||||
|
||||
/** Train carrying the booking departed — dispatched origin → destination. */
|
||||
dispatched(b: Booking, origin: string | null, destination: string | null): void {
|
||||
const msg =
|
||||
dispatched(
|
||||
b: Booking,
|
||||
origin: string | null,
|
||||
destination: string | null,
|
||||
schedule?: TrainRunSource | string | null,
|
||||
): void {
|
||||
this.withRun(b, schedule, 'DISPATCHED', 'Shipment dispatched', (run) =>
|
||||
`Your booking ${b.reference ?? b.id} has been dispatched` +
|
||||
`${origin || destination ? ` from ${origin ?? '?'} to ${destination ?? '?'}` : ''}.`;
|
||||
void this.notifyContact(b, msg, 'DISPATCHED');
|
||||
this.inApp(b, 'Shipment dispatched', msg);
|
||||
`${origin || destination ? ` from ${origin ?? '?'} to ${destination ?? '?'}` : ''}` +
|
||||
`${run ? ` on ${run}` : ''}.`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Train carrying the booking arrived at destination. */
|
||||
arrived(b: Booking, origin: string | null, destination: string | null): void {
|
||||
const msg =
|
||||
`Your booking ${b.reference ?? b.id} has arrived` +
|
||||
`${destination ? ` at ${destination}` : ''}${origin ? ` (from ${origin})` : ''}.`;
|
||||
void this.notifyContact(b, msg, 'ARRIVED');
|
||||
this.inApp(b, 'Shipment arrived', msg);
|
||||
arrived(
|
||||
b: Booking,
|
||||
origin: string | null,
|
||||
destination: string | null,
|
||||
schedule?: TrainRunSource | string | null,
|
||||
): void {
|
||||
this.withRun(b, schedule, 'ARRIVED', 'Shipment arrived', (run) =>
|
||||
`Your booking ${b.reference ?? b.id}${run ? ` on ${run}` : ''} has arrived` +
|
||||
`${destination ? ` at ${destination}` : ''}${origin ? ` (from ${origin})` : ''}.`,
|
||||
);
|
||||
}
|
||||
|
||||
async payNow(b: Booking, deadline: Date): Promise<void> {
|
||||
@@ -318,21 +388,28 @@ export class BookingNotifierService {
|
||||
);
|
||||
}
|
||||
|
||||
displaced(b: Booking): void {
|
||||
const msg = `Booking ${b.reference ?? b.id} was displaced by a government booking. Move to another schedule or cancel.`;
|
||||
void this.notifyContact(b, msg, 'DISPLACED');
|
||||
this.inApp(b, 'Booking displaced', msg);
|
||||
displaced(b: Booking, schedule?: TrainRunSource | string | null): void {
|
||||
this.withRun(b, schedule, 'DISPLACED', 'Booking displaced', (run) =>
|
||||
`Booking ${b.reference ?? b.id} was displaced${run ? ` from ${run}` : ''} by a government booking. ` +
|
||||
`Move to another schedule or cancel.`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff rescheduled the train carrying this booking to a new departure date.
|
||||
* The booking stays on the train — only the date moved.
|
||||
*/
|
||||
rescheduled(b: Booking, newDeparture: Date): void {
|
||||
rescheduled(
|
||||
b: Booking,
|
||||
newDeparture: Date,
|
||||
schedule?: TrainRunSource | string | null,
|
||||
reason?: string | null,
|
||||
): void {
|
||||
const when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE });
|
||||
const msg = `Booking ${b.reference ?? b.id} has been rescheduled. New departure date: ${when}.`;
|
||||
void this.notifyContact(b, msg, 'RESCHEDULED');
|
||||
this.inApp(b, 'Booking rescheduled', msg);
|
||||
this.withRun(b, schedule, 'RESCHEDULED', 'Booking rescheduled', (run) =>
|
||||
`Booking ${b.reference ?? b.id}${run ? ` on ${run}` : ''} has been rescheduled` +
|
||||
`${reasonClause(reason)}. New departure date: ${when}.`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -340,49 +417,75 @@ export class BookingNotifierService {
|
||||
* the customer's original choice. In-app only — staff drove the change and
|
||||
* the allocation itself already notifies through the secured path.
|
||||
*/
|
||||
allocatedOtherDay(b: Booking, newDeparture: Date): void {
|
||||
allocatedOtherDay(
|
||||
b: Booking,
|
||||
newDeparture: Date,
|
||||
schedule?: TrainRunSource | string | null,
|
||||
): void {
|
||||
const when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE });
|
||||
const msg =
|
||||
`Booking ${b.reference ?? b.id} has been allocated to a train on a different date. ` +
|
||||
`New departure date: ${when}.`;
|
||||
this.inApp(b, 'Booking allocated to another date', msg);
|
||||
this.withRun(
|
||||
b,
|
||||
schedule,
|
||||
'ALLOCATED OTHER DAY',
|
||||
'Booking allocated to another date',
|
||||
(run) =>
|
||||
`Booking ${b.reference ?? b.id} has been allocated to ${run ?? 'a train'} on a different date. ` +
|
||||
`New departure date: ${when}.`,
|
||||
{ contact: false },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Booking was removed from its train during a staff reschedule (not a government
|
||||
* pre-empt). It returns to eligible — the customer must rebook or reschedule.
|
||||
*/
|
||||
removedFromTrain(b: Booking): void {
|
||||
const msg =
|
||||
`Booking ${b.reference ?? b.id} has been removed from its train during rescheduling. ` +
|
||||
`Please rebook or select a new schedule from the portal.`;
|
||||
void this.notifyContact(b, msg, 'REMOVED FROM TRAIN');
|
||||
this.inApp(b, 'Removed from train', msg);
|
||||
removedFromTrain(b: Booking, schedule?: TrainRunSource | string | null): void {
|
||||
this.withRun(b, schedule, 'REMOVED FROM TRAIN', 'Removed from train', (run) =>
|
||||
`Booking ${b.reference ?? b.id} has been removed from ${run ?? 'its train'} during rescheduling. ` +
|
||||
`Please rebook or select a new schedule from the portal.`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The train carrying this booking was cancelled. The booking is detached and
|
||||
* returns to the eligible pool — the customer must rebook or pick a new schedule.
|
||||
*/
|
||||
scheduleCancelled(b: Booking): void {
|
||||
const msg =
|
||||
`The train for booking ${b.reference ?? b.id} has been cancelled. ` +
|
||||
`Your booking is not lost — please rebook or select a new schedule from the portal.`;
|
||||
void this.notifyContact(b, msg, 'TRAIN CANCELLED');
|
||||
scheduleCancelled(b: Booking, schedule?: TrainRunSource | string | null): void {
|
||||
// HIGH: a cancelled train invalidates the customer's plans — must reach SMS/email.
|
||||
this.inApp(b, 'Train cancelled', msg, { priority: NotificationPriority.HIGH });
|
||||
this.withRun(
|
||||
b,
|
||||
schedule,
|
||||
'TRAIN CANCELLED',
|
||||
'Train cancelled',
|
||||
(run) =>
|
||||
`${run ? capitalize(run) : 'The train'} for booking ${b.reference ?? b.id} has been cancelled. ` +
|
||||
`Your booking is not lost — please rebook or select a new schedule from the portal.`,
|
||||
{ inApp: { priority: NotificationPriority.HIGH } },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The train carrying this booking was moved for maintenance to a new departure
|
||||
* date. The booking stays on the train — only the date moved.
|
||||
* The train carrying this booking was moved (maintenance reschedule) to a new
|
||||
* departure date. The booking stays on the train — only the date moved. The
|
||||
* staff-entered reason is what the customer reads; "for maintenance" is only
|
||||
* the fallback when none was typed.
|
||||
*/
|
||||
maintenanceMoved(b: Booking, newDeparture: Date): void {
|
||||
maintenanceMoved(
|
||||
b: Booking,
|
||||
newDeparture: Date,
|
||||
schedule?: TrainRunSource | string | null,
|
||||
reason?: string | null,
|
||||
): void {
|
||||
const when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE });
|
||||
const msg =
|
||||
`The train for booking ${b.reference ?? b.id} was rescheduled for maintenance. ` +
|
||||
`New departure date: ${when}.`;
|
||||
void this.notifyContact(b, msg, 'MAINTENANCE RESCHEDULE');
|
||||
this.inApp(b, 'Train maintenance reschedule', msg);
|
||||
const why = reason?.trim() ? reasonClause(reason) : ' for maintenance';
|
||||
this.withRun(
|
||||
b,
|
||||
schedule,
|
||||
'MAINTENANCE RESCHEDULE',
|
||||
'Train rescheduled',
|
||||
(run) =>
|
||||
`${run ? capitalize(run) : 'The train'} for booking ${b.reference ?? b.id} was rescheduled${why}. ` +
|
||||
`New departure date: ${when}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||
import { trainRunLabel } from '../train-schedules/train-run-label.util';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import {
|
||||
@@ -671,8 +672,11 @@ export class BookingWindowService implements OnModuleInit {
|
||||
const depart = schedule.scheduledDepartureDate.toLocaleDateString('en-GB', {
|
||||
timeZone: BATCH_TIMEZONE,
|
||||
});
|
||||
// Name the departure by the schedule's train + voyage numbers (never the
|
||||
// built train's name) so customers can match it to yard/customs paperwork.
|
||||
const run = trainRunLabel(schedule);
|
||||
const msg =
|
||||
`Booking is now open for the train departing ${depart}. ` +
|
||||
`Booking is now open for ${run ?? 'the train'} departing ${depart}. ` +
|
||||
`Book your shipment from the portal home page before ${closes} EAT.`;
|
||||
|
||||
const seenPhone = new Set<string>();
|
||||
@@ -797,8 +801,9 @@ export class BookingWindowService implements OnModuleInit {
|
||||
const depart = schedule.scheduledDepartureDate.toLocaleDateString('en-GB', {
|
||||
timeZone: BATCH_TIMEZONE,
|
||||
});
|
||||
const run = trainRunLabel(schedule, { capitalize: true });
|
||||
const msgFor = (corridors: string[]) =>
|
||||
`A train is scheduled on your intercity corridor ${corridors.join(', ')}, ` +
|
||||
`${run ?? 'A train'} is scheduled on your intercity corridor ${corridors.join(', ')}, ` +
|
||||
`departing ${depart}. EDR will confirm once your cargo is placed on a train.`;
|
||||
|
||||
// One inbox item per booking (its `data` is the once-per-booking marker
|
||||
|
||||
@@ -6,10 +6,13 @@ import {
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
@@ -119,6 +122,19 @@ export class CreateContainerTrainScheduleDto {
|
||||
@IsDateString()
|
||||
scheduleDate!: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'V-2026-0620',
|
||||
maxLength: 20,
|
||||
description:
|
||||
'Voyage (sailing) number for this departure — the run identifier yards and ' +
|
||||
'customs quote. Required at creation; the UI pre-fills it with the built ' +
|
||||
"train's direction-matched run number, but staff may override it.",
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'A voyage number is required' })
|
||||
@MaxLength(20)
|
||||
voyageNumber!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description:
|
||||
|
||||
@@ -439,6 +439,7 @@ export class IntercityService {
|
||||
id: booking.id,
|
||||
reference: booking.reference,
|
||||
status: booking.status,
|
||||
paymentStatus: booking.paymentStatus ?? null,
|
||||
freightType: booking.freightType,
|
||||
isGovernment: booking.isGovernment,
|
||||
customer: booking.company?.name ?? 'Unknown customer',
|
||||
|
||||
@@ -508,6 +508,7 @@ describe('TrainSchedulingService', () => {
|
||||
const result = await service.createContainerTrainSchedule({
|
||||
routeId: 'route-1',
|
||||
scheduleDate: futureDeparture,
|
||||
voyageNumber: 'V-TEST-1',
|
||||
locomotiveIds: ['loc-1', 'loc-2'],
|
||||
});
|
||||
|
||||
@@ -610,6 +611,7 @@ describe('TrainSchedulingService', () => {
|
||||
service.createContainerTrainSchedule({
|
||||
routeId: 'route-1',
|
||||
scheduleDate: '2026-06-20T08:00:00.000Z',
|
||||
voyageNumber: 'V-TEST-2',
|
||||
locomotiveIds: ['loc-1', 'loc-2'],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
@@ -1951,6 +1953,8 @@ describe('TrainSchedulingService', () => {
|
||||
save: jest.fn().mockResolvedValue(undefined),
|
||||
create: jest.fn((x: unknown) => x),
|
||||
})),
|
||||
// Wagon-history lookup of the released allocations' physical wagons.
|
||||
query: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
TrainCheckpointKind,
|
||||
TrainScheduleStatus as TrainScheduleStatusEnum,
|
||||
WagonAllocationSnapshot,
|
||||
WagonEventType,
|
||||
WagonMovementKind,
|
||||
WagonStatus,
|
||||
} from '@edr/types';
|
||||
@@ -72,6 +73,7 @@ import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||
import { WagonTypesRepository } from '../../wagon-types/wagon-types.repository';
|
||||
import { Wagon } from '../../wagons/entities/wagon.entity';
|
||||
import { WagonEventInput, WagonHistoryService } from '../../wagon-history/wagon-history.service';
|
||||
import { AdjustScheduleConsistDto } from '../dto/adjust-schedule-consist.dto';
|
||||
import { AssignBookingsDto } from '../dto/assign-bookings.dto';
|
||||
import { CreateContainerTrainScheduleDto } from '../dto/create-container-train-schedule.dto';
|
||||
@@ -422,8 +424,28 @@ export class TrainSchedulingService {
|
||||
@Optional()
|
||||
@Inject(forwardRef(() => BookingBatchService))
|
||||
private readonly bookingBatchService?: BookingBatchService,
|
||||
// Per-wagon history ledger (global module). @Optional keeps the positional
|
||||
// spec constructors working; production always has it.
|
||||
@Optional() private readonly wagonHistory?: WagonHistoryService,
|
||||
) {}
|
||||
|
||||
/** Physical wagons behind a set of booking allocations (via their slots), for cargo history rows. */
|
||||
private async wagonsOfAllocations(
|
||||
manager: EntityManager,
|
||||
allocationIds: string[],
|
||||
): Promise<Array<{ allocationId: string; wagonId: string; wagonNumber: string; yardId: string | null; trainId: string | null }>> {
|
||||
if (!allocationIds.length) return [];
|
||||
return manager.query(
|
||||
`SELECT a.id AS "allocationId", w.id AS "wagonId", w.wagon_number AS "wagonNumber",
|
||||
w.current_yard_id AS "yardId", w.train_id AS "trainId"
|
||||
FROM freight.wagon_booking_allocations a
|
||||
JOIN freight.train_set_wagons tsw ON tsw.id = a.train_set_wagon_id
|
||||
JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
|
||||
WHERE a.id = ANY($1::uuid[])`,
|
||||
[allocationIds],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify each booking's customer that their shipment was dispatched / arrived,
|
||||
* with a deep-link to the booking. Fire-and-forget — never blocks the action.
|
||||
@@ -443,8 +465,11 @@ export class TrainSchedulingService {
|
||||
relations: { company: true },
|
||||
});
|
||||
for (const b of bookings) {
|
||||
if (event === 'dispatched') this.bookingNotifier.dispatched(b, origin, destination);
|
||||
else this.bookingNotifier.arrived(b, origin, destination);
|
||||
if (event === 'dispatched') {
|
||||
this.bookingNotifier.dispatched(b, origin, destination, schedule);
|
||||
} else {
|
||||
this.bookingNotifier.arrived(b, origin, destination, schedule);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to notify schedule bookings (${event}): ${(err as Error).message}`);
|
||||
@@ -1170,7 +1195,7 @@ export class TrainSchedulingService {
|
||||
});
|
||||
for (const booking of allocatedBookings) {
|
||||
if (['CANCELLED', 'EXPIRED', 'REJECTED'].includes(booking.status)) continue;
|
||||
this.bookingNotifier.rescheduled(booking, departure);
|
||||
this.bookingNotifier.rescheduled(booking, departure, schedule);
|
||||
notifiedCount += 1;
|
||||
}
|
||||
}
|
||||
@@ -1375,7 +1400,7 @@ export class TrainSchedulingService {
|
||||
.getRepository(Booking)
|
||||
.update(aboard.map((b) => b.id), { scheduledDate: departure } as never);
|
||||
for (const booking of aboard) {
|
||||
this.bookingNotifier.maintenanceMoved(booking, departure);
|
||||
this.bookingNotifier.maintenanceMoved(booking, departure, schedule, dto.reason);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1872,6 +1897,10 @@ export class TrainSchedulingService {
|
||||
status: TrainScheduleStatusEnum.Scheduled,
|
||||
direction,
|
||||
trainNumber: pairTrainNumber ?? undefined,
|
||||
// Staff-entered at creation; the UI defaults it to the built train's
|
||||
// own voyage number (Train.trainName). Fall back to the pair train
|
||||
// number here only for non-UI callers that send none.
|
||||
voyageNumber: dto.voyageNumber?.trim() || pairTrainNumber || null,
|
||||
maxWagons,
|
||||
plannedWagonYards,
|
||||
reverseWagonOrder: dto.reverseWagonOrder ?? false,
|
||||
@@ -2415,6 +2444,21 @@ export class TrainSchedulingService {
|
||||
manager,
|
||||
);
|
||||
await this.wagonAllocationBulkLoadsRepository.deleteByAllocationIds(allocationIds, manager);
|
||||
const carried = await this.wagonsOfAllocations(manager, allocationIds);
|
||||
await this.wagonHistory?.record(
|
||||
manager,
|
||||
carried.map((c) => ({
|
||||
wagonId: c.wagonId,
|
||||
wagonNumber: c.wagonNumber,
|
||||
type: WagonEventType.BookingUnassigned,
|
||||
actorUserId: userId ?? null,
|
||||
fromYardId: c.yardId,
|
||||
trainId: c.trainId,
|
||||
trainScheduleId: scheduleId,
|
||||
bookingId,
|
||||
metadata: { allocationId: c.allocationId },
|
||||
})),
|
||||
);
|
||||
await manager.getRepository(WagonBookingAllocation).delete(allocationIds);
|
||||
}
|
||||
|
||||
@@ -2481,6 +2525,18 @@ export class TrainSchedulingService {
|
||||
trainSetWagonId: null,
|
||||
status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
|
||||
});
|
||||
await this.wagonHistory?.record(manager, {
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.ReleasedFromSchedule,
|
||||
actorUserId: userId ?? null,
|
||||
fromYardId: wagon.currentYardId ?? null,
|
||||
trainId: wagon.trainId ?? null,
|
||||
trainScheduleId: scheduleId,
|
||||
bookingId,
|
||||
toValue: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
|
||||
reason: 'Booking unassigned from the dispatched train',
|
||||
});
|
||||
}
|
||||
}
|
||||
await manager.getRepository(TrainSetWagon).delete(slot.id);
|
||||
@@ -2531,7 +2587,8 @@ export class TrainSchedulingService {
|
||||
.getRepository(Booking)
|
||||
.findOne({ where: { id: bookingId }, relations: { company: true } });
|
||||
if (removedBooking && opts.notifyCustomer !== false) {
|
||||
this.bookingNotifier.removedFromTrain(removedBooking);
|
||||
// The booking's train_schedule_id is already cleared — name the run explicitly.
|
||||
this.bookingNotifier.removedFromTrain(removedBooking, schedule);
|
||||
}
|
||||
this.logger.log(
|
||||
`Booking ${bookingReference} removed from schedule ${scheduleId} by user ${userId ?? 'unknown'} — customer notified to reschedule or cancel.`,
|
||||
@@ -2823,10 +2880,34 @@ export class TrainSchedulingService {
|
||||
|
||||
// The pin lives ONLY on the schedule's slot — the Wagon entity keeps
|
||||
// its status untouched so other schedules can still use the wagon.
|
||||
const previousPinId = slotById.get(assignment.trainSetWagonId)?.physicalWagonId ?? null;
|
||||
await manager.getRepository(TrainSetWagon).update(assignment.trainSetWagonId, {
|
||||
physicalWagonId: assignment.physicalWagonId,
|
||||
status: 'RESERVED',
|
||||
});
|
||||
if (previousPinId !== assignment.physicalWagonId) {
|
||||
const pinEvents: WagonEventInput[] = [
|
||||
{
|
||||
wagonId: assignment.physicalWagonId,
|
||||
type: WagonEventType.PinnedToSchedule,
|
||||
trainScheduleId: scheduleId,
|
||||
trainId: builtTrainId ?? null,
|
||||
fromYardId: schedule.originStationId ?? null,
|
||||
metadata: { slotId: assignment.trainSetWagonId, auto: false },
|
||||
},
|
||||
];
|
||||
if (previousPinId) {
|
||||
pinEvents.push({
|
||||
wagonId: previousPinId,
|
||||
type: WagonEventType.UnpinnedFromSchedule,
|
||||
trainScheduleId: scheduleId,
|
||||
trainId: builtTrainId ?? null,
|
||||
reason: 'Replaced on the slot',
|
||||
metadata: { slotId: assignment.trainSetWagonId },
|
||||
});
|
||||
}
|
||||
await this.wagonHistory?.record(manager, pinEvents);
|
||||
}
|
||||
for (const [physicalId, slotId] of slotIdByPhysicalId) {
|
||||
if (slotId === assignment.trainSetWagonId) {
|
||||
slotIdByPhysicalId.delete(physicalId);
|
||||
@@ -3020,6 +3101,25 @@ export class TrainSchedulingService {
|
||||
{ id: In(dispatchedPhysicalIds) },
|
||||
{ status: WagonStatus.Assigned, currentTrainScheduleId: scheduleId },
|
||||
);
|
||||
const dispatchedWagons = await manager.getRepository(Wagon).find({
|
||||
where: { id: In(dispatchedPhysicalIds) },
|
||||
select: { id: true, wagonNumber: true, currentYardId: true, trainId: true },
|
||||
});
|
||||
await this.wagonHistory?.record(
|
||||
manager,
|
||||
dispatchedWagons.map((w) => ({
|
||||
wagonId: w.id,
|
||||
wagonNumber: w.wagonNumber,
|
||||
type: WagonEventType.Dispatched,
|
||||
occurredAt: now,
|
||||
actorUserId: userId ?? null,
|
||||
fromYardId: w.currentYardId ?? null,
|
||||
trainId: w.trainId ?? schedule.trainSet?.trainId ?? null,
|
||||
trainScheduleId: scheduleId,
|
||||
toValue: WagonStatus.Assigned,
|
||||
metadata: { destinationYardId: schedule.destinationStationId ?? null },
|
||||
})),
|
||||
);
|
||||
}
|
||||
// Planned couples boarding at the ORIGIN join the built train now — the
|
||||
// departure is the moment they are physically hooked on. Mid-route
|
||||
@@ -3057,6 +3157,32 @@ export class TrainSchedulingService {
|
||||
status: WagonStatus.Assigned,
|
||||
currentTrainScheduleId: scheduleId,
|
||||
});
|
||||
await this.wagonHistory?.record(manager, [
|
||||
{
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.CoupledToTrain,
|
||||
occurredAt: now,
|
||||
actorUserId: userId ?? null,
|
||||
fromYardId: coupleYardId,
|
||||
trainId: dispatchTrainId,
|
||||
trainScheduleId: scheduleId,
|
||||
toValue: maxSeq,
|
||||
reason: 'Planned couple at the origin yard',
|
||||
metadata: { status: { from: wagon.status, to: WagonStatus.Assigned } },
|
||||
},
|
||||
{
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.Dispatched,
|
||||
occurredAt: now,
|
||||
actorUserId: userId ?? null,
|
||||
fromYardId: coupleYardId,
|
||||
trainId: dispatchTrainId,
|
||||
trainScheduleId: scheduleId,
|
||||
toValue: WagonStatus.Assigned,
|
||||
},
|
||||
]);
|
||||
await manager.getRepository(ScheduleWagonAdjustmentLog).save(
|
||||
manager.getRepository(ScheduleWagonAdjustmentLog).create({
|
||||
trainScheduleId: scheduleId,
|
||||
@@ -3102,7 +3228,7 @@ export class TrainSchedulingService {
|
||||
AND b.deleted_at IS NULL
|
||||
AND b.origin_yard_id = $2
|
||||
AND b.loaded_at IS NULL
|
||||
AND (b.status = 'PAID' OR (b.is_government = true AND b.status = 'APPROVED'))
|
||||
AND (b.payment_status = 'PAID' OR (b.is_government = true AND b.status = 'APPROVED'))
|
||||
AND ($4::uuid[] IS NULL OR b.id = ANY($4::uuid[]))`,
|
||||
[
|
||||
scheduleId,
|
||||
@@ -3319,7 +3445,7 @@ export class TrainSchedulingService {
|
||||
AND b.loading_started_at IS NULL
|
||||
AND COALESCE(tsb.loading_status, 'UNLOADED') <> 'LOADED'
|
||||
AND b.is_government = false
|
||||
AND (b.status = 'PAID'
|
||||
AND (b.payment_status = 'PAID'
|
||||
OR (b.shipping_line_company_id IS NOT NULL AND b.status = 'FULLY_EXECUTED'))`,
|
||||
[scheduleId, originYardId],
|
||||
);
|
||||
@@ -3433,7 +3559,7 @@ export class TrainSchedulingService {
|
||||
// milestone still counts as paid — the clearance views self-heal the row on
|
||||
// read, and the gate pass must not lag behind that.
|
||||
for (const booking of bookings) {
|
||||
if (booking.paymentStatus === 'PAID' || booking.status === 'PAID') {
|
||||
if (booking.paymentStatus === 'PAID') {
|
||||
paidBookingIds.add(booking.id);
|
||||
}
|
||||
}
|
||||
@@ -5177,6 +5303,7 @@ export class TrainSchedulingService {
|
||||
);
|
||||
const adjustmentRows: ScheduleWagonAdjustmentLog[] = [];
|
||||
const movementRows: WagonMovement[] = [];
|
||||
const historyRows: WagonEventInput[] = [];
|
||||
let realCutHappened = false;
|
||||
for (const [wagonId, cutYardId] of cutNow) {
|
||||
const wagon = cutWagonById.get(wagonId);
|
||||
@@ -5184,6 +5311,31 @@ export class TrainSchedulingService {
|
||||
if (!wagon || wagon.currentTrainScheduleId !== scheduleId) continue;
|
||||
if (realCutIds.has(wagonId) && builtTrainId) {
|
||||
// REAL cut: the built train permanently loses the wagon here.
|
||||
historyRows.push(
|
||||
{
|
||||
wagonId,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.CutAtYard,
|
||||
occurredAt,
|
||||
fromYardId: scheduleYardOf(schedule.plannedWagonYards, wagon) ?? schedule.originStationId ?? null,
|
||||
toYardId: cutYardId,
|
||||
trainId: builtTrainId,
|
||||
trainScheduleId: scheduleId,
|
||||
toValue: WagonStatus.Available,
|
||||
metadata: { permanent: true },
|
||||
},
|
||||
{
|
||||
wagonId,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.UncoupledFromTrain,
|
||||
occurredAt,
|
||||
fromYardId: cutYardId,
|
||||
trainId: builtTrainId,
|
||||
trainScheduleId: scheduleId,
|
||||
fromValue: wagon.sequenceNumber,
|
||||
reason: 'Cut from the train at this yard (permanent)',
|
||||
},
|
||||
);
|
||||
await manager.getRepository(Wagon).update(wagonId, {
|
||||
currentYardId: cutYardId,
|
||||
currentTrainScheduleId: null,
|
||||
@@ -5216,6 +5368,18 @@ export class TrainSchedulingService {
|
||||
realCutHappened = true;
|
||||
} else {
|
||||
// Soft cut: sits out the rest of this trip, stays in the build.
|
||||
historyRows.push({
|
||||
wagonId,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.CutAtYard,
|
||||
occurredAt,
|
||||
fromYardId: scheduleYardOf(schedule.plannedWagonYards, wagon) ?? schedule.originStationId ?? null,
|
||||
toYardId: cutYardId,
|
||||
trainId: wagon.trainId ?? null,
|
||||
trainScheduleId: scheduleId,
|
||||
toValue: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
|
||||
metadata: { permanent: false },
|
||||
});
|
||||
await manager.getRepository(Wagon).update(wagonId, {
|
||||
currentYardId: cutYardId,
|
||||
currentTrainScheduleId: null,
|
||||
@@ -5240,6 +5404,7 @@ export class TrainSchedulingService {
|
||||
if (movementRows.length) {
|
||||
await manager.getRepository(WagonMovement).save(movementRows);
|
||||
}
|
||||
await this.wagonHistory?.record(manager, historyRows);
|
||||
// Keep the coupling order gapless after permanent removals.
|
||||
if (realCutHappened && builtTrainId) {
|
||||
const remaining = await manager.getRepository(Wagon).find({
|
||||
@@ -5293,6 +5458,18 @@ export class TrainSchedulingService {
|
||||
status: WagonStatus.Assigned,
|
||||
currentTrainScheduleId: scheduleId,
|
||||
});
|
||||
await this.wagonHistory?.record(manager, {
|
||||
wagonId,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.CoupledToTrain,
|
||||
occurredAt,
|
||||
fromYardId: coupleYardId,
|
||||
trainId: builtTrainId,
|
||||
trainScheduleId: scheduleId,
|
||||
toValue: maxSeq,
|
||||
reason: 'Planned couple at a mid-route stop',
|
||||
metadata: { status: { from: wagon.status, to: WagonStatus.Assigned } },
|
||||
});
|
||||
coupleLogRows.push(
|
||||
manager.getRepository(ScheduleWagonAdjustmentLog).create({
|
||||
trainScheduleId: scheduleId,
|
||||
@@ -5310,6 +5487,20 @@ export class TrainSchedulingService {
|
||||
await manager.getRepository(ScheduleWagonAdjustmentLog).save(coupleLogRows);
|
||||
}
|
||||
}
|
||||
// Which wagons the position fix below will actually move — read first
|
||||
// so each gets its own PASSED_CHECKPOINT history row (from → to yard).
|
||||
const riding = await manager
|
||||
.getRepository(Wagon)
|
||||
.createQueryBuilder('w')
|
||||
.select(['w.id', 'w.wagonNumber', 'w.currentYardId', 'w.trainId'])
|
||||
.where('w.current_train_schedule_id = :scheduleId', { scheduleId })
|
||||
.andWhere('(w.current_yard_id IS NULL OR w.current_yard_id IN (:...passedYardIds))', {
|
||||
passedYardIds,
|
||||
})
|
||||
.andWhere('w.current_yard_id IS DISTINCT FROM :stationYardId', {
|
||||
stationYardId: station.yardId,
|
||||
})
|
||||
.getMany();
|
||||
|
||||
// Leg slots (booking legs boarding/alighting mid-corridor — see
|
||||
// stampSlotLegs) reaching their board/alight yard here: logged same as
|
||||
@@ -5392,6 +5583,20 @@ export class TrainSchedulingService {
|
||||
passedYardIds,
|
||||
})
|
||||
.execute();
|
||||
await this.wagonHistory?.record(
|
||||
manager,
|
||||
riding.map((w) => ({
|
||||
wagonId: w.id,
|
||||
wagonNumber: w.wagonNumber,
|
||||
type: WagonEventType.PassedCheckpoint,
|
||||
occurredAt,
|
||||
fromYardId: w.currentYardId ?? null,
|
||||
toYardId: station.yardId,
|
||||
trainId: w.trainId ?? schedule.trainSet?.trainId ?? null,
|
||||
trainScheduleId: scheduleId,
|
||||
metadata: { sequenceNo: dto.sequenceNo, kind: dto.kind ?? null },
|
||||
})),
|
||||
);
|
||||
if (schedule.trainSet?.trainId) {
|
||||
await manager
|
||||
.getRepository(Train)
|
||||
@@ -5618,6 +5823,7 @@ export class TrainSchedulingService {
|
||||
);
|
||||
const arrivalLogRows: ScheduleWagonAdjustmentLog[] = [];
|
||||
const arrivalMovementRows: WagonMovement[] = [];
|
||||
const arrivalHistoryRows: WagonEventInput[] = [];
|
||||
for (const slot of schedule.trainSet?.wagons ?? []) {
|
||||
if (!slot.physicalWagonId) continue;
|
||||
const wagon = settleWagonById.get(slot.physicalWagonId);
|
||||
@@ -5641,6 +5847,32 @@ export class TrainSchedulingService {
|
||||
// Arrival fallback for a journey logged without mid-route
|
||||
// checkpoints: the REAL cut still permanently removes the wagon
|
||||
// from the built train at its cut yard.
|
||||
arrivalHistoryRows.push(
|
||||
{
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.CutAtYard,
|
||||
occurredAt: now,
|
||||
fromYardId: slot.boardYardId ?? schedule.originStationId ?? null,
|
||||
toYardId: settleYardId,
|
||||
trainId: ownerTrainId,
|
||||
trainScheduleId: scheduleId,
|
||||
bookingId: (slot.allocations ?? [])[0]?.bookingId ?? null,
|
||||
toValue: WagonStatus.Available,
|
||||
metadata: { permanent: true, atArrival: true },
|
||||
},
|
||||
{
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.UncoupledFromTrain,
|
||||
occurredAt: now,
|
||||
fromYardId: settleYardId,
|
||||
trainId: ownerTrainId,
|
||||
trainScheduleId: scheduleId,
|
||||
fromValue: wagon.sequenceNumber,
|
||||
reason: 'Cut from the train at its planned yard (permanent)',
|
||||
},
|
||||
);
|
||||
await manager.getRepository(Wagon).update(wagon.id, {
|
||||
currentTrainScheduleId: null,
|
||||
trainSetWagonId: null,
|
||||
@@ -5670,6 +5902,19 @@ export class TrainSchedulingService {
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
arrivalHistoryRows.push({
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.SettledOnArrival,
|
||||
occurredAt: now,
|
||||
fromYardId: slot.boardYardId ?? schedule.originStationId ?? null,
|
||||
toYardId: settleYardId,
|
||||
trainId: wagon.trainId ?? null,
|
||||
trainScheduleId: scheduleId,
|
||||
bookingId: (slot.allocations ?? [])[0]?.bookingId ?? null,
|
||||
toValue: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
|
||||
metadata: { slotId: slot.id, loaded: (slot.allocations ?? []).length > 0 },
|
||||
});
|
||||
await manager.getRepository(Wagon).update(wagon.id, {
|
||||
currentTrainScheduleId: null,
|
||||
trainSetWagonId: null,
|
||||
@@ -5721,6 +5966,18 @@ export class TrainSchedulingService {
|
||||
if (!wagon) continue;
|
||||
if (wagon.currentTrainScheduleId === scheduleId) {
|
||||
// Joined during the trip, slot-less: settle at the destination.
|
||||
arrivalHistoryRows.push({
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.SettledOnArrival,
|
||||
occurredAt: now,
|
||||
fromYardId: coupleYardId,
|
||||
toYardId: schedule.destinationStationId ?? null,
|
||||
trainId: wagon.trainId ?? null,
|
||||
trainScheduleId: scheduleId,
|
||||
toValue: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
|
||||
metadata: { loaded: false, coupledMidRoute: true },
|
||||
});
|
||||
await manager.getRepository(Wagon).update(wagon.id, {
|
||||
currentTrainScheduleId: null,
|
||||
trainSetWagonId: null,
|
||||
@@ -5758,6 +6015,32 @@ export class TrainSchedulingService {
|
||||
status: WagonStatus.Assigned,
|
||||
currentYardId: schedule.destinationStationId,
|
||||
});
|
||||
arrivalHistoryRows.push(
|
||||
{
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.CoupledToTrain,
|
||||
occurredAt: now,
|
||||
fromYardId: coupleYardId,
|
||||
trainId: arrivalTrainId,
|
||||
trainScheduleId: scheduleId,
|
||||
toValue: arrivalMaxSeq,
|
||||
reason: 'Planned couple joined on arrival',
|
||||
metadata: { status: { from: wagon.status, to: WagonStatus.Assigned } },
|
||||
},
|
||||
{
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.SettledOnArrival,
|
||||
occurredAt: now,
|
||||
fromYardId: coupleYardId,
|
||||
toYardId: schedule.destinationStationId ?? null,
|
||||
trainId: arrivalTrainId,
|
||||
trainScheduleId: scheduleId,
|
||||
toValue: WagonStatus.Assigned,
|
||||
metadata: { loaded: false, coupledMidRoute: true },
|
||||
},
|
||||
);
|
||||
arrivalLogRows.push(
|
||||
manager.getRepository(ScheduleWagonAdjustmentLog).create({
|
||||
trainScheduleId: scheduleId,
|
||||
@@ -5787,6 +6070,23 @@ export class TrainSchedulingService {
|
||||
// per-slot settle above never sees them. Release them here or they stay
|
||||
// locked to a finished schedule and no later train can pick them up.
|
||||
// They carry no cargo, so they simply settle where the train ended up.
|
||||
const looseEmpties = await manager.getRepository(Wagon).find({
|
||||
where: { currentTrainScheduleId: scheduleId },
|
||||
select: { id: true, wagonNumber: true, currentYardId: true, trainId: true },
|
||||
});
|
||||
arrivalHistoryRows.push(
|
||||
...looseEmpties.map((w) => ({
|
||||
wagonId: w.id,
|
||||
wagonNumber: w.wagonNumber,
|
||||
type: WagonEventType.SettledOnArrival,
|
||||
occurredAt: now,
|
||||
fromYardId: w.currentYardId ?? null,
|
||||
toYardId: schedule.destinationStationId ?? null,
|
||||
trainId: w.trainId ?? null,
|
||||
trainScheduleId: scheduleId,
|
||||
metadata: { loaded: false, consistOnly: true },
|
||||
})),
|
||||
);
|
||||
await manager
|
||||
.getRepository(Wagon)
|
||||
.createQueryBuilder()
|
||||
@@ -5801,6 +6101,7 @@ export class TrainSchedulingService {
|
||||
if (arrivalLogRows.length) {
|
||||
await manager.getRepository(ScheduleWagonAdjustmentLog).save(arrivalLogRows);
|
||||
}
|
||||
await this.wagonHistory?.record(manager, arrivalHistoryRows);
|
||||
if (arrivalMovementRows.length) {
|
||||
await manager.getRepository(WagonMovement).save(arrivalMovementRows);
|
||||
}
|
||||
@@ -5997,6 +6298,19 @@ export class TrainSchedulingService {
|
||||
}
|
||||
for (const wagon of schedule.trainSet?.wagons ?? []) {
|
||||
if (wagon.physicalWagonId) {
|
||||
await this.wagonHistory?.record(manager, {
|
||||
wagonId: wagon.physicalWagonId,
|
||||
wagonNumber: wagon.physicalWagon?.wagonNumber ?? null,
|
||||
type: WagonEventType.ReturnedOnCancel,
|
||||
actorUserId: userId ?? null,
|
||||
fromYardId: wagon.physicalWagon?.currentYardId ?? null,
|
||||
toYardId: schedule.originStationId ?? null,
|
||||
trainId: wagon.physicalWagon?.trainId ?? null,
|
||||
trainScheduleId: id,
|
||||
toValue: wagon.physicalWagon?.trainId ? WagonStatus.Assigned : WagonStatus.Available,
|
||||
reason: dto?.reason?.trim() || 'Schedule cancelled',
|
||||
metadata: { slotId: wagon.id },
|
||||
});
|
||||
await manager.getRepository(Wagon).update(wagon.physicalWagonId, {
|
||||
currentTrainScheduleId: null,
|
||||
trainSetWagonId: null,
|
||||
@@ -6033,7 +6347,8 @@ export class TrainSchedulingService {
|
||||
const booking = await this.bookingsRepository
|
||||
.findByIdWithFiles(sb.bookingId)
|
||||
.catch(() => null);
|
||||
if (booking) this.bookingNotifier.scheduleCancelled(booking);
|
||||
// Detached above, so pass the cancelled schedule for its train/voyage numbers.
|
||||
if (booking) this.bookingNotifier.scheduleCancelled(booking, schedule);
|
||||
}
|
||||
|
||||
// Window retired (DONE) — remove the card from portal/GL lists right away.
|
||||
@@ -6973,6 +7288,15 @@ export class TrainSchedulingService {
|
||||
physicalWagonId: physical.id,
|
||||
status: 'RESERVED',
|
||||
});
|
||||
await this.wagonHistory?.record(manager, {
|
||||
wagonId: physical.id,
|
||||
wagonNumber: physical.wagonNumber,
|
||||
type: WagonEventType.PinnedToSchedule,
|
||||
trainScheduleId: scheduleId,
|
||||
trainId: builtTrainId ?? null,
|
||||
fromYardId: physical.currentYardId ?? null,
|
||||
metadata: { slotId: slot.trainSetWagonId, auto: true },
|
||||
});
|
||||
const pinnedSpans = occupiedSpans.get(physical.id) ?? [];
|
||||
pinnedSpans.push(span);
|
||||
occupiedSpans.set(physical.id, pinnedSpans);
|
||||
@@ -9049,6 +9373,21 @@ export class TrainSchedulingService {
|
||||
for (const wagon of removed) {
|
||||
await manager.getRepository(Wagon).update(wagon.id, detachPatch);
|
||||
}
|
||||
const consistReason = (dto as { reason?: string | null }).reason?.trim() || null;
|
||||
await this.wagonHistory?.record(
|
||||
manager,
|
||||
removed.map((wagon) => ({
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.UncoupledFromTrain,
|
||||
actorUserId: userId ?? null,
|
||||
trainId: train.id,
|
||||
trainScheduleId: scheduleId,
|
||||
fromYardId: currentYardId ?? null,
|
||||
fromValue: wagon.sequenceNumber,
|
||||
reason: consistReason ?? 'Trimmed from the consist on the schedule',
|
||||
})),
|
||||
);
|
||||
if (removed.length && ownSetIds.length) {
|
||||
// This train's own pins (all its runs) on trimmed wagons are stale —
|
||||
// clear them so the freed wagon isn't still claimed by slots it left.
|
||||
@@ -9086,6 +9425,32 @@ export class TrainSchedulingService {
|
||||
// Mirror on the in-memory row — the compaction below sorts by it.
|
||||
to.sequenceNumber = from.sequenceNumber;
|
||||
await manager.getRepository(Wagon).update(from.id, detachPatch);
|
||||
await this.wagonHistory?.record(manager, [
|
||||
{
|
||||
wagonId: to.id,
|
||||
wagonNumber: to.wagonNumber,
|
||||
type: WagonEventType.CoupledToTrain,
|
||||
actorUserId: userId ?? null,
|
||||
trainId: train.id,
|
||||
trainScheduleId: scheduleId,
|
||||
fromYardId: to.currentYardId ?? null,
|
||||
toValue: from.sequenceNumber,
|
||||
reason: consistReason ?? `Switched in for ${from.wagonNumber}`,
|
||||
metadata: { replaced: from.wagonNumber, replacedWagonId: from.id },
|
||||
},
|
||||
{
|
||||
wagonId: from.id,
|
||||
wagonNumber: from.wagonNumber,
|
||||
type: WagonEventType.UncoupledFromTrain,
|
||||
actorUserId: userId ?? null,
|
||||
trainId: train.id,
|
||||
trainScheduleId: scheduleId,
|
||||
fromYardId: currentYardId ?? null,
|
||||
fromValue: from.sequenceNumber,
|
||||
reason: consistReason ?? `Switched out for ${to.wagonNumber}`,
|
||||
metadata: { replacedBy: to.wagonNumber, replacedByWagonId: to.id },
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
const remaining = consist.filter(
|
||||
@@ -9101,6 +9466,7 @@ export class TrainSchedulingService {
|
||||
}
|
||||
}
|
||||
let sequence = compacted.length;
|
||||
const addedEvents: WagonEventInput[] = [];
|
||||
for (const wagon of added) {
|
||||
sequence += 1;
|
||||
await manager.getRepository(Wagon).update(wagon.id, {
|
||||
@@ -9108,7 +9474,20 @@ export class TrainSchedulingService {
|
||||
sequenceNumber: sequence,
|
||||
status: WagonStatus.Assigned,
|
||||
});
|
||||
addedEvents.push({
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.CoupledToTrain,
|
||||
actorUserId: userId ?? null,
|
||||
trainId: train.id,
|
||||
trainScheduleId: scheduleId,
|
||||
fromYardId: wagon.currentYardId ?? null,
|
||||
toValue: sequence,
|
||||
reason: consistReason ?? 'Added to the consist on the schedule',
|
||||
metadata: { status: { from: wagon.status, to: WagonStatus.Assigned } },
|
||||
});
|
||||
}
|
||||
await this.wagonHistory?.record(manager, addedEvents);
|
||||
|
||||
// The schedule is full when every consist wagon is allocated.
|
||||
await manager
|
||||
@@ -10637,6 +11016,8 @@ export class TrainSchedulingService {
|
||||
// without the wagons' tare. The legs tab shows this per booking.
|
||||
cargoWeightTons: sb.booking ? bookingCargoTons(sb.booking) : 0,
|
||||
status: sb.booking?.status ?? null,
|
||||
// Loadability is decided by the payment status, not `status`.
|
||||
paymentStatus: sb.booking?.paymentStatus ?? null,
|
||||
schedulingStatus: sb.booking?.schedulingStatus ?? null,
|
||||
freightType: sb.booking?.freightType ?? null,
|
||||
// Which leg of the corridor this booking rides — the workspace can't
|
||||
@@ -11632,6 +12013,30 @@ export class TrainSchedulingService {
|
||||
await allocs.update(alloc.id, { trainSetWagonId: created.id });
|
||||
}
|
||||
await slotRepo.update(source.id, emptyLoadFields);
|
||||
await this.wagonHistory?.record(manager, [
|
||||
...(source.physicalWagonId
|
||||
? [
|
||||
{
|
||||
wagonId: source.physicalWagonId,
|
||||
wagonNumber: source.physicalWagon?.wagonNumber ?? null,
|
||||
type: WagonEventType.LoadMovedOut,
|
||||
trainScheduleId: scheduleId,
|
||||
bookingId: sourceAllocs[0]?.bookingId ?? null,
|
||||
toValue: consistWagon.wagonNumber,
|
||||
metadata: { toWagonId: consistWagon.id, allocations: sourceAllocs.length },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
wagonId: consistWagon.id,
|
||||
wagonNumber: consistWagon.wagonNumber,
|
||||
type: WagonEventType.LoadMovedIn,
|
||||
trainScheduleId: scheduleId,
|
||||
bookingId: sourceAllocs[0]?.bookingId ?? null,
|
||||
fromValue: source.physicalWagon?.wagonNumber ?? null,
|
||||
metadata: { fromWagonId: source.physicalWagonId ?? null, allocations: sourceAllocs.length },
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -11646,6 +12051,54 @@ export class TrainSchedulingService {
|
||||
}
|
||||
await slotRepo.update(target.id, sourceLoadFields);
|
||||
await slotRepo.update(source.id, targetLoadFields);
|
||||
const moveEvents: WagonEventInput[] = [];
|
||||
if (source.physicalWagonId) {
|
||||
moveEvents.push({
|
||||
wagonId: source.physicalWagonId,
|
||||
wagonNumber: source.physicalWagon?.wagonNumber ?? null,
|
||||
type: WagonEventType.LoadMovedOut,
|
||||
trainScheduleId: scheduleId,
|
||||
bookingId: sourceAllocs[0]?.bookingId ?? null,
|
||||
toValue: target.physicalWagon?.wagonNumber ?? null,
|
||||
metadata: { toWagonId: target.physicalWagonId ?? null, allocations: sourceAllocs.length, swap: targetAllocs.length > 0 },
|
||||
});
|
||||
}
|
||||
if (target.physicalWagonId) {
|
||||
moveEvents.push({
|
||||
wagonId: target.physicalWagonId,
|
||||
wagonNumber: target.physicalWagon?.wagonNumber ?? null,
|
||||
type: WagonEventType.LoadMovedIn,
|
||||
trainScheduleId: scheduleId,
|
||||
bookingId: sourceAllocs[0]?.bookingId ?? null,
|
||||
fromValue: source.physicalWagon?.wagonNumber ?? null,
|
||||
metadata: { fromWagonId: source.physicalWagonId ?? null, allocations: sourceAllocs.length, swap: targetAllocs.length > 0 },
|
||||
});
|
||||
}
|
||||
if (targetAllocs.length) {
|
||||
if (target.physicalWagonId) {
|
||||
moveEvents.push({
|
||||
wagonId: target.physicalWagonId,
|
||||
wagonNumber: target.physicalWagon?.wagonNumber ?? null,
|
||||
type: WagonEventType.LoadMovedOut,
|
||||
trainScheduleId: scheduleId,
|
||||
bookingId: targetAllocs[0]?.bookingId ?? null,
|
||||
toValue: source.physicalWagon?.wagonNumber ?? null,
|
||||
metadata: { toWagonId: source.physicalWagonId ?? null, allocations: targetAllocs.length, swap: true },
|
||||
});
|
||||
}
|
||||
if (source.physicalWagonId) {
|
||||
moveEvents.push({
|
||||
wagonId: source.physicalWagonId,
|
||||
wagonNumber: source.physicalWagon?.wagonNumber ?? null,
|
||||
type: WagonEventType.LoadMovedIn,
|
||||
trainScheduleId: scheduleId,
|
||||
bookingId: targetAllocs[0]?.bookingId ?? null,
|
||||
fromValue: target.physicalWagon?.wagonNumber ?? null,
|
||||
metadata: { fromWagonId: target.physicalWagonId ?? null, allocations: targetAllocs.length, swap: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
await this.wagonHistory?.record(manager, moveEvents);
|
||||
});
|
||||
|
||||
return this.getTrainScheduleById(scheduleId);
|
||||
@@ -11950,7 +12403,11 @@ export class TrainSchedulingService {
|
||||
return assignability.shortage;
|
||||
}
|
||||
|
||||
/** Paid (or government) bookings that may be loaded onto wagons — excludes expired / awaiting payment. */
|
||||
/**
|
||||
* Paid (or government) bookings that may be loaded onto wagons — excludes
|
||||
* expired / awaiting payment. "Paid" is read from the PAYMENT status only;
|
||||
* the booking status is not a reliable payment signal.
|
||||
*/
|
||||
private isReadyToLoadBooking(booking: {
|
||||
status: string;
|
||||
paymentStatus?: string | null;
|
||||
@@ -11960,7 +12417,7 @@ export class TrainSchedulingService {
|
||||
if (booking.status === 'SELECTED_FOR_BATCH' || booking.status === 'AWAITING_PAYMENT') {
|
||||
return false;
|
||||
}
|
||||
if (booking.status === 'PAID' || booking.paymentStatus === 'PAID') return true;
|
||||
if (booking.paymentStatus === 'PAID') return true;
|
||||
if (booking.isGovernment) return true;
|
||||
return false;
|
||||
}
|
||||
@@ -12340,6 +12797,12 @@ export class TrainSchedulingService {
|
||||
// 2. The physical wagons follow the train — the target's stay put, and
|
||||
// EVERY wagon on the source train (coupled or loose) moves across so
|
||||
// nothing strands on the deactivated train.
|
||||
const mergedFromSource = sourceTrainId
|
||||
? await manager.getRepository(Wagon).find({
|
||||
where: { trainId: sourceTrainId },
|
||||
select: { id: true, wagonNumber: true, currentYardId: true },
|
||||
})
|
||||
: [];
|
||||
if (incomingWagons.length) {
|
||||
await manager.getRepository(Wagon).update(
|
||||
{ id: In(incomingWagons.map((w) => w.id)) },
|
||||
@@ -12351,6 +12814,20 @@ export class TrainSchedulingService {
|
||||
.getRepository(Wagon)
|
||||
.update({ trainId: sourceTrainId }, { trainId: targetTrain.id });
|
||||
}
|
||||
await this.wagonHistory?.record(
|
||||
manager,
|
||||
mergedFromSource.map((w) => ({
|
||||
wagonId: w.id,
|
||||
wagonNumber: w.wagonNumber,
|
||||
type: WagonEventType.TrainMerged,
|
||||
fromYardId: w.currentYardId ?? null,
|
||||
trainId: targetTrain.id,
|
||||
trainScheduleId: schedule.id,
|
||||
fromValue: sourceTrainId,
|
||||
toValue: targetTrain.code,
|
||||
reason: `Train merged into ${targetTrain.code}`,
|
||||
})),
|
||||
);
|
||||
|
||||
// 3. Carry the target's train-set wagon rows into THIS consist, appended
|
||||
// after the existing wagons. Sequence is provisional — staff reorder
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Freight, WagonMovementKind, WagonStatus } from '@edr/types';
|
||||
import { Freight, WagonEventType, WagonMovementKind, WagonStatus } from '@edr/types';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
@@ -25,6 +25,7 @@ import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
||||
import { WagonStatusLog } from '../wagons/entities/wagon-status-log.entity';
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
import { WagonEventInput, WagonHistoryService } from '../wagon-history/wagon-history.service';
|
||||
import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
|
||||
import { BuildTrainDto } from './dto/build-train.dto';
|
||||
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
|
||||
@@ -76,6 +77,7 @@ export class TrainBuilderService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
private readonly wagonHistory: WagonHistoryService,
|
||||
) {}
|
||||
|
||||
async buildTrain(dto: BuildTrainDto) {
|
||||
@@ -134,7 +136,7 @@ export class TrainBuilderService {
|
||||
await this.replaceLocomotiveLinks(manager, train.id, locomotiveIds);
|
||||
|
||||
if (dto.wagonIds?.length) {
|
||||
await this.attachWagons(manager, train, dto.wagonIds, 0);
|
||||
await this.attachWagons(manager, train, dto.wagonIds, 0, null);
|
||||
}
|
||||
return train.id;
|
||||
});
|
||||
@@ -684,8 +686,19 @@ export class TrainBuilderService {
|
||||
wagon.currentYardId === previousYardId,
|
||||
);
|
||||
const now = new Date();
|
||||
const events: WagonEventInput[] = [];
|
||||
for (const wagon of wagons) {
|
||||
if (wagon.currentYardId === yard.id) continue;
|
||||
events.push({
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.MovedWithTrain,
|
||||
occurredAt: now,
|
||||
fromYardId: wagon.currentYardId ?? null,
|
||||
toYardId: yard.id,
|
||||
trainId: train.id,
|
||||
reason: `Train ${train.code} relocated`,
|
||||
});
|
||||
await manager.getRepository(Wagon).update(wagon.id, { currentYardId: yard.id });
|
||||
// Ledger row keeps the wagon's yard history auditable (mirrors the
|
||||
// manual-relocation path in the wagons service).
|
||||
@@ -699,6 +712,7 @@ export class TrainBuilderService {
|
||||
}),
|
||||
);
|
||||
}
|
||||
await this.wagonHistory.record(manager, events);
|
||||
});
|
||||
return this.getComposition(id);
|
||||
}
|
||||
@@ -735,6 +749,16 @@ export class TrainBuilderService {
|
||||
occurredAt: new Date(),
|
||||
}),
|
||||
);
|
||||
await this.wagonHistory.record(manager, {
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.MovedManually,
|
||||
actorUserId: userId ?? null,
|
||||
fromYardId: wagon.currentYardId ?? null,
|
||||
toYardId: yard.id,
|
||||
trainId: train.id,
|
||||
reason: 'Coupled wagon moved from the train builder',
|
||||
});
|
||||
});
|
||||
return this.getComposition(id);
|
||||
}
|
||||
@@ -787,6 +811,19 @@ export class TrainBuilderService {
|
||||
await manager
|
||||
.getRepository(Wagon)
|
||||
.update(moving.map((w) => w.id), { currentYardId: yard.id });
|
||||
await this.wagonHistory.record(
|
||||
manager,
|
||||
moving.map((w) => ({
|
||||
wagonId: w.id,
|
||||
wagonNumber: w.wagonNumber,
|
||||
type: WagonEventType.MovedManually,
|
||||
actorUserId: userId ?? null,
|
||||
fromYardId: w.currentYardId ?? null,
|
||||
toYardId: yard.id,
|
||||
trainId: train.id,
|
||||
reason: 'Coupled wagons moved from the train builder',
|
||||
})),
|
||||
);
|
||||
await manager.getRepository(WagonMovement).save(
|
||||
moving.map((w) =>
|
||||
manager.getRepository(WagonMovement).create({
|
||||
@@ -810,7 +847,7 @@ export class TrainBuilderService {
|
||||
const currentCount = await manager
|
||||
.getRepository(Wagon)
|
||||
.count({ where: { trainId: train.id } });
|
||||
const attached = await this.attachWagons(manager, train, dto.wagonIds, currentCount);
|
||||
const attached = await this.attachWagons(manager, train, dto.wagonIds, currentCount, userId ?? null);
|
||||
return this.syncLiveScheduleAfterConsistChange(
|
||||
manager,
|
||||
train.id,
|
||||
@@ -949,6 +986,18 @@ export class TrainBuilderService {
|
||||
}),
|
||||
);
|
||||
}
|
||||
await this.wagonHistory.record(manager, {
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.StatusChanged,
|
||||
actorUserId: userId ?? null,
|
||||
trainId: train.id,
|
||||
fromYardId: wagon.currentYardId ?? train.currentYardId ?? null,
|
||||
fromValue: previousStatus,
|
||||
toValue: WagonStatus.Maintenance,
|
||||
reason: note?.trim() || null,
|
||||
metadata: { trainCode: train.code },
|
||||
});
|
||||
// Audit row: which train it came off and when. The wagon does not change
|
||||
// yard here, so from/to are the same — the ledger is the wagon's history
|
||||
// surface, and a maintenance detach has to be in it.
|
||||
@@ -1166,6 +1215,22 @@ export class TrainBuilderService {
|
||||
for (let i = 0; i < dto.wagonIds.length; i++) {
|
||||
await manager.getRepository(Wagon).update(dto.wagonIds[i], { sequenceNumber: i + 1 });
|
||||
}
|
||||
const previousSeq = new Map(wagons.map((w) => [w.id, w]));
|
||||
await this.wagonHistory.record(
|
||||
manager,
|
||||
dto.wagonIds
|
||||
.map((wid, i) => ({ wagon: previousSeq.get(wid), to: i + 1 }))
|
||||
.filter((x) => x.wagon && x.wagon.sequenceNumber !== x.to)
|
||||
.map(({ wagon, to }) => ({
|
||||
wagonId: wagon!.id,
|
||||
wagonNumber: wagon!.wagonNumber,
|
||||
type: WagonEventType.SequenceChanged,
|
||||
trainId: train.id,
|
||||
fromValue: wagon!.sequenceNumber,
|
||||
toValue: to,
|
||||
reason: 'Consist reordered',
|
||||
})),
|
||||
);
|
||||
|
||||
// Propagate the new order to every live (DRAFT/SCHEDULED) schedule of
|
||||
// this train: slots pinned to a reordered wagon adopt the wagon's new
|
||||
@@ -1287,6 +1352,10 @@ export class TrainBuilderService {
|
||||
'Train has active schedules; cancel them before disbanding the train',
|
||||
);
|
||||
}
|
||||
const consist = await manager.getRepository(Wagon).find({
|
||||
where: { trainId: train.id },
|
||||
select: { id: true, wagonNumber: true, currentYardId: true, sequenceNumber: true, status: true },
|
||||
});
|
||||
await manager
|
||||
.getRepository(Wagon)
|
||||
.update(
|
||||
@@ -1299,6 +1368,19 @@ export class TrainBuilderService {
|
||||
exportTrainNumber: null,
|
||||
},
|
||||
);
|
||||
await this.wagonHistory.record(
|
||||
manager,
|
||||
consist.map((w) => ({
|
||||
wagonId: w.id,
|
||||
wagonNumber: w.wagonNumber,
|
||||
type: WagonEventType.TrainDisbanded,
|
||||
trainId: train.id,
|
||||
fromYardId: w.currentYardId ?? null,
|
||||
fromValue: w.sequenceNumber,
|
||||
reason: `Train ${train.code} disbanded`,
|
||||
metadata: { status: { from: w.status, to: WagonStatus.Available } },
|
||||
})),
|
||||
);
|
||||
await manager.getRepository(TrainLocomotive).delete({ trainId: train.id });
|
||||
await manager.getRepository(Train).remove(train);
|
||||
});
|
||||
@@ -1432,6 +1514,25 @@ export class TrainBuilderService {
|
||||
),
|
||||
);
|
||||
|
||||
// COUPLED rows are written by attachWagons (build + assign); the detach
|
||||
// side is logged here, where the reason and the live schedule are known.
|
||||
await this.wagonHistory.record(
|
||||
manager,
|
||||
changes
|
||||
.filter((c) => c.action === 'REMOVE')
|
||||
.map((c) => ({
|
||||
wagonId: c.wagonId,
|
||||
wagonNumber: c.wagonNumber,
|
||||
type: WagonEventType.UncoupledFromTrain,
|
||||
occurredAt: now,
|
||||
actorUserId: userId,
|
||||
trainId,
|
||||
trainScheduleId: schedule?.id ?? null,
|
||||
fromYardId: yardId,
|
||||
reason: reason?.trim() || null,
|
||||
})),
|
||||
);
|
||||
|
||||
if (!schedule) return null;
|
||||
|
||||
await manager.getRepository(TrainSchedule).update(schedule.id, { maxWagons: wagonCount });
|
||||
@@ -1543,6 +1644,7 @@ export class TrainBuilderService {
|
||||
train: Train,
|
||||
wagonIds: string[],
|
||||
startCount: number,
|
||||
userId: string | null = null,
|
||||
): Promise<Wagon[]> {
|
||||
const uniqueIds = [...new Set(wagonIds)];
|
||||
const wagonRepo = manager.getRepository(Wagon);
|
||||
@@ -1572,6 +1674,7 @@ export class TrainBuilderService {
|
||||
await this.assertConsistLengthWithinLimit(manager, train, toAttach);
|
||||
|
||||
let sequence = startCount;
|
||||
const events: WagonEventInput[] = [];
|
||||
for (const wagon of toAttach) {
|
||||
sequence += 1;
|
||||
await wagonRepo.update(wagon.id, {
|
||||
@@ -1583,7 +1686,23 @@ export class TrainBuilderService {
|
||||
importTrainNumber: train.importTrainNumber,
|
||||
exportTrainNumber: train.exportTrainNumber,
|
||||
});
|
||||
events.push({
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.CoupledToTrain,
|
||||
actorUserId: userId,
|
||||
trainId: train.id,
|
||||
fromYardId: wagon.currentYardId ?? null,
|
||||
toValue: sequence,
|
||||
metadata: {
|
||||
trainCode: train.code,
|
||||
status: { from: wagon.status, to: WagonStatus.Assigned },
|
||||
importTrainNumber: train.importTrainNumber ?? null,
|
||||
exportTrainNumber: train.exportTrainNumber ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
await this.wagonHistory.record(manager, events);
|
||||
return toAttach;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { WagonEventCategory, WagonEventType } from '@edr/types';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { IsArray, IsDateString, IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
|
||||
export class WagonHistoryQueryDto {
|
||||
@ApiPropertyOptional({ enum: WagonEventCategory, description: 'Only events of this category' })
|
||||
@IsOptional()
|
||||
@IsEnum(WagonEventCategory)
|
||||
category?: WagonEventCategory;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: WagonEventType,
|
||||
isArray: true,
|
||||
description: 'Only these event types (repeat the param or comma-separate)',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }) =>
|
||||
Array.isArray(value) ? value : String(value).split(',').map((v) => v.trim()).filter(Boolean),
|
||||
)
|
||||
@IsArray()
|
||||
@IsEnum(WagonEventType, { each: true })
|
||||
types?: WagonEventType[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'ISO timestamp — events at or after this moment' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
from?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'ISO timestamp — events at or before this moment' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
to?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Opaque `nextCursor` from the previous page' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cursor?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: 50, minimum: 1, maximum: 200 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(200)
|
||||
limit?: number;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { WagonEventCategory, WagonEventType } from '@edr/types';
|
||||
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Append-only history of everything that happens to a wagon — one row per
|
||||
* wagon per transition, written inside the same transaction as the change.
|
||||
* Plain id columns, no foreign keys and no soft delete on purpose: the history
|
||||
* must outlive the wagon, train, schedule or booking it refers to, exactly like
|
||||
* `audit_logs` and `schedule_wagon_adjustment_logs`. Rows are never updated.
|
||||
*
|
||||
* Read path: `(wagon_id, occurred_at DESC, id DESC)` keyset pagination — one
|
||||
* index range scan per page regardless of how long the wagon has been in
|
||||
* service. Labels (yard, train, schedule, booking, actor) are joined at read
|
||||
* time on primary keys, so the write path stays a single INSERT.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'wagon_events' })
|
||||
@Index('idx_wagon_events_wagon_time', ['wagonId', 'occurredAt', 'id'])
|
||||
@Index('idx_wagon_events_wagon_cat_time', ['wagonId', 'category', 'occurredAt', 'id'])
|
||||
export class WagonEvent {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'wagon_id', type: 'uuid' })
|
||||
wagonId!: string;
|
||||
|
||||
/** Snapshot so the row still reads after the wagon is purged or renumbered. */
|
||||
@Column({ name: 'wagon_number', type: 'varchar', nullable: true })
|
||||
wagonNumber?: string | null;
|
||||
|
||||
@Column({ name: 'event_type', type: 'varchar', length: 40 })
|
||||
type!: WagonEventType;
|
||||
|
||||
/** Derived from `type` at write time; stored so the category filter hits the index. */
|
||||
@Column({ name: 'category', type: 'varchar', length: 20 })
|
||||
category!: WagonEventCategory;
|
||||
|
||||
@Column({ name: 'occurred_at', type: 'timestamptz' })
|
||||
occurredAt!: Date;
|
||||
|
||||
@Column({ name: 'actor_user_id', type: 'uuid', nullable: true })
|
||||
actorUserId?: string | null;
|
||||
|
||||
@Column({ name: 'from_yard_id', type: 'uuid', nullable: true })
|
||||
fromYardId?: string | null;
|
||||
|
||||
@Column({ name: 'to_yard_id', type: 'uuid', nullable: true })
|
||||
toYardId?: string | null;
|
||||
|
||||
@Column({ name: 'train_id', type: 'uuid', nullable: true })
|
||||
trainId?: string | null;
|
||||
|
||||
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
|
||||
trainScheduleId?: string | null;
|
||||
|
||||
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
|
||||
bookingId?: string | null;
|
||||
|
||||
/** Previous value of whatever the event changed (status, sequence, train code…). */
|
||||
@Column({ name: 'from_value', type: 'varchar', length: 120, nullable: true })
|
||||
fromValue?: string | null;
|
||||
|
||||
@Column({ name: 'to_value', type: 'varchar', length: 120, nullable: true })
|
||||
toValue?: string | null;
|
||||
|
||||
/** Staff-entered reason / note, when the action carried one. */
|
||||
@Column({ name: 'reason', type: 'text', nullable: true })
|
||||
reason?: string | null;
|
||||
|
||||
@Column({ name: 'metadata', type: 'jsonb', nullable: true })
|
||||
metadata?: Record<string, unknown> | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
||||
import { WagonHistoryService } from './wagon-history.service';
|
||||
|
||||
/**
|
||||
* Global, dependency-free (only the DataSource): every service that writes a
|
||||
* wagon row — wagons desk, train builder, scheduling, booking journey,
|
||||
* containers, cancellations — records history through WagonHistoryService
|
||||
* without adding a module edge, the same pattern as FleetHistoryModule.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [WagonHistoryService],
|
||||
exports: [WagonHistoryService],
|
||||
})
|
||||
export class WagonHistoryModule {}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { WagonEventCategory, WagonEventType } from '@edr/types';
|
||||
|
||||
import { WagonHistoryService } from './wagon-history.service';
|
||||
|
||||
/** Captures the INSERT query-builder chain and the raw list query. */
|
||||
function makeDataSource() {
|
||||
const execute = jest.fn().mockResolvedValue(undefined);
|
||||
const values = jest.fn();
|
||||
const chain = { insert: jest.fn(), into: jest.fn(), values, updateEntity: jest.fn(), execute };
|
||||
chain.insert.mockReturnValue(chain);
|
||||
chain.into.mockReturnValue(chain);
|
||||
values.mockReturnValue(chain);
|
||||
chain.updateEntity.mockReturnValue(chain);
|
||||
const manager = { createQueryBuilder: jest.fn(() => chain) };
|
||||
const query = jest.fn().mockResolvedValue([]);
|
||||
return { dataSource: { manager, query }, manager, values, execute, query };
|
||||
}
|
||||
|
||||
describe('WagonHistoryService.record', () => {
|
||||
it('writes a batch as one INSERT, deriving the category from the type', async () => {
|
||||
const { dataSource, values, execute } = makeDataSource();
|
||||
const service = new WagonHistoryService(dataSource as never);
|
||||
const at = new Date('2026-09-01T10:00:00Z');
|
||||
|
||||
await service.record(dataSource.manager as never, [
|
||||
{ wagonId: 'w1', wagonNumber: 'W-1', type: WagonEventType.MovedManually, toYardId: 'y2', occurredAt: at },
|
||||
{ wagonId: 'w2', type: WagonEventType.CargoLoaded, bookingId: 'b1', toValue: 12.5 },
|
||||
null,
|
||||
]);
|
||||
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
const rows = values.mock.calls[0][0];
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]).toMatchObject({
|
||||
wagonId: 'w1',
|
||||
wagonNumber: 'W-1',
|
||||
type: WagonEventType.MovedManually,
|
||||
category: WagonEventCategory.Yard,
|
||||
toYardId: 'y2',
|
||||
occurredAt: at,
|
||||
actorUserId: null,
|
||||
});
|
||||
expect(rows[1]).toMatchObject({
|
||||
wagonId: 'w2',
|
||||
category: WagonEventCategory.Cargo,
|
||||
bookingId: 'b1',
|
||||
toValue: '12.5',
|
||||
});
|
||||
expect(rows[1].occurredAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('skips empty input without touching the database', async () => {
|
||||
const { dataSource, execute } = makeDataSource();
|
||||
const service = new WagonHistoryService(dataSource as never);
|
||||
await service.record(dataSource.manager as never, []);
|
||||
await service.record(null, null);
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates a failure inside a caller transaction but swallows it outside one', async () => {
|
||||
const { dataSource, execute } = makeDataSource();
|
||||
execute.mockRejectedValue(new Error('db down'));
|
||||
const service = new WagonHistoryService(dataSource as never);
|
||||
const input = { wagonId: 'w1', type: WagonEventType.Registered };
|
||||
|
||||
await expect(service.record(dataSource.manager as never, input)).rejects.toThrow('db down');
|
||||
await expect(service.record(null, input)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('WagonHistoryService.list', () => {
|
||||
const A = '11111111-1111-4111-8111-111111111111';
|
||||
const B = '22222222-2222-4222-8222-222222222222';
|
||||
const C = '33333333-3333-4333-8333-333333333333';
|
||||
const row = (id: string, at: string) => ({
|
||||
id,
|
||||
wagonId: 'w1',
|
||||
wagonNumber: 'W-1',
|
||||
type: WagonEventType.PassedCheckpoint,
|
||||
category: WagonEventCategory.Yard,
|
||||
occurredAt: new Date(at),
|
||||
actorUserId: null,
|
||||
actorName: null,
|
||||
fromYardId: 'y1',
|
||||
fromYardLabel: 'Origin',
|
||||
toYardId: 'y2',
|
||||
toYardLabel: 'Stop',
|
||||
trainId: null,
|
||||
trainCode: null,
|
||||
trainScheduleId: 's1',
|
||||
scheduleLabel: 'V-100',
|
||||
bookingId: null,
|
||||
bookingReference: null,
|
||||
fromValue: null,
|
||||
toValue: null,
|
||||
reason: null,
|
||||
metadata: null,
|
||||
});
|
||||
|
||||
it('returns a page with a cursor when more rows exist, and decodes that cursor on the next call', async () => {
|
||||
const { dataSource, query } = makeDataSource();
|
||||
const service = new WagonHistoryService(dataSource as never);
|
||||
query.mockResolvedValueOnce([
|
||||
row(A, '2026-09-01T10:00:00Z'),
|
||||
row(B, '2026-09-01T09:00:00Z'),
|
||||
row(C, '2026-09-01T08:00:00Z'), // the +1 probe row
|
||||
]);
|
||||
|
||||
const first = await service.list('w1', { limit: 2, category: WagonEventCategory.Yard });
|
||||
expect(first.items.map((i) => i.id)).toEqual([A, B]);
|
||||
expect(first.items[0].occurredAt).toBe('2026-09-01T10:00:00.000Z');
|
||||
expect(first.nextCursor).toEqual(expect.any(String));
|
||||
const [sql, params] = query.mock.calls[0];
|
||||
expect(sql).toContain('e.wagon_id = $1');
|
||||
expect(sql).toContain('e.category = $2');
|
||||
expect(sql).toContain('LIMIT 3');
|
||||
expect(params).toEqual(['w1', WagonEventCategory.Yard]);
|
||||
|
||||
query.mockResolvedValueOnce([row(C, '2026-09-01T08:00:00Z')]);
|
||||
const second = await service.list('w1', { limit: 2, cursor: first.nextCursor! });
|
||||
expect(second.items.map((i) => i.id)).toEqual([C]);
|
||||
expect(second.nextCursor).toBeNull();
|
||||
const [sql2, params2] = query.mock.calls[1];
|
||||
expect(sql2).toContain('(e.occurred_at, e.id) < ($2, $3::uuid)');
|
||||
expect(params2[1]).toEqual(new Date('2026-09-01T09:00:00Z'));
|
||||
expect(params2[2]).toBe(B);
|
||||
});
|
||||
|
||||
it('rejects a malformed cursor', async () => {
|
||||
const { dataSource } = makeDataSource();
|
||||
const service = new WagonHistoryService(dataSource as never);
|
||||
await expect(service.list('w1', { cursor: 'not-a-cursor' })).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import {
|
||||
WAGON_EVENT_CATEGORY,
|
||||
WagonEventCategory,
|
||||
WagonEventType,
|
||||
WagonHistoryEvent,
|
||||
WagonHistoryPage,
|
||||
} from '@edr/types';
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
|
||||
|
||||
import { WagonHistoryQueryDto } from './dto/wagon-history-query.dto';
|
||||
import { WagonEvent } from './wagon-event.entity';
|
||||
|
||||
/** One transition to append. Everything but the wagon and the type is optional context. */
|
||||
export interface WagonEventInput {
|
||||
wagonId: string;
|
||||
/** Snapshot for the row; pass it when the caller already holds the wagon (no lookup is made). */
|
||||
wagonNumber?: string | null;
|
||||
type: WagonEventType;
|
||||
/** Defaults to now. Pass the business timestamp when the caller has one. */
|
||||
occurredAt?: Date | null;
|
||||
actorUserId?: string | null;
|
||||
fromYardId?: string | null;
|
||||
toYardId?: string | null;
|
||||
trainId?: string | null;
|
||||
trainScheduleId?: string | null;
|
||||
bookingId?: string | null;
|
||||
fromValue?: string | number | null;
|
||||
toValue?: string | number | null;
|
||||
reason?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
const DEFAULT_LIMIT = 50;
|
||||
const MAX_LIMIT = 200;
|
||||
|
||||
/**
|
||||
* The single write and read path for `freight.wagon_events`.
|
||||
*
|
||||
* Writes: {@link record} takes the caller's EntityManager so the history row
|
||||
* commits (or rolls back) with the business change — a wagon can never end up
|
||||
* moved without its history row or vice versa. A batch is one INSERT.
|
||||
*
|
||||
* Reads: {@link list} is keyset-paginated on `(occurred_at, id)` under the
|
||||
* per-wagon index, so page N costs the same as page 1; labels come from
|
||||
* primary-key LEFT JOINs on the page only.
|
||||
*/
|
||||
@Injectable()
|
||||
export class WagonHistoryService {
|
||||
private readonly logger = new Logger(WagonHistoryService.name);
|
||||
|
||||
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
|
||||
|
||||
/**
|
||||
* Append one or more events. Inside a transaction (manager given) a failure
|
||||
* propagates — Postgres has already aborted the transaction at that point,
|
||||
* so swallowing it would only hide the rollback. Outside a transaction the
|
||||
* write is best-effort: logged, never thrown, so history can't break the
|
||||
* operation that produced it.
|
||||
*/
|
||||
async record(
|
||||
manager: EntityManager | null | undefined,
|
||||
input: WagonEventInput | null | Array<WagonEventInput | null>,
|
||||
): Promise<void> {
|
||||
const inputs = (Array.isArray(input) ? input : [input]).filter(
|
||||
(i): i is WagonEventInput => Boolean(i?.wagonId),
|
||||
);
|
||||
if (!inputs.length) return;
|
||||
const now = new Date();
|
||||
const rows = inputs.map((i) => ({
|
||||
wagonId: i.wagonId,
|
||||
wagonNumber: i.wagonNumber ?? null,
|
||||
type: i.type,
|
||||
category: WAGON_EVENT_CATEGORY[i.type] ?? WagonEventCategory.Lifecycle,
|
||||
occurredAt: i.occurredAt ?? now,
|
||||
actorUserId: i.actorUserId ?? null,
|
||||
fromYardId: i.fromYardId ?? null,
|
||||
toYardId: i.toYardId ?? null,
|
||||
trainId: i.trainId ?? null,
|
||||
trainScheduleId: i.trainScheduleId ?? null,
|
||||
bookingId: i.bookingId ?? null,
|
||||
fromValue: i.fromValue == null ? null : String(i.fromValue).slice(0, 120),
|
||||
toValue: i.toValue == null ? null : String(i.toValue).slice(0, 120),
|
||||
reason: i.reason?.trim() ? i.reason.trim() : null,
|
||||
metadata: i.metadata ?? null,
|
||||
}));
|
||||
const mg = manager ?? this.dataSource.manager;
|
||||
const write = () =>
|
||||
mg
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.into(WagonEvent)
|
||||
.values(rows as unknown as QueryDeepPartialEntity<WagonEvent>[])
|
||||
.updateEntity(false)
|
||||
.execute();
|
||||
if (manager) {
|
||||
await write();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await write();
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to record ${rows.length} wagon event(s) (${rows[0].type}): ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** One wagon's timeline, newest first, with labels resolved. */
|
||||
async list(wagonId: string, query: WagonHistoryQueryDto = {}): Promise<WagonHistoryPage> {
|
||||
const limit = Math.min(Math.max(query.limit ?? DEFAULT_LIMIT, 1), MAX_LIMIT);
|
||||
const params: unknown[] = [wagonId];
|
||||
const where: string[] = ['e.wagon_id = $1'];
|
||||
const push = (value: unknown) => {
|
||||
params.push(value);
|
||||
return `$${params.length}`;
|
||||
};
|
||||
if (query.category) where.push(`e.category = ${push(query.category)}`);
|
||||
if (query.types?.length) where.push(`e.event_type = ANY(${push(query.types)}::text[])`);
|
||||
if (query.from) where.push(`e.occurred_at >= ${push(new Date(query.from))}`);
|
||||
if (query.to) where.push(`e.occurred_at <= ${push(new Date(query.to))}`);
|
||||
const cursor = decodeCursor(query.cursor);
|
||||
if (cursor) {
|
||||
// Row-value comparison walks the (wagon_id, occurred_at DESC, id DESC) index directly.
|
||||
where.push(`(e.occurred_at, e.id) < (${push(cursor.occurredAt)}, ${push(cursor.id)}::uuid)`);
|
||||
}
|
||||
|
||||
const rows: Array<WagonHistoryEvent & { occurredAt: Date }> = await this.dataSource.query(
|
||||
`SELECT e.id,
|
||||
e.wagon_id AS "wagonId",
|
||||
e.wagon_number AS "wagonNumber",
|
||||
e.event_type AS "type",
|
||||
e.category,
|
||||
e.occurred_at AS "occurredAt",
|
||||
e.actor_user_id AS "actorUserId",
|
||||
COALESCE(u.username, u.email) AS "actorName",
|
||||
e.from_yard_id AS "fromYardId",
|
||||
fy.label AS "fromYardLabel",
|
||||
e.to_yard_id AS "toYardId",
|
||||
ty.label AS "toYardLabel",
|
||||
e.train_id AS "trainId",
|
||||
t.code AS "trainCode",
|
||||
e.train_schedule_id AS "trainScheduleId",
|
||||
COALESCE(s.voyage_number, s.train_number) AS "scheduleLabel",
|
||||
e.booking_id AS "bookingId",
|
||||
b.reference AS "bookingReference",
|
||||
e.from_value AS "fromValue",
|
||||
e.to_value AS "toValue",
|
||||
e.reason,
|
||||
e.metadata
|
||||
FROM freight.wagon_events e
|
||||
LEFT JOIN iam.users u ON u.id = e.actor_user_id
|
||||
LEFT JOIN freight.yards fy ON fy.id = e.from_yard_id
|
||||
LEFT JOIN freight.yards ty ON ty.id = e.to_yard_id
|
||||
LEFT JOIN freight.trains t ON t.id = e.train_id
|
||||
LEFT JOIN freight.train_schedules s ON s.id = e.train_schedule_id
|
||||
LEFT JOIN freight.bookings b ON b.id = e.booking_id
|
||||
WHERE ${where.join(' AND ')}
|
||||
ORDER BY e.occurred_at DESC, e.id DESC
|
||||
LIMIT ${limit + 1}`,
|
||||
params,
|
||||
);
|
||||
|
||||
const hasMore = rows.length > limit;
|
||||
const page = hasMore ? rows.slice(0, limit) : rows;
|
||||
const last = page[page.length - 1];
|
||||
return {
|
||||
items: page.map((r) => ({
|
||||
...r,
|
||||
occurredAt: new Date(r.occurredAt).toISOString(),
|
||||
})),
|
||||
nextCursor: hasMore && last ? encodeCursor(new Date(last.occurredAt), last.id) : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function encodeCursor(occurredAt: Date, id: string): string {
|
||||
return Buffer.from(`${occurredAt.toISOString()}|${id}`, 'utf8').toString('base64url');
|
||||
}
|
||||
|
||||
function decodeCursor(cursor?: string): { occurredAt: Date; id: string } | null {
|
||||
if (!cursor) return null;
|
||||
const raw = Buffer.from(cursor, 'base64url').toString('utf8');
|
||||
const sep = raw.indexOf('|');
|
||||
const occurredAt = sep > 0 ? new Date(raw.slice(0, sep)) : new Date(NaN);
|
||||
const id = sep > 0 ? raw.slice(sep + 1) : '';
|
||||
if (Number.isNaN(occurredAt.getTime()) || !/^[0-9a-f-]{36}$/i.test(id)) {
|
||||
throw new BadRequestException('Invalid history cursor');
|
||||
}
|
||||
return { occurredAt, id };
|
||||
}
|
||||
@@ -14,7 +14,12 @@ const makeService = (wagon: any, counts: [number, number, number], pinned = fals
|
||||
return [];
|
||||
}),
|
||||
};
|
||||
const svc = new WagonsService(wagonRepo as any, {} as any, dataSource as any);
|
||||
const svc = new WagonsService(
|
||||
wagonRepo as any,
|
||||
{} as any,
|
||||
dataSource as any,
|
||||
{ record: jest.fn() } as any,
|
||||
);
|
||||
return { svc, wagonRepo };
|
||||
};
|
||||
|
||||
@@ -54,7 +59,12 @@ describe('WagonsService.purge', () => {
|
||||
|
||||
it('404s an unknown wagon', async () => {
|
||||
const wagonRepo = { findOne: jest.fn().mockResolvedValue(null), remove: jest.fn() };
|
||||
const svc = new WagonsService(wagonRepo as any, {} as any, { query: jest.fn() } as any);
|
||||
const svc = new WagonsService(
|
||||
wagonRepo as any,
|
||||
{} as any,
|
||||
{ query: jest.fn() } as any,
|
||||
{ record: jest.fn() } as any,
|
||||
);
|
||||
await expect(svc.purge('nope')).rejects.toThrow(NotFoundException);
|
||||
expect(wagonRepo.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -27,6 +27,8 @@ import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto';
|
||||
import { BulkTransferWagonsDto } from './dto/bulk-transfer-wagons.dto';
|
||||
import { BulkSetWagonStatusDto } from './dto/bulk-set-wagon-status.dto';
|
||||
import { WagonsService } from './wagons.service';
|
||||
import { WagonHistoryQueryDto } from '../wagon-history/dto/wagon-history-query.dto';
|
||||
import { WagonHistoryService } from '../wagon-history/wagon-history.service';
|
||||
|
||||
@ApiTags('wagons')
|
||||
// No class-level guard: reads (list, by-id, movements) are login-only reference
|
||||
@@ -34,13 +36,16 @@ import { WagonsService } from './wagons.service';
|
||||
// fleet:view that drives the Fleet sidebar. Every mutation has its @FleetManage().
|
||||
@Controller('wagons')
|
||||
export class WagonsController {
|
||||
constructor(private readonly wagonsService: WagonsService) {}
|
||||
constructor(
|
||||
private readonly wagonsService: WagonsService,
|
||||
private readonly wagonHistory: WagonHistoryService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@FleetManage(FREIGHT_PERMS.wagons.create)
|
||||
@ApiOperation({ summary: 'Create a new wagon' })
|
||||
create(@Body() dto: CreateWagonDto) {
|
||||
return this.wagonsService.create(dto);
|
||||
create(@Body() dto: CreateWagonDto, @CurrentUser() user: TCurrentUser) {
|
||||
return this.wagonsService.create(dto, user?.id);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@@ -68,11 +73,26 @@ export class WagonsController {
|
||||
return this.wagonsService.listMovements(id);
|
||||
}
|
||||
|
||||
@Get(':id/history')
|
||||
@FleetView(FREIGHT_PERMS.wagons.view)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Unified wagon history — yard moves, coupling, schedule pins/dispatch, status flips, cargo, lifecycle — newest first, keyset-paginated (`cursor`)',
|
||||
})
|
||||
history(@Param('id', ParseUUIDPipe) id: string, @Query() query: WagonHistoryQueryDto) {
|
||||
// No existence check on purpose: a deleted or purged wagon keeps its history.
|
||||
return this.wagonHistory.list(id, query);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@FleetManage(FREIGHT_PERMS.wagons.update)
|
||||
@ApiOperation({ summary: 'Update a wagon' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWagonDto) {
|
||||
return this.wagonsService.update(id, dto);
|
||||
update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateWagonDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.wagonsService.update(id, dto, user?.id);
|
||||
}
|
||||
|
||||
// Declared before @Delete(':id') so "permanent" is never captured as an id.
|
||||
@@ -86,29 +106,33 @@ export class WagonsController {
|
||||
summary:
|
||||
'Permanently delete a wagon (irreversible; refused if it has movements, containers or train-set slots)',
|
||||
})
|
||||
purge(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.wagonsService.purge(id);
|
||||
purge(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser) {
|
||||
return this.wagonsService.purge(id, user?.id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@FleetManage(FREIGHT_PERMS.wagons.delete)
|
||||
@ApiOperation({ summary: 'Delete a wagon' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.wagonsService.remove(id);
|
||||
remove(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser) {
|
||||
return this.wagonsService.remove(id, user?.id);
|
||||
}
|
||||
|
||||
@Post(':id/assign-train')
|
||||
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||
@ApiOperation({ summary: 'Assign wagon to a train' })
|
||||
assignToTrain(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignWagonToTrainDto) {
|
||||
return this.wagonsService.assignToTrain(id, dto);
|
||||
assignToTrain(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: AssignWagonToTrainDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.wagonsService.assignToTrain(id, dto, user?.id);
|
||||
}
|
||||
|
||||
@Post(':id/unassign-train')
|
||||
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||
@ApiOperation({ summary: 'Unassign wagon from train' })
|
||||
unassignFromTrain(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.wagonsService.unassignFromTrain(id);
|
||||
unassignFromTrain(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser) {
|
||||
return this.wagonsService.unassignFromTrain(id, user?.id);
|
||||
}
|
||||
|
||||
@Post('bulk-transfer')
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { Freight, PaginatedResponse, WagonMovementKind, WagonStatus } from '@edr/types';
|
||||
import {
|
||||
Freight,
|
||||
PaginatedResponse,
|
||||
WagonEventType,
|
||||
WagonMovementKind,
|
||||
WagonStatus,
|
||||
} from '@edr/types';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
@@ -19,6 +25,16 @@ import { WagonStatusLog } from './entities/wagon-status-log.entity';
|
||||
import { WagonMovement } from './entities/wagon-movement.entity';
|
||||
import { Train } from '../trains/entities/train.entity';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { WagonEventInput, WagonHistoryService } from '../wagon-history/wagon-history.service';
|
||||
|
||||
/** Wagon columns whose manual edits are diffed into a DETAILS_UPDATED history row. */
|
||||
const TRACKED_DETAIL_FIELDS = [
|
||||
'wagonNumber',
|
||||
'wagonTypeId',
|
||||
'exportTrainNumber',
|
||||
'importTrainNumber',
|
||||
'notes',
|
||||
] as const;
|
||||
|
||||
@Injectable()
|
||||
export class WagonsService {
|
||||
@@ -28,9 +44,10 @@ export class WagonsService {
|
||||
@InjectRepository(Train)
|
||||
private readonly trainRepo: Repository<Train>,
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly wagonHistory: WagonHistoryService,
|
||||
) {}
|
||||
|
||||
async create(dto: CreateWagonDto): Promise<Wagon> {
|
||||
async create(dto: CreateWagonDto, userId?: string | null): Promise<Wagon> {
|
||||
const wagon = this.wagonRepo.create({
|
||||
...dto,
|
||||
status: dto.status ?? WagonStatus.Available,
|
||||
@@ -41,7 +58,22 @@ export class WagonsService {
|
||||
if (dto.currentYardId === undefined) wagon.currentYardId = null;
|
||||
if (dto.exportTrainNumber === undefined) wagon.exportTrainNumber = null;
|
||||
if (dto.importTrainNumber === undefined) wagon.importTrainNumber = null;
|
||||
return this.wagonRepo.save(wagon);
|
||||
const saved = await this.wagonRepo.save(wagon);
|
||||
await this.wagonHistory.record(null, {
|
||||
wagonId: saved.id,
|
||||
wagonNumber: saved.wagonNumber,
|
||||
type: WagonEventType.Registered,
|
||||
actorUserId: userId ?? null,
|
||||
toYardId: saved.currentYardId ?? null,
|
||||
trainId: saved.trainId ?? null,
|
||||
toValue: saved.status,
|
||||
metadata: {
|
||||
wagonTypeId: saved.wagonTypeId,
|
||||
exportTrainNumber: saved.exportTrainNumber ?? null,
|
||||
importTrainNumber: saved.importTrainNumber ?? null,
|
||||
},
|
||||
});
|
||||
return saved;
|
||||
}
|
||||
|
||||
/** Shared filter/sort builder behind `findAll` (array) and `findAllPaged` (envelope). */
|
||||
@@ -210,6 +242,10 @@ export class WagonsService {
|
||||
}
|
||||
}
|
||||
const previousYardId = wagon.currentYardId ?? null;
|
||||
const previousStatus = wagon.status;
|
||||
const before = Object.fromEntries(
|
||||
TRACKED_DETAIL_FIELDS.map((f) => [f, (wagon as unknown as Record<string, unknown>)[f] ?? null]),
|
||||
);
|
||||
Object.assign(wagon, dto);
|
||||
// `findById` eager-loads `currentYard`; when the DTO changes the scalar FK
|
||||
// TypeORM otherwise re-derives `current_yard_id` from the STALE relation
|
||||
@@ -243,6 +279,47 @@ export class WagonsService {
|
||||
}),
|
||||
);
|
||||
}
|
||||
// History: one row per kind of change — a yard move, a status flip, and
|
||||
// the remaining field edits as a single diff.
|
||||
const events: WagonEventInput[] = [];
|
||||
const changes: Record<string, { from: unknown; to: unknown }> = {};
|
||||
for (const f of TRACKED_DETAIL_FIELDS) {
|
||||
if (dto[f] === undefined) continue;
|
||||
const to = (wagon as unknown as Record<string, unknown>)[f] ?? null;
|
||||
if (before[f] !== to) changes[f] = { from: before[f], to };
|
||||
}
|
||||
if (Object.keys(changes).length) {
|
||||
events.push({
|
||||
wagonId: id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.DetailsUpdated,
|
||||
actorUserId: userId ?? null,
|
||||
metadata: { changes },
|
||||
});
|
||||
}
|
||||
if (dto.currentYardId !== undefined && dto.currentYardId !== previousYardId) {
|
||||
events.push({
|
||||
wagonId: id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.MovedManually,
|
||||
actorUserId: userId ?? null,
|
||||
fromYardId: previousYardId,
|
||||
toYardId: dto.currentYardId ?? null,
|
||||
reason: 'Wagon record edited',
|
||||
});
|
||||
}
|
||||
if (dto.status !== undefined && dto.status !== previousStatus) {
|
||||
events.push({
|
||||
wagonId: id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.StatusChanged,
|
||||
actorUserId: userId ?? null,
|
||||
fromValue: previousStatus,
|
||||
toValue: dto.status,
|
||||
reason: 'Wagon record edited',
|
||||
});
|
||||
}
|
||||
await this.wagonHistory.record(null, events);
|
||||
// Re-read with the relation so the response reflects the new yard label
|
||||
// instead of the stale relation object loaded before the assign.
|
||||
return this.findById(id);
|
||||
@@ -258,7 +335,7 @@ export class WagonsService {
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
async remove(id: string, userId?: string | null): Promise<void> {
|
||||
const wagon = await this.findById(id);
|
||||
// A coupled wagon must be detached via train-builder before it can be
|
||||
// removed, so a built train never silently loses a wagon.
|
||||
@@ -275,6 +352,14 @@ export class WagonsService {
|
||||
// Soft delete (deleted_at) — hard-deleting would strand ledger/schedule
|
||||
// history that references this wagon.
|
||||
await this.wagonRepo.softRemove(wagon);
|
||||
await this.wagonHistory.record(null, {
|
||||
wagonId: id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.Deleted,
|
||||
actorUserId: userId ?? null,
|
||||
fromYardId: wagon.currentYardId ?? null,
|
||||
fromValue: wagon.status,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -288,7 +373,7 @@ export class WagonsService {
|
||||
*
|
||||
* Soft-deleted wagons are purgeable, so `withDeleted` is used to find them.
|
||||
*/
|
||||
async purge(id: string): Promise<void> {
|
||||
async purge(id: string, userId?: string | null): Promise<void> {
|
||||
const wagon = await this.wagonRepo.findOne({
|
||||
where: { id },
|
||||
withDeleted: true,
|
||||
@@ -343,6 +428,16 @@ export class WagonsService {
|
||||
);
|
||||
}
|
||||
|
||||
// Recorded BEFORE the row goes: wagon_events has no FK, so the history of
|
||||
// a purged wagon survives under its id and number snapshot.
|
||||
await this.wagonHistory.record(null, {
|
||||
wagonId: id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.Purged,
|
||||
actorUserId: userId ?? null,
|
||||
fromYardId: wagon.currentYardId ?? null,
|
||||
fromValue: wagon.status,
|
||||
});
|
||||
await this.wagonRepo.remove(wagon);
|
||||
}
|
||||
|
||||
@@ -366,7 +461,11 @@ export class WagonsService {
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async assignToTrain(wagonId: string, dto: AssignWagonToTrainDto): Promise<Wagon> {
|
||||
async assignToTrain(
|
||||
wagonId: string,
|
||||
dto: AssignWagonToTrainDto,
|
||||
userId?: string | null,
|
||||
): Promise<Wagon> {
|
||||
const wagon = await this.findById(wagonId);
|
||||
// Mirror train-builder attachWagons: only a truly free, available wagon
|
||||
// (any yard) can be coupled, and never onto a dispatched train.
|
||||
@@ -399,13 +498,25 @@ export class WagonsService {
|
||||
);
|
||||
}
|
||||
|
||||
const previousStatus = wagon.status;
|
||||
wagon.trainId = train.id;
|
||||
wagon.sequenceNumber = nextSequence;
|
||||
wagon.status = WagonStatus.Assigned;
|
||||
return this.wagonRepo.save(wagon);
|
||||
const saved = await this.wagonRepo.save(wagon);
|
||||
await this.wagonHistory.record(null, {
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.CoupledToTrain,
|
||||
actorUserId: userId ?? null,
|
||||
trainId: train.id,
|
||||
fromYardId: wagon.currentYardId ?? null,
|
||||
toValue: nextSequence,
|
||||
metadata: { status: { from: previousStatus, to: WagonStatus.Assigned }, trainCode: train.code },
|
||||
});
|
||||
return saved;
|
||||
}
|
||||
|
||||
async unassignFromTrain(wagonId: string): Promise<Wagon> {
|
||||
async unassignFromTrain(wagonId: string, userId?: string | null): Promise<Wagon> {
|
||||
const wagon = await this.findById(wagonId);
|
||||
// A wagon pinned to a live schedule is still operationally committed even
|
||||
// if the fleet train is being edited — don't free it out from under it.
|
||||
@@ -414,10 +525,24 @@ export class WagonsService {
|
||||
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be detached`,
|
||||
);
|
||||
}
|
||||
const previousTrainId = wagon.trainId;
|
||||
const previousSequence = wagon.sequenceNumber;
|
||||
const previousStatus = wagon.status;
|
||||
wagon.trainId = null;
|
||||
wagon.sequenceNumber = null;
|
||||
wagon.status = WagonStatus.Available;
|
||||
return this.wagonRepo.save(wagon);
|
||||
const saved = await this.wagonRepo.save(wagon);
|
||||
await this.wagonHistory.record(null, {
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.UncoupledFromTrain,
|
||||
actorUserId: userId ?? null,
|
||||
trainId: previousTrainId,
|
||||
fromYardId: wagon.currentYardId ?? null,
|
||||
fromValue: previousSequence,
|
||||
metadata: { status: { from: previousStatus, to: WagonStatus.Available } },
|
||||
});
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -464,9 +589,20 @@ export class WagonsService {
|
||||
}
|
||||
|
||||
let moved = 0;
|
||||
const events: WagonEventInput[] = [];
|
||||
for (const wagon of wagons) {
|
||||
const previousYardId = wagon.currentYardId ?? null;
|
||||
if (previousYardId === toYardId) continue;
|
||||
events.push({
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.MovedManually,
|
||||
actorUserId: userId ?? null,
|
||||
fromYardId: previousYardId,
|
||||
toYardId,
|
||||
reason: opts?.transferRequestId ? 'Transfer request fulfilled' : 'Bulk transfer',
|
||||
metadata: opts?.transferRequestId ? { transferRequestId: opts.transferRequestId } : null,
|
||||
});
|
||||
wagon.currentYardId = toYardId;
|
||||
// Drop the eager relation so the scalar FK wins on save (see `update`).
|
||||
wagon.currentYard = null;
|
||||
@@ -484,6 +620,7 @@ export class WagonsService {
|
||||
);
|
||||
moved++;
|
||||
}
|
||||
await this.wagonHistory.record(queryRunner.manager, events);
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
return { moved };
|
||||
@@ -547,6 +684,18 @@ export class WagonsService {
|
||||
}
|
||||
await queryRunner.manager.save(Wagon, wagons);
|
||||
if (logs.length) await queryRunner.manager.save(WagonStatusLog, logs);
|
||||
await this.wagonHistory.record(
|
||||
queryRunner.manager,
|
||||
logs.map((l) => ({
|
||||
wagonId: l.wagonId,
|
||||
wagonNumber: wagons.find((w) => w.id === l.wagonId)?.wagonNumber ?? null,
|
||||
type: WagonEventType.StatusChanged,
|
||||
actorUserId: changedByUserId ?? null,
|
||||
fromValue: l.fromStatus,
|
||||
toValue: l.toStatus,
|
||||
reason: dto.note ?? null,
|
||||
})),
|
||||
);
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
return { updated: wagons.length };
|
||||
|
||||
@@ -129,6 +129,7 @@ interface BookingSummaryRow {
|
||||
id: string;
|
||||
reference: string | null;
|
||||
status: string | null;
|
||||
paymentStatus: string | null;
|
||||
customer: string | null;
|
||||
}
|
||||
|
||||
@@ -1372,14 +1373,19 @@ export class WarehouseInventoryService {
|
||||
return this.findById(saved.id);
|
||||
}
|
||||
|
||||
/** Auto-load all READY_FOR_LOADING inventory whose booking is PAID. Unpaid stay pending. */
|
||||
/**
|
||||
* Auto-load all READY_FOR_LOADING inventory whose booking is paid (payment
|
||||
* status PAID — the booking status is not consulted). Unpaid stay pending.
|
||||
*/
|
||||
async autoLoadReady(): Promise<AutoLoadResult> {
|
||||
const ready = await this.inventoryRepository.findAll({ where: { status: 'READY_FOR_LOADING' } });
|
||||
const result: AutoLoadResult = { loadedCount: 0, skippedCount: 0, results: [] };
|
||||
|
||||
for (const item of ready) {
|
||||
const bookingStatus = item.bookingId ? await this.getBookingStatus(item.bookingId) : null;
|
||||
if (bookingStatus !== 'PAID') {
|
||||
const paymentStatus = item.bookingId
|
||||
? await this.getBookingPaymentStatus(item.bookingId)
|
||||
: null;
|
||||
if (paymentStatus !== 'PAID') {
|
||||
result.skippedCount += 1;
|
||||
result.results.push({ inventoryId: item.id, status: 'SKIPPED', reason: 'Booking not PAID' });
|
||||
continue;
|
||||
@@ -3771,12 +3777,14 @@ export class WarehouseInventoryService {
|
||||
throw new BadRequestException(`Inventory must be STORED to reserve (current: ${item.status})`);
|
||||
}
|
||||
|
||||
const status = await this.getBookingStatus(dto.bookingId);
|
||||
if (!status) {
|
||||
const paymentStatus = await this.getBookingPaymentStatus(dto.bookingId);
|
||||
if (paymentStatus === null) {
|
||||
throw new NotFoundException(`Booking ${dto.bookingId} not found`);
|
||||
}
|
||||
if (status !== 'PAID') {
|
||||
throw new BadRequestException(`Booking must be PAID to reserve inventory (current: ${status})`);
|
||||
if (paymentStatus !== 'PAID') {
|
||||
throw new BadRequestException(
|
||||
`Booking must be paid to reserve inventory (payment status: ${paymentStatus})`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
@@ -7468,12 +7476,19 @@ export class WarehouseInventoryService {
|
||||
};
|
||||
}
|
||||
|
||||
private async getBookingStatus(bookingId: string): Promise<string | null> {
|
||||
const [row]: Array<{ status: string | null }> = await this.dataSource.query(
|
||||
'SELECT status FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1',
|
||||
/**
|
||||
* The booking's PAYMENT status — the only signal loading/reservation gates
|
||||
* use to decide "paid". Returns null when the booking does not exist;
|
||||
* an existing booking with no payment status yet reads as PENDING.
|
||||
*/
|
||||
private async getBookingPaymentStatus(bookingId: string): Promise<string | null> {
|
||||
const [row]: Array<{ paymentStatus: string | null }> = await this.dataSource.query(
|
||||
`SELECT payment_status AS "paymentStatus"
|
||||
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1`,
|
||||
[bookingId],
|
||||
);
|
||||
return row?.status ?? null;
|
||||
if (!row) return null;
|
||||
return row.paymentStatus ?? 'PENDING';
|
||||
}
|
||||
|
||||
private async attachBookingSummaries(items: WarehouseInventory[]): Promise<void> {
|
||||
@@ -7481,7 +7496,8 @@ export class WarehouseInventoryService {
|
||||
if (bookingIds.length === 0) return;
|
||||
|
||||
const rows: BookingSummaryRow[] = await this.dataSource.query(
|
||||
`SELECT b.id, b.reference, b.status, company.name AS customer
|
||||
`SELECT b.id, b.reference, b.status, b.payment_status AS "paymentStatus",
|
||||
company.name AS customer
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
WHERE b.id = ANY($1) AND b.deleted_at IS NULL`,
|
||||
@@ -7495,6 +7511,7 @@ export class WarehouseInventoryService {
|
||||
Object.assign(item, {
|
||||
bookingReference: summary.reference,
|
||||
bookingStatus: summary.status,
|
||||
bookingPaymentStatus: summary.paymentStatus,
|
||||
customerName: summary.customer,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2852,6 +2852,11 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
FREIGHT_PERMS.contracts.clearanceReview,
|
||||
FREIGHT_PERMS.contracts.finalizeClearance,
|
||||
FREIGHT_PERMS.contracts.createBooking,
|
||||
// GL rebooks cancelled-wagon credits on the customer's behalf — whoever
|
||||
// cancelled (customer or staff) and whichever side was at fault. Needs to
|
||||
// see the ledger rows and to redeem the credit.
|
||||
FREIGHT_PERMS.bookings.wagonCancellationView,
|
||||
FREIGHT_PERMS.bookings.wagonCancellationRebook,
|
||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||
FREIGHT_PERMS.contracts.clearanceDutyAdvise,
|
||||
FREIGHT_PERMS.contracts.finalInvoiceConfirm,
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, Group, Modal, Select, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { DatePickerInput } from "@mantine/dates";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { api } from "@/auth/http";
|
||||
import { toDayString } from "@/hooks/useListControls";
|
||||
import { formatMoney } from "@/lib/format";
|
||||
import {
|
||||
hasOddFt20,
|
||||
type RebookPartnerCandidate,
|
||||
type WagonCancellation,
|
||||
} from "./types";
|
||||
|
||||
/** Editable rebook unit — prefilled from the cancelled snapshot. */
|
||||
interface RebookUnitDraft {
|
||||
containerSize: string;
|
||||
containerNumber: string;
|
||||
sealNumber: string;
|
||||
vgmTons: number | "";
|
||||
}
|
||||
|
||||
const draftsFrom = (r: WagonCancellation): RebookUnitDraft[] =>
|
||||
(r.cancelledQuantities?.units ?? []).map((u) => ({
|
||||
containerSize: u.containerSize,
|
||||
containerNumber: u.containerNumber,
|
||||
sealNumber: u.sealNumber ?? "",
|
||||
vgmTons: Number(u.vgmTons) || "",
|
||||
}));
|
||||
|
||||
const containersPayload = (drafts: RebookUnitDraft[]) => {
|
||||
const bySize = new Map<string, RebookUnitDraft[]>();
|
||||
for (const d of drafts) {
|
||||
bySize.set(d.containerSize, [...(bySize.get(d.containerSize) ?? []), d]);
|
||||
}
|
||||
return [...bySize.entries()].map(([containerSize, units]) => ({
|
||||
containerSize,
|
||||
units: units.map((u) => ({
|
||||
containerNumber: u.containerNumber.trim(),
|
||||
...(u.sealNumber.trim() ? { sealNumber: u.sealNumber.trim() } : {}),
|
||||
...(u.vgmTons !== "" ? { vgmTons: Number(u.vgmTons) } : {}),
|
||||
})),
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Staff/GL rebook of a CREDIT_AVAILABLE wagon cancellation: pick the shipment
|
||||
* day, correct container details if they changed, and — for an odd-20ft
|
||||
* credit — pick the consolidation partner that shares the wagon. The server
|
||||
* creates the new booking under the contract and marks it PAID from the credit.
|
||||
* Used by the wagon-cancellations list, the GL clearance page and the staff
|
||||
* booking page, so every desk gets the same flow.
|
||||
*/
|
||||
export function RebookWagonCancellationModal({
|
||||
cancellation,
|
||||
onClose,
|
||||
onRebooked,
|
||||
}: {
|
||||
cancellation: WagonCancellation | null;
|
||||
onClose: () => void;
|
||||
/** Called after a successful rebook with the new booking id (when the API returns it). */
|
||||
onRebooked?: (result: { bookingId?: string }) => void;
|
||||
}) {
|
||||
const [date, setDate] = useState<Date | null>(null);
|
||||
const [partnerId, setPartnerId] = useState<string | null>(null);
|
||||
const [drafts, setDrafts] = useState<RebookUnitDraft[]>([]);
|
||||
|
||||
// Fresh form per row: the modal instance is long-lived on the host page.
|
||||
useEffect(() => {
|
||||
setDate(null);
|
||||
setPartnerId(null);
|
||||
setDrafts(cancellation ? draftsFrom(cancellation) : []);
|
||||
}, [cancellation]);
|
||||
|
||||
const needsPartner = cancellation ? hasOddFt20(cancellation) : false;
|
||||
const partners = useQuery({
|
||||
queryKey: [
|
||||
"wagon-cancellations",
|
||||
cancellation?.id,
|
||||
"rebook-partners",
|
||||
date ? toDayString(date) : null,
|
||||
],
|
||||
enabled: Boolean(cancellation && needsPartner && date),
|
||||
queryFn: async () => {
|
||||
const res = await api.get<RebookPartnerCandidate[]>(
|
||||
`/bookings/wagon-cancellations/${cancellation!.id}/rebook-partners`,
|
||||
{ params: { scheduledDate: toDayString(date!) } },
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
const rebook = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await api.post<{ bookingId?: string }>(
|
||||
`/bookings/wagon-cancellations/${cancellation!.id}/rebook`,
|
||||
{
|
||||
scheduledDate: toDayString(date!),
|
||||
...(drafts.length ? { containers: containersPayload(drafts) } : {}),
|
||||
...(partnerId ? { partnerBookingId: partnerId } : {}),
|
||||
},
|
||||
);
|
||||
return res.data ?? {};
|
||||
},
|
||||
});
|
||||
|
||||
const patchDraft = (i: number, patch: Partial<RebookUnitDraft>) =>
|
||||
setDrafts((prev) => prev.map((x, idx) => (idx === i ? { ...x, ...patch } : x)));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={!!cancellation}
|
||||
onClose={onClose}
|
||||
title="Rebook cancelled wagons"
|
||||
centered
|
||||
radius="md"
|
||||
>
|
||||
{cancellation && (
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">
|
||||
{cancellation.booking?.reference ?? cancellation.bookingId} ·{" "}
|
||||
{cancellation.wagonsCancelled} wagon(s) · credit{" "}
|
||||
{formatMoney(cancellation.creditAmount, cancellation.feeCurrency, 2)}
|
||||
</Text>
|
||||
<DatePickerInput
|
||||
label="Shipment day"
|
||||
placeholder="Pick the day"
|
||||
value={date}
|
||||
onChange={(v) => {
|
||||
setDate(v ? new Date(v) : null);
|
||||
setPartnerId(null);
|
||||
}}
|
||||
minDate={new Date()}
|
||||
radius="md"
|
||||
/>
|
||||
{needsPartner && (
|
||||
<Select
|
||||
label="Consolidation partner"
|
||||
description="This credit has an odd 20ft container — pick the odd booking that shares its wagon. The rebooked booking is paid; it ships once the partner pays."
|
||||
placeholder={
|
||||
!date
|
||||
? "Pick the day first"
|
||||
: partners.isLoading
|
||||
? "Loading…"
|
||||
: "Pick the partner booking"
|
||||
}
|
||||
data={(partners.data ?? []).map((c) => ({
|
||||
value: c.id,
|
||||
label: `${c.reference} · ${c.companyName ?? "—"} · ${c.ft20Quantity}×20ft`,
|
||||
}))}
|
||||
value={partnerId}
|
||||
onChange={setPartnerId}
|
||||
disabled={!date}
|
||||
searchable
|
||||
radius="md"
|
||||
/>
|
||||
)}
|
||||
{needsPartner &&
|
||||
date &&
|
||||
!partners.isLoading &&
|
||||
(partners.data ?? []).length === 0 && (
|
||||
<Text size="xs" c="orange">
|
||||
No odd-20ft booking rides that day — pick another day or wait for
|
||||
a partner booking.
|
||||
</Text>
|
||||
)}
|
||||
{drafts.length > 0 && (
|
||||
<Stack gap={6}>
|
||||
<Text size="xs" c="dimmed">
|
||||
Correct the container details if they changed — sizes and
|
||||
quantities stay as cancelled.
|
||||
</Text>
|
||||
{drafts.map((d, i) => (
|
||||
<Group key={i} gap={8} wrap="nowrap" align="flex-end">
|
||||
<TextInput
|
||||
label={`${d.containerSize} container`}
|
||||
value={d.containerNumber}
|
||||
onChange={(e) => patchDraft(i, { containerNumber: e.currentTarget.value })}
|
||||
size="xs"
|
||||
radius="md"
|
||||
style={{ flex: 1.4 }}
|
||||
/>
|
||||
<TextInput
|
||||
label="Seal no."
|
||||
value={d.sealNumber}
|
||||
onChange={(e) => patchDraft(i, { sealNumber: e.currentTarget.value })}
|
||||
size="xs"
|
||||
radius="md"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<TextInput
|
||||
label="VGM (t)"
|
||||
type="number"
|
||||
value={d.vgmTons === "" ? "" : String(d.vgmTons)}
|
||||
onChange={(e) => {
|
||||
const raw = e.currentTarget.value;
|
||||
patchDraft(i, { vgmTons: raw === "" ? "" : Number(raw) });
|
||||
}}
|
||||
size="xs"
|
||||
radius="md"
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" radius="md" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
color="green"
|
||||
radius="md"
|
||||
disabled={!date || (needsPartner && !partnerId)}
|
||||
loading={rebook.isPending}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const result = await rebook.mutateAsync();
|
||||
toast.success("Credit rebooked as a new paid booking");
|
||||
onClose();
|
||||
onRebooked?.(result);
|
||||
} catch {
|
||||
// interceptor surfaces the reason
|
||||
}
|
||||
}}
|
||||
>
|
||||
Rebook
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useState } from "react";
|
||||
import { Badge, Button, Group, Stack, Text } from "@mantine/core";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { api } from "@/auth/http";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { formatDate, formatMoney } from "@/lib/format";
|
||||
import { RebookWagonCancellationModal } from "./RebookWagonCancellationModal";
|
||||
import {
|
||||
canRebookWagonCancellations,
|
||||
isRebookableCredit,
|
||||
WAGON_CANCELLATION_STATUS_CHIP,
|
||||
type WagonCancellation,
|
||||
type WagonCancellationListResponse,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Per-booking wagon-cancellation ledger with the GL/staff "Rebook" action.
|
||||
* Rendered on the booking pages GL and staff actually work from, so a credit
|
||||
* never sits unredeemed just because the customer cannot rebook it from the
|
||||
* portal (customs bookings, odd-20ft credits) — whoever cancelled the wagons
|
||||
* and whichever side was at fault. Renders nothing when the booking has no
|
||||
* cancellations.
|
||||
*/
|
||||
export function WagonCancellationCreditCard({
|
||||
bookingId,
|
||||
onRebooked,
|
||||
}: {
|
||||
bookingId: string;
|
||||
onRebooked?: () => void;
|
||||
}) {
|
||||
const { user } = useAuth();
|
||||
const qc = useQueryClient();
|
||||
const canRebook = canRebookWagonCancellations(user);
|
||||
const [rebooking, setRebooking] = useState<WagonCancellation | null>(null);
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ["bookings", bookingId, "wagon-cancellations"],
|
||||
queryFn: async () => {
|
||||
const res = await api.get<WagonCancellationListResponse | WagonCancellation[]>(
|
||||
`/bookings/${bookingId}/wagon-cancellations`,
|
||||
);
|
||||
const body = res.data;
|
||||
return Array.isArray(body) ? body : (body?.items ?? []);
|
||||
},
|
||||
});
|
||||
|
||||
const rows = data ?? [];
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionCard
|
||||
icon={RotateCcw}
|
||||
title="Cancelled wagons"
|
||||
subtitle="Credits from cancelled wagons are rebooked here on the customer's behalf."
|
||||
accent="grape"
|
||||
>
|
||||
<Stack gap="sm">
|
||||
{rows.map((r) => {
|
||||
const chip = WAGON_CANCELLATION_STATUS_CHIP[r.status] ?? {
|
||||
label: r.status,
|
||||
color: "gray",
|
||||
};
|
||||
const feeOpen =
|
||||
r.fault === "CUSTOMER" && Number(r.feeAmount) > 0 && !r.feePaidAt;
|
||||
return (
|
||||
<Group key={r.id} justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Stack gap={2} style={{ minWidth: 0 }}>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Text size="sm" fw={600}>
|
||||
{Number(r.wagonsCancelled)} wagon(s) · credit{" "}
|
||||
{formatMoney(Number(r.creditAmount), r.feeCurrency, 2)}
|
||||
</Text>
|
||||
<Badge color={chip.color} variant="light" size="sm" radius="md">
|
||||
{chip.label}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
Cancelled {formatDate(r.createdAt)}
|
||||
{r.fault ? ` · ${r.fault === "EDR" ? "EDR fault (no fee)" : "customer fault"}` : ""}
|
||||
{Number(r.feeAmount) > 0
|
||||
? ` · fee ${formatMoney(Number(r.feeAmount), r.feeCurrency, 2)}${
|
||||
r.feePaidAt ? " paid" : " unpaid"
|
||||
}`
|
||||
: ""}
|
||||
{r.reason ? ` · ${r.reason}` : ""}
|
||||
</Text>
|
||||
{r.status === "FEE_PENDING" && (
|
||||
<Text size="xs" c="orange">
|
||||
The customer must pay the cancellation fee from the portal
|
||||
Payments tab before the credit can be rebooked.
|
||||
</Text>
|
||||
)}
|
||||
{isRebookableCredit(r) && feeOpen && (
|
||||
<Text size="xs" c="orange">
|
||||
The cancellation fee invoice is still open — the rebook is
|
||||
allowed once it is paid.
|
||||
</Text>
|
||||
)}
|
||||
{r.rebookedBookingId && (
|
||||
<Text size="xs">
|
||||
Rebooked as{" "}
|
||||
<Link to={`/dashboard/booking-requests/${r.rebookedBookingId}`}>
|
||||
{r.rebookedBooking?.reference ?? r.rebookedBookingId}
|
||||
</Link>
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
{isRebookableCredit(r) && canRebook && (
|
||||
<Button
|
||||
size="xs"
|
||||
radius="md"
|
||||
variant="light"
|
||||
color="green"
|
||||
leftSection={<RotateCcw size={14} />}
|
||||
onClick={() => setRebooking(r)}
|
||||
>
|
||||
Rebook
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
<RebookWagonCancellationModal
|
||||
cancellation={rebooking}
|
||||
onClose={() => setRebooking(null)}
|
||||
onRebooked={() => {
|
||||
void refetch();
|
||||
void qc.invalidateQueries({ queryKey: ["bookings"] });
|
||||
onRebooked?.();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./types";
|
||||
export * from "./RebookWagonCancellationModal";
|
||||
export * from "./WagonCancellationCreditCard";
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { AuthUser } from "@/auth/types";
|
||||
import { FREIGHT_PERMS, hasPermission, isDjiboutiGl } from "@/lib/permissions";
|
||||
|
||||
export type WagonCancellationStatus =
|
||||
| "FEE_PENDING"
|
||||
| "CREDIT_AVAILABLE"
|
||||
| "REBOOKED"
|
||||
| "WITHDRAWN"
|
||||
| "EXPIRED";
|
||||
|
||||
export interface WagonCancellation {
|
||||
id: string;
|
||||
bookingId: string;
|
||||
rebookedBookingId?: string | null;
|
||||
wagonsCancelled: number;
|
||||
weightTons: number;
|
||||
creditAmount: number;
|
||||
feeAmount: number;
|
||||
feeCurrency: string;
|
||||
feeInvoiceId?: string | null;
|
||||
feePaidAt?: string | null;
|
||||
fault?: "CUSTOMER" | "EDR" | string | null;
|
||||
status: WagonCancellationStatus;
|
||||
reason?: string | null;
|
||||
rebookedAt?: string | null;
|
||||
createdAt: string;
|
||||
booking?: {
|
||||
id: string;
|
||||
reference: string;
|
||||
customsClearingEnabled?: boolean;
|
||||
company?: { name: string };
|
||||
};
|
||||
rebookedBooking?: { id: string; reference: string };
|
||||
feeInvoice?: { invoiceNumber: string; status: string };
|
||||
cancelledQuantities?: {
|
||||
bySize?: Record<string, number>;
|
||||
units?: Array<{
|
||||
containerSize: string;
|
||||
containerNumber: string;
|
||||
sealNumber?: string | null;
|
||||
vgmTons: number;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WagonCancellationListResponse {
|
||||
items: WagonCancellation[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface RebookPartnerCandidate {
|
||||
id: string;
|
||||
reference: string;
|
||||
companyName: string | null;
|
||||
status: string;
|
||||
scheduledDate: string | null;
|
||||
ft20Quantity: number;
|
||||
}
|
||||
|
||||
export const WAGON_CANCELLATION_STATUS_CHIP: Record<
|
||||
WagonCancellationStatus,
|
||||
{ label: string; color: string }
|
||||
> = {
|
||||
FEE_PENDING: { label: "Fee pending", color: "yellow" },
|
||||
CREDIT_AVAILABLE: { label: "Credit available", color: "edr-green" },
|
||||
REBOOKED: { label: "Rebooked", color: "indigo" },
|
||||
WITHDRAWN: { label: "Withdrawn", color: "gray" },
|
||||
EXPIRED: { label: "Expired", color: "red" },
|
||||
};
|
||||
|
||||
/** Odd 20ft in the credit ⇒ the rebooked booking shares a wagon and GL must pick the partner. */
|
||||
export const hasOddFt20 = (r: WagonCancellation): boolean =>
|
||||
Object.entries(r.cancelledQuantities?.bySize ?? {})
|
||||
.filter(([size]) => parseInt(size, 10) === 20)
|
||||
.reduce((sum, [, qty]) => sum + Number(qty || 0), 0) %
|
||||
2 ===
|
||||
1;
|
||||
|
||||
/**
|
||||
* Who may redeem a cancelled-wagon credit from the backoffice: anyone holding
|
||||
* the dedicated rebook key, plus GL Ethiopia through its booking-creation key
|
||||
* (a rebook is a new booking under the contract). GL Djibouti never creates
|
||||
* bookings. Mirrors the API's rebook gate.
|
||||
*/
|
||||
export const canRebookWagonCancellations = (
|
||||
user: AuthUser | null | undefined,
|
||||
): boolean =>
|
||||
hasPermission(user, FREIGHT_PERMS.bookings.wagonCancellationRebook) ||
|
||||
(hasPermission(user, FREIGHT_PERMS.contracts.createBooking) && !isDjiboutiGl(user));
|
||||
|
||||
/** A CREDIT_AVAILABLE row with real credit — whoever cancelled and whoever was at fault. */
|
||||
export const isRebookableCredit = (r: WagonCancellation): boolean =>
|
||||
r.status === "CREDIT_AVAILABLE" && Number(r.creditAmount) > 0;
|
||||
@@ -887,7 +887,9 @@ export default function GlCreateBookingForm() {
|
||||
} else if ((numberCounts.get(key) ?? 0) > 1) {
|
||||
errs.containerNumber = "Duplicate container number in this shipment.";
|
||||
}
|
||||
if (u.sealNumber.trim() === "") {
|
||||
// Every container ships sealed and the yard checks the seal against
|
||||
// the booking — required alongside number and VGM (portal parity).
|
||||
if (!u.sealNumber.trim()) {
|
||||
errs.sealNumber = "Seal number is required.";
|
||||
}
|
||||
const vgm = Number(u.vgmTons);
|
||||
@@ -1051,6 +1053,12 @@ export default function GlCreateBookingForm() {
|
||||
|
||||
const dateError =
|
||||
!isIntercity && !scheduledDate ? "Select a shipment date." : undefined;
|
||||
// EXPORT completion locks the booking onto a train. Only raised once a day is
|
||||
// chosen — the picker is hidden until then and the date error covers it.
|
||||
const trainError =
|
||||
isExportPick && scheduledDate && !trainScheduleId
|
||||
? "Select a train for the shipment day."
|
||||
: undefined;
|
||||
const routeError =
|
||||
multiRoute && !contractRouteId ? "Select a route." : undefined;
|
||||
|
||||
@@ -1069,7 +1077,7 @@ export default function GlCreateBookingForm() {
|
||||
!e.returnQuantity,
|
||||
) &&
|
||||
unitErrors.every((line) =>
|
||||
line.every((e) => !e.containerNumber && !e.vgmTons),
|
||||
line.every((e) => !e.containerNumber && !e.sealNumber && !e.vgmTons),
|
||||
) &&
|
||||
!cargoDescriptionError
|
||||
: !bulkErrors.quantity &&
|
||||
@@ -1116,11 +1124,12 @@ export default function GlCreateBookingForm() {
|
||||
line.units.some(
|
||||
(u) =>
|
||||
!ISO_CONTAINER_NUMBER_REGEX.test(u.containerNumber.trim().toUpperCase()) ||
|
||||
!u.sealNumber.trim() ||
|
||||
!(Number(u.vgmTons) > 0),
|
||||
),
|
||||
);
|
||||
if (badUnit) {
|
||||
return `Every ${partner.reference} container needs a valid container number and a VGM above 0.`;
|
||||
return `Every ${partner.reference} container needs a valid container number, a seal number and a VGM above 0.`;
|
||||
}
|
||||
if (!partnerCargoDescription.trim()) {
|
||||
return `Describe the cargo carried in ${partner.reference}'s containers.`;
|
||||
@@ -1146,6 +1155,7 @@ export default function GlCreateBookingForm() {
|
||||
cargoValid &&
|
||||
!oddBlocksSubmit &&
|
||||
!dateError &&
|
||||
!trainError &&
|
||||
!routeError &&
|
||||
!partnerError &&
|
||||
!currencyError;
|
||||
@@ -1905,7 +1915,7 @@ export default function GlCreateBookingForm() {
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<TextInput
|
||||
placeholder="e.g. SL-0099231"
|
||||
placeholder="e.g. SL0123456"
|
||||
value={unit.sealNumber}
|
||||
error={
|
||||
showErrors
|
||||
@@ -2314,12 +2324,19 @@ export default function GlCreateBookingForm() {
|
||||
</Text>
|
||||
)}
|
||||
{isExportPick && scheduledDate ? (
|
||||
<ExportTrainPicker
|
||||
options={exportTrainsQuery.data ?? []}
|
||||
loading={exportTrainsQuery.isLoading}
|
||||
value={trainScheduleId}
|
||||
onChange={setTrainScheduleId}
|
||||
/>
|
||||
<>
|
||||
<ExportTrainPicker
|
||||
options={exportTrainsQuery.data ?? []}
|
||||
loading={exportTrainsQuery.isLoading}
|
||||
value={trainScheduleId}
|
||||
onChange={setTrainScheduleId}
|
||||
/>
|
||||
{showErrors && trainError && (
|
||||
<Text fz="xs" c="red" mt={6}>
|
||||
{trainError}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</Box>
|
||||
</StepCard>
|
||||
@@ -2410,7 +2427,11 @@ export default function GlCreateBookingForm() {
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
<Text fz={13} fw={500} c="#C0392B">
|
||||
Fix the highlighted fields to review the price.
|
||||
{trainError
|
||||
? "Select a train for the shipment day to review the price."
|
||||
: dateError && isExportPick
|
||||
? "Select a shipment day and a train to review the price."
|
||||
: "Fix the highlighted fields to review the price."}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -196,8 +196,13 @@ export function ConsolidationPartnerPanel({
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Seal number"
|
||||
label="Seal number *"
|
||||
value={unit.sealNumber}
|
||||
error={
|
||||
showErrors && !unit.sealNumber.trim()
|
||||
? "Seal number is required."
|
||||
: undefined
|
||||
}
|
||||
onChange={(e) =>
|
||||
patchUnit(lineIdx, unitIdx, {
|
||||
sealNumber: e.currentTarget.value,
|
||||
|
||||
@@ -1,11 +1,38 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Badge, Center, Group, Loader, Modal, Text, Timeline } from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowRight, PackageCheck, TrainFront, Wrench } from "lucide-react";
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { Freight } from "@edr/types";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
Timeline,
|
||||
} from "@mantine/core";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Activity,
|
||||
ArrowRight,
|
||||
CalendarClock,
|
||||
Container,
|
||||
FileEdit,
|
||||
Link2,
|
||||
Link2Off,
|
||||
MapPin,
|
||||
PackageCheck,
|
||||
PackageX,
|
||||
Pin,
|
||||
PinOff,
|
||||
Route,
|
||||
TrainFront,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import type { FleetRecord } from "@/services/fleet/fleet.service";
|
||||
import type { WagonMovementRecord } from "@/services/wagon.service";
|
||||
|
||||
export interface WagonMovementHistoryModalProps {
|
||||
opened: boolean;
|
||||
@@ -15,43 +42,116 @@ export interface WagonMovementHistoryModalProps {
|
||||
|
||||
const asObj = (r: FleetRecord | null) => (r ?? {}) as Record<string, unknown>;
|
||||
|
||||
/** Chip style per wagon_movements ledger kind. */
|
||||
const KIND_META: Record<string, { label: string; color: string; icon: ReactNode }> = {
|
||||
LOADED: {
|
||||
label: "Loaded leg",
|
||||
color: "edr-green",
|
||||
icon: <PackageCheck size={14} />,
|
||||
},
|
||||
EMPTY_REPOSITION: {
|
||||
label: "Empty reposition",
|
||||
color: "blue",
|
||||
icon: <TrainFront size={14} />,
|
||||
},
|
||||
MANUAL: {
|
||||
label: "Manual move",
|
||||
color: "orange",
|
||||
icon: <Wrench size={14} />,
|
||||
},
|
||||
MAINTENANCE: {
|
||||
label: "Sent to maintenance",
|
||||
color: "red",
|
||||
icon: <Wrench size={14} />,
|
||||
},
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
type EventMeta = { label: string; color: string; icon: ReactNode };
|
||||
|
||||
/** Chip style per history event type. */
|
||||
const EVENT_META: Record<Freight.WagonEventType, EventMeta> = {
|
||||
REGISTERED: { label: "Registered", color: "gray", icon: <FileEdit size={14} /> },
|
||||
DETAILS_UPDATED: { label: "Details updated", color: "gray", icon: <FileEdit size={14} /> },
|
||||
DELETED: { label: "Deleted", color: "red", icon: <Trash2 size={14} /> },
|
||||
PURGED: { label: "Purged", color: "red", icon: <Trash2 size={14} /> },
|
||||
MOVED_MANUALLY: { label: "Moved manually", color: "orange", icon: <MapPin size={14} /> },
|
||||
MOVED_WITH_TRAIN: { label: "Moved with train", color: "orange", icon: <TrainFront size={14} /> },
|
||||
PASSED_CHECKPOINT: { label: "Passed checkpoint", color: "blue", icon: <Route size={14} /> },
|
||||
CUT_AT_YARD: { label: "Cut at yard", color: "yellow", icon: <Link2Off size={14} /> },
|
||||
SETTLED_ON_ARRIVAL: { label: "Arrived", color: "edr-green", icon: <MapPin size={14} /> },
|
||||
RELEASED_AT_UNLOAD: { label: "Released after unload", color: "edr-green", icon: <PackageX size={14} /> },
|
||||
RETURNED_ON_CANCEL: { label: "Schedule cancelled", color: "red", icon: <CalendarClock size={14} /> },
|
||||
COUPLED_TO_TRAIN: { label: "Coupled to train", color: "indigo", icon: <Link2 size={14} /> },
|
||||
UNCOUPLED_FROM_TRAIN: { label: "Uncoupled from train", color: "indigo", icon: <Link2Off size={14} /> },
|
||||
SEQUENCE_CHANGED: { label: "Position changed", color: "indigo", icon: <Link2 size={14} /> },
|
||||
TRAIN_MERGED: { label: "Train merged", color: "indigo", icon: <TrainFront size={14} /> },
|
||||
TRAIN_DISBANDED: { label: "Train disbanded", color: "indigo", icon: <Link2Off size={14} /> },
|
||||
PINNED_TO_SCHEDULE: { label: "Pinned to schedule", color: "cyan", icon: <Pin size={14} /> },
|
||||
UNPINNED_FROM_SCHEDULE: { label: "Unpinned", color: "cyan", icon: <PinOff size={14} /> },
|
||||
DISPATCHED: { label: "Dispatched", color: "cyan", icon: <TrainFront size={14} /> },
|
||||
RELEASED_FROM_SCHEDULE: { label: "Released from schedule", color: "cyan", icon: <PinOff size={14} /> },
|
||||
STATUS_CHANGED: { label: "Status changed", color: "violet", icon: <Activity size={14} /> },
|
||||
CARGO_LOADED: { label: "Cargo loaded", color: "edr-green", icon: <PackageCheck size={14} /> },
|
||||
CARGO_UNLOADED: { label: "Cargo unloaded", color: "teal", icon: <PackageX size={14} /> },
|
||||
BOOKING_UNASSIGNED: { label: "Booking removed", color: "teal", icon: <PackageX size={14} /> },
|
||||
BOOKING_CANCELLED: { label: "Booking cancelled", color: "red", icon: <PackageX size={14} /> },
|
||||
LOAD_MOVED_IN: { label: "Load moved in", color: "teal", icon: <PackageCheck size={14} /> },
|
||||
LOAD_MOVED_OUT: { label: "Load moved out", color: "teal", icon: <PackageX size={14} /> },
|
||||
CONTAINER_PLACED: { label: "Container placed", color: "teal", icon: <Container size={14} /> },
|
||||
CONTAINER_REMOVED: { label: "Container removed", color: "teal", icon: <Container size={14} /> },
|
||||
};
|
||||
|
||||
const yardLabel = (
|
||||
yard: { label?: string; code?: string } | null | undefined,
|
||||
yardId: string | null,
|
||||
) => yard?.label ?? yard?.code ?? yardId ?? "Unknown";
|
||||
const CATEGORY_OPTIONS: Array<{ label: string; value: string }> = [
|
||||
{ label: "All", value: "" },
|
||||
{ label: "Yard", value: Freight.WagonEventCategory.Yard },
|
||||
{ label: "Train", value: Freight.WagonEventCategory.Train },
|
||||
{ label: "Schedule", value: Freight.WagonEventCategory.Schedule },
|
||||
{ label: "Status", value: Freight.WagonEventCategory.Status },
|
||||
{ label: "Cargo", value: Freight.WagonEventCategory.Cargo },
|
||||
{ label: "Record", value: Freight.WagonEventCategory.Lifecycle },
|
||||
];
|
||||
|
||||
const fmt = (iso: string) => {
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
AVAILABLE: "Available",
|
||||
ASSIGNED: "Assigned",
|
||||
IMPORT_READY: "Import ready",
|
||||
EXPORT_READY: "Export ready",
|
||||
MAINTENANCE: "Maintenance",
|
||||
DETAINED: "Detained",
|
||||
OUT_OF_SERVICE: "Out of service",
|
||||
};
|
||||
const statusLabel = (v: string | null) => (v ? (STATUS_LABEL[v] ?? v) : null);
|
||||
|
||||
/** Headline for one event: the from → to pair that best describes it. */
|
||||
const headline = (e: Freight.WagonHistoryEvent): { from: string | null; to: string | null } => {
|
||||
const fromYard = e.fromYardLabel ?? e.fromYardId;
|
||||
const toYard = e.toYardLabel ?? e.toYardId;
|
||||
switch (e.category) {
|
||||
case Freight.WagonEventCategory.Yard:
|
||||
return { from: fromYard, to: toYard };
|
||||
case Freight.WagonEventCategory.Status:
|
||||
return { from: statusLabel(e.fromValue), to: statusLabel(e.toValue) };
|
||||
case Freight.WagonEventCategory.Train:
|
||||
if (e.type === "SEQUENCE_CHANGED") {
|
||||
return { from: e.fromValue ? `#${e.fromValue}` : null, to: e.toValue ? `#${e.toValue}` : null };
|
||||
}
|
||||
if (e.type === "TRAIN_MERGED") return { from: null, to: e.toValue ?? e.trainCode };
|
||||
return {
|
||||
from: e.trainCode ? `Train ${e.trainCode}` : null,
|
||||
to: e.type === "COUPLED_TO_TRAIN" && e.toValue ? `position #${e.toValue}` : null,
|
||||
};
|
||||
case Freight.WagonEventCategory.Schedule:
|
||||
return { from: e.scheduleLabel ? `Run ${e.scheduleLabel}` : null, to: toYard };
|
||||
case Freight.WagonEventCategory.Cargo:
|
||||
if (e.type === "CONTAINER_PLACED" || e.type === "CONTAINER_REMOVED") {
|
||||
return { from: e.toValue ?? e.fromValue, to: null };
|
||||
}
|
||||
if (e.type === "LOAD_MOVED_IN") return { from: e.fromValue ? `from ${e.fromValue}` : null, to: null };
|
||||
if (e.type === "LOAD_MOVED_OUT") return { from: e.toValue ? `to ${e.toValue}` : null, to: null };
|
||||
return { from: e.bookingReference ? `Booking ${e.bookingReference}` : null, to: null };
|
||||
default:
|
||||
return { from: null, to: null };
|
||||
}
|
||||
};
|
||||
|
||||
/** Secondary line: the linked records this event touched, deduplicated against the headline. */
|
||||
const context = (e: Freight.WagonHistoryEvent): string[] => {
|
||||
const parts: string[] = [];
|
||||
if (e.trainCode && e.category !== Freight.WagonEventCategory.Train) parts.push(`Train ${e.trainCode}`);
|
||||
if (e.scheduleLabel && e.category !== Freight.WagonEventCategory.Schedule) parts.push(`Run ${e.scheduleLabel}`);
|
||||
if (e.bookingReference && e.category !== Freight.WagonEventCategory.Cargo) parts.push(`Booking ${e.bookingReference}`);
|
||||
if (e.actorName) parts.push(`by ${e.actorName}`);
|
||||
return parts;
|
||||
};
|
||||
|
||||
/**
|
||||
* Movement ledger for one wagon: every relocation between yards — booking legs,
|
||||
* empty reposition rides, and manual staff corrections — newest first.
|
||||
* Full history of one wagon — every yard move, coupling, schedule pin and
|
||||
* dispatch, status flip, cargo load/unload, container placement and record
|
||||
* edit — newest first, filterable by category, paged with a cursor so a
|
||||
* long-serving wagon never loads its whole life at once.
|
||||
*/
|
||||
const WagonMovementHistoryModal = ({
|
||||
opened,
|
||||
@@ -61,15 +161,27 @@ const WagonMovementHistoryModal = ({
|
||||
const r = asObj(record);
|
||||
const id = r.id ? String(r.id) : "";
|
||||
const wagonNumber = r.wagonNumber ? String(r.wagonNumber) : "";
|
||||
const [category, setCategory] = useState<string>("");
|
||||
|
||||
const { data, isLoading } = useQuery(
|
||||
api.wagons.movements.queryOptions({
|
||||
input: { id },
|
||||
enabled: opened && Boolean(id),
|
||||
const input = useMemo(
|
||||
() => ({
|
||||
id,
|
||||
category: (category || undefined) as Freight.WagonEventCategory | undefined,
|
||||
limit: PAGE_SIZE,
|
||||
}),
|
||||
[id, category],
|
||||
);
|
||||
|
||||
const movements: WagonMovementRecord[] = data ?? [];
|
||||
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } = useInfiniteQuery({
|
||||
queryKey: [...api.wagons.history.queryKey(input), "infinite"],
|
||||
queryFn: ({ pageParam }) =>
|
||||
api.wagons.history.call({ ...input, cursor: pageParam || undefined }),
|
||||
initialPageParam: "" as string,
|
||||
getNextPageParam: (last) => last.nextCursor ?? undefined,
|
||||
enabled: opened && Boolean(id),
|
||||
});
|
||||
|
||||
const events = useMemo(() => data?.pages.flatMap((p) => p.items) ?? [], [data]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -80,63 +192,86 @@ const WagonMovementHistoryModal = ({
|
||||
size="lg"
|
||||
centered
|
||||
>
|
||||
{isLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
) : movements.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="lg" size="sm">
|
||||
No movements recorded yet. Every yard-to-yard move appears here — a
|
||||
booking's loaded leg, an empty reposition ride, or a manual correction.
|
||||
</Text>
|
||||
) : (
|
||||
<Timeline active={movements.length} bulletSize={24} lineWidth={2}>
|
||||
{movements.map((movement) => {
|
||||
const meta = KIND_META[movement.kind] ?? {
|
||||
label: movement.kind,
|
||||
color: "gray",
|
||||
icon: <TrainFront size={14} />,
|
||||
};
|
||||
const from = yardLabel(movement.fromYard, movement.fromYardId);
|
||||
const to = yardLabel(movement.toYard, movement.toYardId);
|
||||
return (
|
||||
<Timeline.Item
|
||||
key={movement.id}
|
||||
bullet={meta.icon}
|
||||
title={
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={600}>
|
||||
{from}
|
||||
</Text>
|
||||
{/* Status events (maintenance) sit in one yard — an arrow
|
||||
pointing at the same yard reads as a broken row. */}
|
||||
{movement.fromYardId !== movement.toYardId && (
|
||||
<>
|
||||
<ArrowRight size={13} />
|
||||
<Stack gap="md">
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={category}
|
||||
onChange={setCategory}
|
||||
data={CATEGORY_OPTIONS}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
) : events.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="lg" size="sm">
|
||||
Nothing recorded yet{category ? " in this category" : ""}. Every yard move,
|
||||
coupling, schedule pin, dispatch, status change and load appears here as it
|
||||
happens.
|
||||
</Text>
|
||||
) : (
|
||||
<Timeline active={events.length} bulletSize={24} lineWidth={2}>
|
||||
{events.map((e) => {
|
||||
const meta = EVENT_META[e.type] ?? {
|
||||
label: e.type,
|
||||
color: "gray",
|
||||
icon: <TrainFront size={14} />,
|
||||
};
|
||||
const { from, to } = headline(e);
|
||||
const extra = context(e);
|
||||
return (
|
||||
<Timeline.Item
|
||||
key={e.id}
|
||||
bullet={meta.icon}
|
||||
title={
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{from && (
|
||||
<Text size="sm" fw={600}>
|
||||
{from}
|
||||
</Text>
|
||||
)}
|
||||
{from && to && from !== to && <ArrowRight size={13} />}
|
||||
{to && to !== from && (
|
||||
<Text size="sm" fw={600}>
|
||||
{to}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
<Badge size="xs" variant="light" color={meta.color}>
|
||||
{meta.label}
|
||||
</Badge>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
{movement.note && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{movement.note}
|
||||
)}
|
||||
<Badge size="xs" variant="light" color={meta.color}>
|
||||
{meta.label}
|
||||
</Badge>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
{e.reason && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{e.reason}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="xs" mt={4} c="dimmed">
|
||||
{fmt(e.occurredAt)}
|
||||
{extra.length ? ` · ${extra.join(" · ")}` : ""}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="xs" mt={4} c="dimmed">
|
||||
{fmt(movement.occurredAt)}
|
||||
</Text>
|
||||
</Timeline.Item>
|
||||
);
|
||||
})}
|
||||
</Timeline>
|
||||
)}
|
||||
</Timeline.Item>
|
||||
);
|
||||
})}
|
||||
</Timeline>
|
||||
)}
|
||||
|
||||
{hasNextPage && (
|
||||
<Center>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="subtle"
|
||||
loading={isFetchingNextPage}
|
||||
onClick={() => void fetchNextPage()}
|
||||
>
|
||||
Load older events
|
||||
</Button>
|
||||
</Center>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -277,8 +277,15 @@ export function AllocateBookingWizard({
|
||||
if (!routeId || !scheduleDate || locomotiveIds.length < 2) {
|
||||
throw new Error("Select route, date, and at least two locomotives");
|
||||
}
|
||||
// This ad-hoc path has no built train (and so no run number) and no voyage
|
||||
// input, but voyage number is required at creation — default it to a
|
||||
// date-stamped placeholder that staff can edit later on the schedule.
|
||||
const voyageNumber = `V-${new Date(scheduleDate)
|
||||
.toISOString()
|
||||
.slice(0, 10)
|
||||
.replace(/-/g, "")}`;
|
||||
const created = await create.mutateAsync({
|
||||
payload: { routeId, scheduleDate, locomotiveIds },
|
||||
payload: { routeId, scheduleDate, voyageNumber, locomotiveIds },
|
||||
});
|
||||
showScheduleWarnings(created.warnings);
|
||||
setSelectedScheduleId(created.id);
|
||||
|
||||
@@ -382,7 +382,12 @@ export function IntercityRideAlongPanel({
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end">
|
||||
{row.status === "PAID" && (
|
||||
{/* Paid = PAYMENT status only; still show Load only
|
||||
while the cargo has not ridden yet. */}
|
||||
{row.paymentStatus === "PAID" &&
|
||||
row.status !== "IN_TRANSIT" &&
|
||||
row.status !== "ARRIVED" &&
|
||||
row.status !== "COMPLETED" && (
|
||||
<Tooltip
|
||||
label={
|
||||
canLoad
|
||||
|
||||
@@ -534,7 +534,8 @@ function LocoDetailPanel({
|
||||
<Text size="sm" c="gray.5">No locomotive assigned yet.</Text>
|
||||
)}
|
||||
<Divider color="rgba(255,255,255,0.1)" label="Train" labelPosition="left" />
|
||||
<InfoRow label="Voyage / reference" value={schedule.reference} />
|
||||
<InfoRow label="Voyage number" value={schedule.voyageNumber} />
|
||||
<InfoRow label="Reference" value={schedule.reference} />
|
||||
<InfoRow label="Train number" value={schedule.trainNumber} />
|
||||
<InfoRow
|
||||
label="Train"
|
||||
|
||||
@@ -81,6 +81,7 @@ import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions";
|
||||
import { WagonCancellationCreditCard } from "@/components/bookings/wagon-cancellation";
|
||||
|
||||
export default function BookingRequestDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -652,6 +653,7 @@ function OverviewPanel({
|
||||
/>
|
||||
<BookingCargoCard booking={booking} />
|
||||
<BookingContainerUnitsCard booking={booking} />
|
||||
<WagonCancellationCreditCard bookingId={booking.id} onRebooked={onRefetch} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
} from "@/components/bookings/detail";
|
||||
import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceReviewSection";
|
||||
import { AdditionalDocsRequestCard } from "@/components/bookings/detail/AdditionalDocsRequestCard";
|
||||
import { WagonCancellationCreditCard } from "@/components/bookings/wagon-cancellation";
|
||||
import { ClearancePhaseStepper } from "@/components/contracts/ClearancePhaseStepper";
|
||||
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
|
||||
import { ClearanceMilestoneTimeline } from "@/components/contracts/ClearanceMilestoneTimeline";
|
||||
@@ -374,6 +375,13 @@ export default function DocumentClearanceDetailPage() {
|
||||
/>
|
||||
{booking ? <BookingCompanyCard booking={booking} /> : null}
|
||||
{booking ? <BookingContractCard booking={booking} /> : null}
|
||||
{/* Cancelled-wagon credits: GL rebooks them for the customer. */}
|
||||
{id ? (
|
||||
<WagonCancellationCreditCard
|
||||
bookingId={id}
|
||||
onRebooked={() => void refetch()}
|
||||
/>
|
||||
) : null}
|
||||
{isPhasedGeneral ? (
|
||||
<PhasedClearanceActionPanel
|
||||
bookingId={id!}
|
||||
|
||||
@@ -33,87 +33,15 @@ import {
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
type WagonCancellationStatus =
|
||||
| "FEE_PENDING"
|
||||
| "CREDIT_AVAILABLE"
|
||||
| "REBOOKED"
|
||||
| "WITHDRAWN"
|
||||
| "EXPIRED";
|
||||
|
||||
interface WagonCancellation {
|
||||
id: string;
|
||||
bookingId: string;
|
||||
rebookedBookingId?: string | null;
|
||||
wagonsCancelled: number;
|
||||
weightTons: number;
|
||||
creditAmount: number;
|
||||
feeAmount: number;
|
||||
feeCurrency: string;
|
||||
feeInvoiceId?: string | null;
|
||||
feePaidAt?: string | null;
|
||||
status: WagonCancellationStatus;
|
||||
reason?: string | null;
|
||||
rebookedAt?: string | null;
|
||||
createdAt: string;
|
||||
booking?: {
|
||||
id: string;
|
||||
reference: string;
|
||||
customsClearingEnabled?: boolean;
|
||||
company?: { name: string };
|
||||
};
|
||||
rebookedBooking?: { id: string; reference: string };
|
||||
feeInvoice?: { invoiceNumber: string; status: string };
|
||||
cancelledQuantities?: {
|
||||
bySize?: Record<string, number>;
|
||||
units?: Array<{
|
||||
containerSize: string;
|
||||
containerNumber: string;
|
||||
sealNumber?: string | null;
|
||||
vgmTons: number;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
interface RebookPartnerCandidate {
|
||||
id: string;
|
||||
reference: string;
|
||||
companyName: string | null;
|
||||
status: string;
|
||||
scheduledDate: string | null;
|
||||
ft20Quantity: number;
|
||||
}
|
||||
|
||||
/** Odd 20ft in the credit ⇒ the rebooked booking shares a wagon and GL must pick the partner. */
|
||||
const hasOddFt20 = (r: WagonCancellation): boolean =>
|
||||
Object.entries(r.cancelledQuantities?.bySize ?? {})
|
||||
.filter(([size]) => parseInt(size, 10) === 20)
|
||||
.reduce((sum, [, qty]) => sum + Number(qty || 0), 0) %
|
||||
2 ===
|
||||
1;
|
||||
|
||||
/** Editable rebook unit — prefilled from the cancelled snapshot. */
|
||||
interface RebookUnitDraft {
|
||||
containerSize: string;
|
||||
containerNumber: string;
|
||||
sealNumber: string;
|
||||
vgmTons: number | "";
|
||||
}
|
||||
|
||||
interface WagonCancellationListResponse {
|
||||
items: WagonCancellation[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
const STATUS_CHIP: Record<
|
||||
WagonCancellationStatus,
|
||||
{ label: string; color: string }
|
||||
> = {
|
||||
FEE_PENDING: { label: "Fee pending", color: "yellow" },
|
||||
CREDIT_AVAILABLE: { label: "Credit available", color: "edr-green" },
|
||||
REBOOKED: { label: "Rebooked", color: "indigo" },
|
||||
WITHDRAWN: { label: "Withdrawn", color: "gray" },
|
||||
EXPIRED: { label: "Expired", color: "red" },
|
||||
};
|
||||
import {
|
||||
canRebookWagonCancellations,
|
||||
isRebookableCredit,
|
||||
RebookWagonCancellationModal,
|
||||
WAGON_CANCELLATION_STATUS_CHIP as STATUS_CHIP,
|
||||
type WagonCancellation,
|
||||
type WagonCancellationListResponse,
|
||||
type WagonCancellationStatus,
|
||||
} from "@/components/bookings/wagon-cancellation";
|
||||
|
||||
const STATUS_FILTER_OPTIONS = (
|
||||
Object.keys(STATUS_CHIP) as WagonCancellationStatus[]
|
||||
@@ -155,72 +83,11 @@ export default function WagonCancellationsPage() {
|
||||
const [from, setFrom] = useState<Date | null>(null);
|
||||
const [to, setTo] = useState<Date | null>(null);
|
||||
const [voiding, setVoiding] = useState<WagonCancellation | null>(null);
|
||||
// GL rebook of a customs (Path B) credit: pick the day; container number /
|
||||
// seal / VGM may be corrected. Non-customs credits are rebooked by the
|
||||
// customer from the portal.
|
||||
const canRebook = hasPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.bookings.wagonCancellationRebook,
|
||||
);
|
||||
// Rebook of a CREDIT_AVAILABLE row — any desk holding the rebook key, or GL
|
||||
// Ethiopia through its booking-creation key. The modal handles the day pick,
|
||||
// container corrections and the odd-20ft partner choice.
|
||||
const canRebook = canRebookWagonCancellations(user);
|
||||
const [rebooking, setRebooking] = useState<WagonCancellation | null>(null);
|
||||
const [rebookDate, setRebookDate] = useState<Date | null>(null);
|
||||
const [rebookPartnerId, setRebookPartnerId] = useState<string | null>(null);
|
||||
const [rebookDrafts, setRebookDrafts] = useState<RebookUnitDraft[]>([]);
|
||||
const openRebook = (r: WagonCancellation) => {
|
||||
setRebooking(r);
|
||||
setRebookDate(null);
|
||||
setRebookPartnerId(null);
|
||||
setRebookDrafts(
|
||||
(r.cancelledQuantities?.units ?? []).map((u) => ({
|
||||
containerSize: u.containerSize,
|
||||
containerNumber: u.containerNumber,
|
||||
sealNumber: u.sealNumber ?? "",
|
||||
vgmTons: Number(u.vgmTons) || "",
|
||||
})),
|
||||
);
|
||||
};
|
||||
const rebookContainersPayload = () => {
|
||||
const bySize = new Map<string, RebookUnitDraft[]>();
|
||||
for (const d of rebookDrafts) {
|
||||
bySize.set(d.containerSize, [...(bySize.get(d.containerSize) ?? []), d]);
|
||||
}
|
||||
return [...bySize.entries()].map(([containerSize, units]) => ({
|
||||
containerSize,
|
||||
units: units.map((u) => ({
|
||||
containerNumber: u.containerNumber.trim(),
|
||||
...(u.sealNumber.trim() ? { sealNumber: u.sealNumber.trim() } : {}),
|
||||
...(u.vgmTons !== "" ? { vgmTons: Number(u.vgmTons) } : {}),
|
||||
})),
|
||||
}));
|
||||
};
|
||||
const rebook = useMutation({
|
||||
mutationFn: () =>
|
||||
api.post(`/bookings/wagon-cancellations/${rebooking!.id}/rebook`, {
|
||||
scheduledDate: toDayString(rebookDate!),
|
||||
...(rebookDrafts.length ? { containers: rebookContainersPayload() } : {}),
|
||||
...(rebookPartnerId ? { partnerBookingId: rebookPartnerId } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
// Odd-20ft credit: the rebooked booking shares a wagon again, so GL must pick
|
||||
// the odd partner booking riding the chosen day. It ships once that partner pays.
|
||||
const rebookNeedsPartner = rebooking ? hasOddFt20(rebooking) : false;
|
||||
const rebookPartners = useQuery({
|
||||
queryKey: [
|
||||
"wagon-cancellations",
|
||||
rebooking?.id,
|
||||
"rebook-partners",
|
||||
rebookDate ? toDayString(rebookDate) : null,
|
||||
],
|
||||
enabled: Boolean(rebooking && rebookNeedsPartner && rebookDate),
|
||||
queryFn: async () => {
|
||||
const res = await api.get<RebookPartnerCandidate[]>(
|
||||
`/bookings/wagon-cancellations/${rebooking!.id}/rebook-partners`,
|
||||
{ params: { scheduledDate: toDayString(rebookDate!) } },
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
const resetPage = () =>
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
@@ -340,14 +207,12 @@ export default function WagonCancellationsPage() {
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
const showVoid = r.status === "FEE_PENDING" && canVoid;
|
||||
// Customs credits are GL's to rebook; non-customs ones the customer
|
||||
// rebooks from the portal — EXCEPT odd-20ft credits: those must be
|
||||
// re-paired with a partner booking, which only GL can pick.
|
||||
const showRebook =
|
||||
r.status === "CREDIT_AVAILABLE" &&
|
||||
canRebook &&
|
||||
(Boolean(r.booking?.customsClearingEnabled) || hasOddFt20(r)) &&
|
||||
Number(r.creditAmount) > 0;
|
||||
// Every available credit is rebookable from here — customs or not,
|
||||
// customer- or staff-cancelled, EDR or customer fault. The customer can
|
||||
// also self-rebook plain non-customs credits from the portal, but GL
|
||||
// must be able to do it for them (customs bookings and odd-20ft
|
||||
// credits are never self-booked). The API enforces the fee gate.
|
||||
const showRebook = isRebookableCredit(r) && canRebook;
|
||||
if (!showVoid && !showRebook) return null;
|
||||
return (
|
||||
<Group justify="flex-end" wrap="nowrap">
|
||||
@@ -357,7 +222,7 @@ export default function WagonCancellationsPage() {
|
||||
radius="md"
|
||||
variant="light"
|
||||
color="green"
|
||||
onClick={() => openRebook(r)}
|
||||
onClick={() => setRebooking(r)}
|
||||
>
|
||||
Rebook
|
||||
</Button>
|
||||
@@ -518,153 +383,11 @@ export default function WagonCancellationsPage() {
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
<Modal
|
||||
opened={!!rebooking}
|
||||
<RebookWagonCancellationModal
|
||||
cancellation={rebooking}
|
||||
onClose={() => setRebooking(null)}
|
||||
title="Rebook cancelled wagons"
|
||||
centered
|
||||
radius="md"
|
||||
>
|
||||
{rebooking && (
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">
|
||||
{rebooking.booking?.reference ?? rebooking.bookingId} ·{" "}
|
||||
{rebooking.wagonsCancelled} wagon(s) · credit{" "}
|
||||
{formatMoney(rebooking.creditAmount, rebooking.feeCurrency, 2)}
|
||||
</Text>
|
||||
<DatePickerInput
|
||||
label="Shipment day"
|
||||
placeholder="Pick the day"
|
||||
value={rebookDate}
|
||||
onChange={(v) => {
|
||||
setRebookDate(v ? new Date(v) : null);
|
||||
setRebookPartnerId(null);
|
||||
}}
|
||||
radius="md"
|
||||
/>
|
||||
{rebookNeedsPartner && (
|
||||
<Select
|
||||
label="Consolidation partner"
|
||||
description="This credit has an odd 20ft container — pick the odd booking that shares its wagon. The rebooked booking is paid; it ships once the partner pays."
|
||||
placeholder={
|
||||
!rebookDate
|
||||
? "Pick the day first"
|
||||
: rebookPartners.isLoading
|
||||
? "Loading…"
|
||||
: "Pick the partner booking"
|
||||
}
|
||||
data={(rebookPartners.data ?? []).map((c) => ({
|
||||
value: c.id,
|
||||
label: `${c.reference} · ${c.companyName ?? "—"} · ${c.ft20Quantity}×20ft`,
|
||||
}))}
|
||||
value={rebookPartnerId}
|
||||
onChange={setRebookPartnerId}
|
||||
disabled={!rebookDate}
|
||||
searchable
|
||||
radius="md"
|
||||
/>
|
||||
)}
|
||||
{rebookNeedsPartner &&
|
||||
rebookDate &&
|
||||
!rebookPartners.isLoading &&
|
||||
(rebookPartners.data ?? []).length === 0 && (
|
||||
<Text size="xs" c="orange">
|
||||
No odd-20ft booking rides that day — pick another day or wait
|
||||
for a partner booking.
|
||||
</Text>
|
||||
)}
|
||||
{rebookDrafts.length > 0 && (
|
||||
<Stack gap={6}>
|
||||
<Text size="xs" c="dimmed">
|
||||
Correct the container details if they changed — sizes and
|
||||
quantities stay as cancelled.
|
||||
</Text>
|
||||
{rebookDrafts.map((d, i) => (
|
||||
<Group key={i} gap={8} wrap="nowrap" align="flex-end">
|
||||
<TextInput
|
||||
label={`${d.containerSize} container`}
|
||||
value={d.containerNumber}
|
||||
onChange={(e) => {
|
||||
const v = e.currentTarget.value;
|
||||
setRebookDrafts((prev) =>
|
||||
prev.map((x, idx) =>
|
||||
idx === i ? { ...x, containerNumber: v } : x,
|
||||
),
|
||||
);
|
||||
}}
|
||||
size="xs"
|
||||
radius="md"
|
||||
style={{ flex: 1.4 }}
|
||||
/>
|
||||
<TextInput
|
||||
label="Seal no."
|
||||
value={d.sealNumber}
|
||||
onChange={(e) => {
|
||||
const v = e.currentTarget.value;
|
||||
setRebookDrafts((prev) =>
|
||||
prev.map((x, idx) =>
|
||||
idx === i ? { ...x, sealNumber: v } : x,
|
||||
),
|
||||
);
|
||||
}}
|
||||
size="xs"
|
||||
radius="md"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<TextInput
|
||||
label="VGM (t)"
|
||||
type="number"
|
||||
value={d.vgmTons === "" ? "" : String(d.vgmTons)}
|
||||
onChange={(e) => {
|
||||
const raw = e.currentTarget.value;
|
||||
setRebookDrafts((prev) =>
|
||||
prev.map((x, idx) =>
|
||||
idx === i
|
||||
? { ...x, vgmTons: raw === "" ? "" : Number(raw) }
|
||||
: x,
|
||||
),
|
||||
);
|
||||
}}
|
||||
size="xs"
|
||||
radius="md"
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
onClick={() => setRebooking(null)}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
color="green"
|
||||
radius="md"
|
||||
disabled={
|
||||
!rebookDate || (rebookNeedsPartner && !rebookPartnerId)
|
||||
}
|
||||
loading={rebook.isPending}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await rebook.mutateAsync();
|
||||
toast.success("Credit rebooked as a new paid booking");
|
||||
setRebooking(null);
|
||||
void refetch();
|
||||
} catch {
|
||||
// interceptor surfaces the reason
|
||||
}
|
||||
}}
|
||||
>
|
||||
Rebook
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
onRebooked={() => void refetch()}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -525,9 +525,10 @@ export default function TrainScheduleV2DetailPage() {
|
||||
b.originYardId === originYardId &&
|
||||
!b.loadedAt &&
|
||||
(b.loadingStatus ?? "UNLOADED") !== "LOADED" &&
|
||||
// Paid is read from the PAYMENT status only, never booking.status.
|
||||
(b.isGovernment
|
||||
? b.status === "APPROVED" || b.status === "PAID"
|
||||
: b.status === "PAID" ||
|
||||
? b.status === "APPROVED" || b.paymentStatus === "PAID"
|
||||
: b.paymentStatus === "PAID" ||
|
||||
// Shipping-line bookings ride from accept on the credit ledger.
|
||||
(Boolean(b.shippingLineCompanyId) && b.status === "FULLY_EXECUTED")),
|
||||
);
|
||||
|
||||
@@ -158,6 +158,11 @@ export default function TrainScheduleV2ListPage() {
|
||||
const [routeId, setRouteId] = useState("");
|
||||
const [scheduleDate, setScheduleDate] = useState("");
|
||||
const [trainId, setTrainId] = useState("");
|
||||
// Voyage number for this departure — required. Auto-filled from the selected
|
||||
// train's own voyage number (typed in the Train Builder) when a train is
|
||||
// picked; legacy trains without one fall back to the direction-matched run
|
||||
// number. Staff may edit.
|
||||
const [voyageNumber, setVoyageNumber] = useState("");
|
||||
const [reverseWagonOrder, setReverseWagonOrder] = useState(false);
|
||||
// "" = a normal customer train; an id dedicates the departure to that
|
||||
// shipping line and hides it from every customer-facing view.
|
||||
@@ -461,6 +466,13 @@ export default function TrainScheduleV2ListPage() {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!voyageNumber.trim()) {
|
||||
toast({
|
||||
title: "Voyage number is required",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Only build the window override when the toggle is on — off means "inherit
|
||||
// the global rules", which the API expresses as an absent windowRule.
|
||||
let windowRule: CreateScheduleWindowRulePayload | undefined;
|
||||
@@ -483,6 +495,7 @@ export default function TrainScheduleV2ListPage() {
|
||||
routeId,
|
||||
scheduleDate: new Date(scheduleDate).toISOString(),
|
||||
trainId,
|
||||
voyageNumber: voyageNumber.trim(),
|
||||
reverseWagonOrder,
|
||||
...(shippingLineCompanyId ? { shippingLineCompanyId } : {}),
|
||||
...(windowRule ? { windowRule } : {}),
|
||||
@@ -490,6 +503,7 @@ export default function TrainScheduleV2ListPage() {
|
||||
});
|
||||
toast({ title: "Train schedule created" });
|
||||
showScheduleWarnings(created.warnings);
|
||||
setVoyageNumber("");
|
||||
setReverseWagonOrder(false);
|
||||
setShippingLineCompanyId("");
|
||||
setConfigureWindow(false);
|
||||
@@ -689,7 +703,20 @@ export default function TrainScheduleV2ListPage() {
|
||||
};
|
||||
})}
|
||||
value={trainId || null}
|
||||
onChange={(v) => setTrainId(v ?? "")}
|
||||
onChange={(v) => {
|
||||
setTrainId(v ?? "");
|
||||
// Default the voyage number to the picked train's own voyage
|
||||
// number (the Train Builder stores it as `trainName`). The run
|
||||
// number is a train number, not a voyage — only fall back to it
|
||||
// for legacy trains that have no voyage number yet; staff can
|
||||
// still override.
|
||||
const picked = (trainsQuery.data ?? []).find((t) => t.id === v);
|
||||
const runNumber =
|
||||
selectedRoute?.direction === "IMPORT"
|
||||
? picked?.importTrainNumber
|
||||
: picked?.exportTrainNumber;
|
||||
setVoyageNumber(picked?.trainName?.trim() || runNumber || "");
|
||||
}}
|
||||
searchable
|
||||
disabled={!routeId}
|
||||
nothingFoundMessage={
|
||||
@@ -698,6 +725,15 @@ export default function TrainScheduleV2ListPage() {
|
||||
: "Select a route first"
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Voyage number"
|
||||
description="Sailing/run number for this departure that yards and customs quote. Defaults to the selected train's voyage number — edit if needed."
|
||||
placeholder={trainId ? "e.g. V-2026-0620" : "Select a train first"}
|
||||
required
|
||||
maxLength={20}
|
||||
value={voyageNumber}
|
||||
onChange={(e) => setVoyageNumber(e.currentTarget.value)}
|
||||
/>
|
||||
<Select
|
||||
label="Shipping line (optional)"
|
||||
description="Dedicate this departure to one shipping line. The train is then hidden from customers and shown only in that line's portal."
|
||||
@@ -930,7 +966,12 @@ function TrainIdentityCell({ schedule }: { schedule: TrainScheduleListItem }) {
|
||||
let subtitle = "";
|
||||
if (schedule.train) {
|
||||
title = schedule.trainNumber ?? schedule.train.code;
|
||||
subtitle = [schedule.trainNumber ? schedule.train.code : null, schedule.train.trainName]
|
||||
// Show THIS departure's voyage number (the schedule's own), not the train's
|
||||
// voyage/name — one train serves many departures, each with its own voyage.
|
||||
subtitle = [
|
||||
schedule.trainNumber ? schedule.train.code : null,
|
||||
schedule.voyageNumber ? `Voyage ${schedule.voyageNumber}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
} else if (locos.length) {
|
||||
|
||||
@@ -205,7 +205,7 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
{/* Work the cargo right here while the train is at the yard. */}
|
||||
{r.trainScheduleId && atOrigin(r) && isWaiting(r) && r.status === "PAID" && (
|
||||
{r.trainScheduleId && atOrigin(r) && isWaiting(r) && r.paymentStatus === "PAID" && (
|
||||
<Tooltip label={canLoad ? undefined : "You don't have permission to load cargo"} disabled={canLoad}>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
|
||||
@@ -18,7 +18,9 @@ import { api } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
||||
|
||||
const isPaid = (item: WarehouseInventoryItem) => item.booking?.status === 'PAID';
|
||||
// "Paid" is the booking's PAYMENT status only — never booking.status === 'PAID'.
|
||||
const isPaid = (item: WarehouseInventoryItem) =>
|
||||
(item.booking?.paymentStatus ?? item.bookingPaymentStatus) === 'PAID';
|
||||
|
||||
/**
|
||||
* Loading Queue — manage inventory through the loading workflow.
|
||||
|
||||
@@ -272,6 +272,7 @@ import {
|
||||
type BulkFulfillResult,
|
||||
type TransferHistory,
|
||||
type TransferRequestListFilter,
|
||||
type WagonHistoryParams,
|
||||
} from "./wagon.service";
|
||||
import { warehouseService } from "./warehouse.service";
|
||||
|
||||
@@ -2196,6 +2197,22 @@ export const api = {
|
||||
({ id }) => wagonService.getStatusHistory(id).then((r) => r.data),
|
||||
({ id }) => ["wagons", "status-history", id],
|
||||
),
|
||||
|
||||
/** One keyset page of the unified wagon history; page with `cursor`. */
|
||||
history: endpoint<{ id: string } & WagonHistoryParams, Freight.WagonHistoryPage>(
|
||||
"wagons",
|
||||
"history",
|
||||
({ id, ...params }) => wagonService.getHistory(id, params).then((r) => r.data),
|
||||
({ id, category, types, cursor, limit }) => [
|
||||
"wagons",
|
||||
"history",
|
||||
id,
|
||||
category ?? null,
|
||||
types?.join(",") ?? null,
|
||||
cursor ?? null,
|
||||
limit ?? null,
|
||||
],
|
||||
),
|
||||
},
|
||||
|
||||
wagonTransferRequests: {
|
||||
|
||||
@@ -154,8 +154,29 @@ export const wagonService = {
|
||||
/** Status audit trail for one wagon, newest first. */
|
||||
getStatusHistory: (id: string) =>
|
||||
apiClient.get<WagonStatusLog[]>(`/wagons/${id}/status-history`),
|
||||
/**
|
||||
* Unified history (yard moves, coupling, schedule pins/dispatch, status,
|
||||
* cargo, lifecycle) — one keyset page, newest first. Pass the previous
|
||||
* page's `nextCursor` to continue.
|
||||
*/
|
||||
getHistory: (id: string, params: WagonHistoryParams = {}) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.category) qs.set('category', params.category);
|
||||
if (params.types?.length) qs.set('types', params.types.join(','));
|
||||
if (params.cursor) qs.set('cursor', params.cursor);
|
||||
if (params.limit) qs.set('limit', String(params.limit));
|
||||
const q = qs.toString();
|
||||
return apiClient.get<Freight.WagonHistoryPage>(`/wagons/${id}/history${q ? `?${q}` : ''}`);
|
||||
},
|
||||
};
|
||||
|
||||
export interface WagonHistoryParams {
|
||||
category?: Freight.WagonEventCategory;
|
||||
types?: Freight.WagonEventType[];
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A two-person wagon-transfer request: a requester asks for N wagons of a type
|
||||
* to move between yards (count only); OCC hand-picks the wagons and fulfils it.
|
||||
|
||||
@@ -192,6 +192,8 @@ export interface TrainScheduleListItem {
|
||||
createdAt?: string | null;
|
||||
scheduleDate: string;
|
||||
trainNumber?: string | null;
|
||||
/** Voyage (sailing) number for THIS departure — the schedule's own, not the train's. */
|
||||
voyageNumber?: string | null;
|
||||
/** Trade direction of this departure (IMPORT / EXPORT), when known. */
|
||||
direction?: string | null;
|
||||
routeName?: string | null;
|
||||
@@ -766,6 +768,8 @@ export interface TrainScheduleDetail {
|
||||
/** Cargo only (VGM/bulk tons) — the booked weight without wagon tare. */
|
||||
cargoWeightTons?: number;
|
||||
status: string | null;
|
||||
/** Payment status — the only signal that decides whether cargo may load. */
|
||||
paymentStatus?: string | null;
|
||||
schedulingStatus?: SchedulingStatus | null;
|
||||
freightType?: FreightType | string | null;
|
||||
/** DOMESTIC = intercity ride-along; rides only its own leg below. */
|
||||
@@ -1034,6 +1038,12 @@ export interface ReschedulePlan {
|
||||
export interface CreateTrainSchedulePayload {
|
||||
routeId: string;
|
||||
scheduleDate: string;
|
||||
/**
|
||||
* Voyage (sailing) number for this departure — required. The create dialog
|
||||
* pre-fills it with the selected train's direction-matched run number; staff
|
||||
* may override before submitting.
|
||||
*/
|
||||
voyageNumber: string;
|
||||
/** Built train (Train Builder) to run this departure — its locomotives are used. */
|
||||
trainId?: string;
|
||||
/** Hand-picked locomotives (minimum 2 — front and back). Ignored when trainId is set. */
|
||||
@@ -1145,6 +1155,8 @@ export interface IntercityBookingRow {
|
||||
id: string;
|
||||
reference: string | null;
|
||||
status: string;
|
||||
/** Payment status — the only signal that decides whether cargo may load. */
|
||||
paymentStatus?: string | null;
|
||||
freightType: FreightType | null;
|
||||
isGovernment: boolean;
|
||||
customer: string;
|
||||
@@ -1265,6 +1277,8 @@ export interface IntercityRideAlongRow {
|
||||
bookingId: string;
|
||||
reference: string | null;
|
||||
status: string;
|
||||
/** Payment status — the only signal that decides whether cargo may load. */
|
||||
paymentStatus?: string | null;
|
||||
freightType: string | null;
|
||||
weightTons: number | null;
|
||||
loadedAt: string | null;
|
||||
|
||||
@@ -361,6 +361,8 @@ export interface WarehouseInventoryItem {
|
||||
/** Flat booking summary fields attached by the inventory list (attachBookingSummaries). */
|
||||
bookingReference?: string | null;
|
||||
bookingStatus?: string | null;
|
||||
/** Payment status of the booking — the only signal that decides "paid". */
|
||||
bookingPaymentStatus?: string | null;
|
||||
customerName?: string | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,8 @@ export type BookingDetail = Freight.IBooking & {
|
||||
/** The allocated train, present once the booking is placed on a schedule. */
|
||||
trainSchedule?: {
|
||||
trainNumber: string | null;
|
||||
/** The schedule's own voyage (sailing) number for this departure. */
|
||||
voyageNumber: string | null;
|
||||
reference: string | null;
|
||||
scheduledDepartureDate: string | null;
|
||||
} | null;
|
||||
|
||||
@@ -106,12 +106,19 @@ export function ScheduleCard({
|
||||
value: <StatusPill status={booking.status as string} />,
|
||||
};
|
||||
|
||||
// The schedule's own voyage (sailing) number — shown once the booking has an
|
||||
// assigned train that carries one.
|
||||
const voyageRows: Row[] = schedule?.voyageNumber
|
||||
? [{ label: "Voyage number", value: schedule.voyageNumber }]
|
||||
: [];
|
||||
|
||||
const rows: Row[] = consignment
|
||||
? [
|
||||
{ label: "Consignment ID", value: booking.reference },
|
||||
{ label: "Service", value: service },
|
||||
{ label: "Equipment return", value: equipmentReturn },
|
||||
assignedTrain,
|
||||
...voyageRows,
|
||||
{ label: "Scheduled", value: fmtDate(booking.scheduledDate) },
|
||||
]
|
||||
: [
|
||||
@@ -120,6 +127,7 @@ export function ScheduleCard({
|
||||
{ label: "Equipment return", value: equipmentReturn },
|
||||
{ label: "Proposed date", value: fmtDate(booking.scheduledDate) },
|
||||
assignedTrain,
|
||||
...voyageRows,
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -311,7 +311,7 @@ export function WagonCancellationCard({
|
||||
{isCustoms || oddFt20Credit ? (
|
||||
<Text fz={13} c="#475569">
|
||||
{isCustoms
|
||||
? "This is a customs-cleared booking — Global Logistics will rebook the credit for you."
|
||||
? "This is a customs-cleared booking — Global Logistics (EDR staff) will rebook the credit for you on a coming train day. Contact them if you have a preferred date."
|
||||
: "Your credit includes an odd 20ft container that must share a wagon with another booking — Global Logistics will rebook it for you and pair the wagon. Please contact EDR staff."}
|
||||
</Text>
|
||||
) : (
|
||||
|
||||
@@ -660,6 +660,21 @@ function NewShipmentBookingForm({
|
||||
// summary alert next to the submit button so the click never looks inert.
|
||||
const showValidationSummary =
|
||||
form.formState.isSubmitted && !form.formState.isValid;
|
||||
// Export completion must lock onto a train. The button stays enabled (a
|
||||
// disabled button with no explanation looks broken) — instead, once the
|
||||
// customer tries to review, name the missing pick here in the always-visible
|
||||
// footer, because the train picker itself is usually scrolled off-screen.
|
||||
const requiresTrainPick =
|
||||
contract.tradeDirection === "EXPORT" && Boolean(completeBookingId);
|
||||
const watchedScheduledDate = form.watch("scheduledDate");
|
||||
const watchedTrainId = form.watch("trainScheduleId");
|
||||
const validationSummaryText = !requiresTrainPick
|
||||
? "Fix the highlighted fields to review the price."
|
||||
: !watchedScheduledDate?.trim()
|
||||
? "Select a shipment day and a train to review the price."
|
||||
: !watchedTrainId?.trim()
|
||||
? "Select a train for your shipment day to review the price."
|
||||
: "Fix the highlighted fields to review the price.";
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!pendingValues) return;
|
||||
@@ -838,7 +853,7 @@ function NewShipmentBookingForm({
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
<Text fz={13} fw={500} c="#C0392B">
|
||||
Fix the highlighted fields to review the price.
|
||||
{validationSummaryText}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
@@ -2358,7 +2373,7 @@ function ContainerLineEditor({
|
||||
render={({ field, fieldState }) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
placeholder="e.g. SL-0099231"
|
||||
placeholder="e.g. SL0123456"
|
||||
error={fieldState.error?.message}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
|
||||
@@ -61,8 +61,11 @@ const containerUnitSchema = z.object({
|
||||
(v) => ISO_CONTAINER_NUMBER_REGEX.test(v.trim().toUpperCase()),
|
||||
"Enter a valid ISO container number (e.g. ABCD1234567).",
|
||||
),
|
||||
// Every physical container is sealed before it ships; the yard checks the
|
||||
// seal against the booking, so it is required alongside number and VGM.
|
||||
sealNumber: z
|
||||
.string()
|
||||
.default("")
|
||||
.refine((v) => v.trim().length > 0, "Seal number is required."),
|
||||
vgmTons: z
|
||||
.string()
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createShipmentFormSchema } from "./schema";
|
||||
|
||||
const schema = createShipmentFormSchema({
|
||||
isContainer: true,
|
||||
isHazardous: false,
|
||||
isReefer: false,
|
||||
withReturnService: false,
|
||||
requiresDate: true,
|
||||
});
|
||||
|
||||
const unit = (overrides: Partial<{ sealNumber: string }> = {}) => ({
|
||||
containerNumber: "MSCU1234567",
|
||||
sealNumber: "SL0123456",
|
||||
vgmTons: "24.5",
|
||||
isHazardous: false,
|
||||
isReefer: false,
|
||||
isReturn: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const values = (sealNumber: string) => ({
|
||||
contractRouteId: "route-1",
|
||||
scheduledDate: "2026-09-10",
|
||||
paymentCurrency: "USD" as const,
|
||||
containers: [
|
||||
{
|
||||
containerSize: "40ft" as const,
|
||||
quantity: "1",
|
||||
units: [unit({ sealNumber })],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const sealIssue = (v: ReturnType<typeof values>) => {
|
||||
const result = schema.safeParse(v);
|
||||
return result.success
|
||||
? undefined
|
||||
: result.error.issues.find((i) => i.path.at(-1) === "sealNumber");
|
||||
};
|
||||
|
||||
describe("container unit seal number", () => {
|
||||
it("rejects a missing seal number", () => {
|
||||
expect(sealIssue(values(""))?.message).toBe("Seal number is required.");
|
||||
});
|
||||
|
||||
it("rejects a whitespace-only seal number", () => {
|
||||
expect(sealIssue(values(" "))?.message).toBe("Seal number is required.");
|
||||
});
|
||||
|
||||
it("accepts a filled seal number", () => {
|
||||
expect(sealIssue(values("SL0123456"))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -439,6 +439,123 @@ export interface IWagonMovement extends BaseEntity {
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
/** Which slice of a wagon's life an event belongs to — the history filter axis. */
|
||||
export enum WagonEventCategory {
|
||||
Lifecycle = "LIFECYCLE",
|
||||
Yard = "YARD",
|
||||
Train = "TRAIN",
|
||||
Schedule = "SCHEDULE",
|
||||
Status = "STATUS",
|
||||
Cargo = "CARGO",
|
||||
}
|
||||
|
||||
/**
|
||||
* Every recorded transition in a wagon's history (`freight.wagon_events`).
|
||||
* One row per wagon per transition, append-only, written inside the same
|
||||
* transaction as the change itself.
|
||||
*/
|
||||
export enum WagonEventType {
|
||||
// Lifecycle
|
||||
Registered = "REGISTERED",
|
||||
DetailsUpdated = "DETAILS_UPDATED",
|
||||
Deleted = "DELETED",
|
||||
Purged = "PURGED",
|
||||
// Yard (where the wagon physically is)
|
||||
MovedManually = "MOVED_MANUALLY",
|
||||
MovedWithTrain = "MOVED_WITH_TRAIN",
|
||||
PassedCheckpoint = "PASSED_CHECKPOINT",
|
||||
CutAtYard = "CUT_AT_YARD",
|
||||
SettledOnArrival = "SETTLED_ON_ARRIVAL",
|
||||
ReleasedAtUnload = "RELEASED_AT_UNLOAD",
|
||||
ReturnedOnCancel = "RETURNED_ON_CANCEL",
|
||||
// Built train / consist
|
||||
CoupledToTrain = "COUPLED_TO_TRAIN",
|
||||
UncoupledFromTrain = "UNCOUPLED_FROM_TRAIN",
|
||||
SequenceChanged = "SEQUENCE_CHANGED",
|
||||
TrainMerged = "TRAIN_MERGED",
|
||||
TrainDisbanded = "TRAIN_DISBANDED",
|
||||
// Schedule slot
|
||||
PinnedToSchedule = "PINNED_TO_SCHEDULE",
|
||||
UnpinnedFromSchedule = "UNPINNED_FROM_SCHEDULE",
|
||||
Dispatched = "DISPATCHED",
|
||||
ReleasedFromSchedule = "RELEASED_FROM_SCHEDULE",
|
||||
// Status
|
||||
StatusChanged = "STATUS_CHANGED",
|
||||
// Cargo
|
||||
CargoLoaded = "CARGO_LOADED",
|
||||
CargoUnloaded = "CARGO_UNLOADED",
|
||||
BookingUnassigned = "BOOKING_UNASSIGNED",
|
||||
BookingCancelled = "BOOKING_CANCELLED",
|
||||
LoadMovedIn = "LOAD_MOVED_IN",
|
||||
LoadMovedOut = "LOAD_MOVED_OUT",
|
||||
ContainerPlaced = "CONTAINER_PLACED",
|
||||
ContainerRemoved = "CONTAINER_REMOVED",
|
||||
}
|
||||
|
||||
export const WAGON_EVENT_CATEGORY: Record<WagonEventType, WagonEventCategory> = {
|
||||
[WagonEventType.Registered]: WagonEventCategory.Lifecycle,
|
||||
[WagonEventType.DetailsUpdated]: WagonEventCategory.Lifecycle,
|
||||
[WagonEventType.Deleted]: WagonEventCategory.Lifecycle,
|
||||
[WagonEventType.Purged]: WagonEventCategory.Lifecycle,
|
||||
[WagonEventType.MovedManually]: WagonEventCategory.Yard,
|
||||
[WagonEventType.MovedWithTrain]: WagonEventCategory.Yard,
|
||||
[WagonEventType.PassedCheckpoint]: WagonEventCategory.Yard,
|
||||
[WagonEventType.CutAtYard]: WagonEventCategory.Yard,
|
||||
[WagonEventType.SettledOnArrival]: WagonEventCategory.Yard,
|
||||
[WagonEventType.ReleasedAtUnload]: WagonEventCategory.Yard,
|
||||
[WagonEventType.ReturnedOnCancel]: WagonEventCategory.Yard,
|
||||
[WagonEventType.CoupledToTrain]: WagonEventCategory.Train,
|
||||
[WagonEventType.UncoupledFromTrain]: WagonEventCategory.Train,
|
||||
[WagonEventType.SequenceChanged]: WagonEventCategory.Train,
|
||||
[WagonEventType.TrainMerged]: WagonEventCategory.Train,
|
||||
[WagonEventType.TrainDisbanded]: WagonEventCategory.Train,
|
||||
[WagonEventType.PinnedToSchedule]: WagonEventCategory.Schedule,
|
||||
[WagonEventType.UnpinnedFromSchedule]: WagonEventCategory.Schedule,
|
||||
[WagonEventType.Dispatched]: WagonEventCategory.Schedule,
|
||||
[WagonEventType.ReleasedFromSchedule]: WagonEventCategory.Schedule,
|
||||
[WagonEventType.StatusChanged]: WagonEventCategory.Status,
|
||||
[WagonEventType.CargoLoaded]: WagonEventCategory.Cargo,
|
||||
[WagonEventType.CargoUnloaded]: WagonEventCategory.Cargo,
|
||||
[WagonEventType.BookingUnassigned]: WagonEventCategory.Cargo,
|
||||
[WagonEventType.BookingCancelled]: WagonEventCategory.Cargo,
|
||||
[WagonEventType.LoadMovedIn]: WagonEventCategory.Cargo,
|
||||
[WagonEventType.LoadMovedOut]: WagonEventCategory.Cargo,
|
||||
[WagonEventType.ContainerPlaced]: WagonEventCategory.Cargo,
|
||||
[WagonEventType.ContainerRemoved]: WagonEventCategory.Cargo,
|
||||
};
|
||||
|
||||
/** One row of a wagon's history as served by `GET /wagons/:id/history` (labels resolved). */
|
||||
export interface WagonHistoryEvent {
|
||||
id: string;
|
||||
wagonId: string;
|
||||
wagonNumber: string | null;
|
||||
type: WagonEventType;
|
||||
category: WagonEventCategory;
|
||||
occurredAt: string;
|
||||
actorUserId: string | null;
|
||||
actorName: string | null;
|
||||
fromYardId: string | null;
|
||||
fromYardLabel: string | null;
|
||||
toYardId: string | null;
|
||||
toYardLabel: string | null;
|
||||
trainId: string | null;
|
||||
trainCode: string | null;
|
||||
trainScheduleId: string | null;
|
||||
scheduleLabel: string | null;
|
||||
bookingId: string | null;
|
||||
bookingReference: string | null;
|
||||
fromValue: string | null;
|
||||
toValue: string | null;
|
||||
reason: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/** Keyset page of a wagon's history, newest first. `nextCursor` is null on the last page. */
|
||||
export interface WagonHistoryPage {
|
||||
items: WagonHistoryEvent[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle of a two-person wagon-transfer request. A requester asks for N
|
||||
* wagons of a type to move from one yard to another (count only, no specific
|
||||
|
||||
Reference in New Issue
Block a user