mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 10:10:57 +00:00
Merge branch 'dev' into freight/feat/fixes-v1
This commit is contained in:
@@ -21,7 +21,9 @@ function makeManager(savedLines: unknown[]) {
|
||||
}
|
||||
|
||||
function makeEvents() {
|
||||
return { emit: jest.fn() };
|
||||
// BillingService emits via both emit() and emitAsync() (the post-commit async
|
||||
// listener path) — the mock must provide both.
|
||||
return { emit: jest.fn(), emitAsync: jest.fn().mockResolvedValue([]) };
|
||||
}
|
||||
|
||||
function generateInput(overrides: Record<string, unknown> = {}) {
|
||||
@@ -162,7 +164,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
||||
],
|
||||
},
|
||||
);
|
||||
expect(events.emit).toHaveBeenCalledWith(
|
||||
expect(events.emitAsync).toHaveBeenCalledWith(
|
||||
"booking.invoice.paid",
|
||||
expect.objectContaining({
|
||||
invoiceId: "inv-1",
|
||||
@@ -197,6 +199,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
||||
|
||||
expect(mg.update).not.toHaveBeenCalled();
|
||||
expect(events.emit).not.toHaveBeenCalled();
|
||||
expect(events.emitAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -257,6 +260,7 @@ describe("BillingService.recordPayment", () => {
|
||||
}),
|
||||
);
|
||||
expect(events.emit).not.toHaveBeenCalled();
|
||||
expect(events.emitAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("settles to PAID, stamps paidAt, and emits ${source}.invoice.paid when the balance clears", async () => {
|
||||
@@ -268,7 +272,7 @@ describe("BillingService.recordPayment", () => {
|
||||
expect(updated.balanceAmount).toBe(0);
|
||||
expect(updated.paidAt).toBeInstanceOf(Date);
|
||||
expect(mg.update).toHaveBeenCalled();
|
||||
expect(events.emit).toHaveBeenCalledWith(
|
||||
expect(events.emitAsync).toHaveBeenCalledWith(
|
||||
"warehouse.invoice.paid",
|
||||
expect.objectContaining({ invoiceId: "inv-1", status: Freight.InvoiceStatus.Paid }),
|
||||
);
|
||||
@@ -296,3 +300,80 @@ describe("BillingService.recordPayment", () => {
|
||||
expect(mg.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Regression: `expirePayable` (batch settle path, called when a payment window
|
||||
* lapses) transitions the invoice to EXPIRED, which locks the row FOR UPDATE.
|
||||
* The bug passed `dataSource.manager` (the non-transactional default) into the
|
||||
* transition, so runTransition skipped opening a transaction and the lock threw
|
||||
* `An open transaction is required for pessimistic lock` — aborting the whole
|
||||
* settle pass (the "settle/reserve one booking at a time" symptom). The locked
|
||||
* write MUST run inside dataSource.transaction.
|
||||
*/
|
||||
describe("BillingService.expirePayable — locked write runs in a transaction", () => {
|
||||
const openInvoice = {
|
||||
id: "inv-1",
|
||||
status: Freight.InvoiceStatus.Pending,
|
||||
source: "booking",
|
||||
sourceId: "booking-1",
|
||||
};
|
||||
|
||||
const build = (lookupResult: Record<string, unknown> | null) => {
|
||||
const defaultManager = {
|
||||
findOne: jest.fn().mockResolvedValue(lookupResult),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const txManager = {
|
||||
findOne: jest.fn().mockResolvedValue(openInvoice),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const transaction = jest
|
||||
.fn()
|
||||
.mockImplementation((cb: (mg: unknown) => unknown) => cb(txManager));
|
||||
const events = makeEvents();
|
||||
const service = new BillingService(
|
||||
{ manager: defaultManager, transaction } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
events as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, defaultManager, txManager, transaction };
|
||||
};
|
||||
|
||||
it("opens a transaction and runs the pessimistic-lock read on the tx manager", async () => {
|
||||
const { service, transaction, txManager, defaultManager } = build(openInvoice);
|
||||
|
||||
await service.expirePayable(
|
||||
Freight.InvoiceSource.Booking,
|
||||
"booking-1",
|
||||
"prepaid",
|
||||
);
|
||||
|
||||
expect(transaction).toHaveBeenCalledTimes(1);
|
||||
expect(txManager.findOne).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ lock: { mode: "pessimistic_write" } }),
|
||||
);
|
||||
expect(txManager.update).toHaveBeenCalled();
|
||||
// The default manager only does the initial lock-free lookup, never a locked read.
|
||||
for (const call of defaultManager.findOne.mock.calls) {
|
||||
expect(call[1]).not.toHaveProperty("lock");
|
||||
}
|
||||
});
|
||||
|
||||
it("is a no-op (no transaction) when there is no open invoice", async () => {
|
||||
const { service, transaction } = build(null);
|
||||
|
||||
const result = await service.expirePayable(
|
||||
Freight.InvoiceSource.Booking,
|
||||
"booking-1",
|
||||
"prepaid",
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -825,6 +825,13 @@ export class BillingService {
|
||||
type?: string,
|
||||
manager?: EntityManager,
|
||||
): Promise<Invoice | null> {
|
||||
// Lookup can use the default manager (no lock). But the pessimistic-lock write
|
||||
// inside `transition` NEEDS an open transaction: pass the caller's `manager`
|
||||
// through untouched (undefined when there is no caller txn) so `runTransition`
|
||||
// opens its own. Passing `this.dataSource.manager` here made `runTransition`
|
||||
// treat it as an already-open transaction and skip wrapping — the lock then
|
||||
// threw `An open transaction is required for pessimistic lock`, aborting the
|
||||
// whole settle pass (the "reservations settle/reserve one at a time" symptom).
|
||||
const mg = manager ?? this.dataSource.manager;
|
||||
const invoice = await mg.findOne(Invoice, {
|
||||
where: {
|
||||
@@ -842,7 +849,7 @@ export class BillingService {
|
||||
Freight.InvoiceStatus.Expired,
|
||||
"expired",
|
||||
{},
|
||||
mg,
|
||||
manager,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,8 @@ import { AddCustomerTruckDto } from './dto/add-customer-truck.dto';
|
||||
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
|
||||
import { LoadCustomerTruckDto } from './dto/load-customer-truck.dto';
|
||||
import { CustomerTruckService } from './customer-truck.service';
|
||||
import { FirstMileService } from '../first-mile/first-mile.service';
|
||||
import { LastMileService } from '../last-mile/last-mile.service';
|
||||
import { GenerateGrnDto } from './dto/generate-grn.dto';
|
||||
import { ContainerReceiptService } from './container-receipt.service';
|
||||
import { SignContractDto } from './dto/sign-contract.dto';
|
||||
@@ -81,6 +83,60 @@ import {
|
||||
hasFreightPermission,
|
||||
} from "../../common/freight-permission.util";
|
||||
|
||||
interface MileVehicleSummary {
|
||||
plate: string | null;
|
||||
code: string | null;
|
||||
driverName: string | null;
|
||||
containerNumber: string | null;
|
||||
distanceKm: number | null;
|
||||
}
|
||||
|
||||
interface MileLegSummary {
|
||||
status: string;
|
||||
exactKm: number | null;
|
||||
remainingPayment: number | null;
|
||||
currency: string;
|
||||
invoiced: boolean;
|
||||
vehicles: MileVehicleSummary[];
|
||||
}
|
||||
|
||||
/** Trim a first/last-mile record down to a customer-safe operational summary. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function summarizeMileLeg(rec?: Record<string, any>): MileLegSummary | null {
|
||||
if (!rec) return null;
|
||||
const num = (v: unknown) => (v == null ? null : Number(v));
|
||||
const assignments: Array<Record<string, any>> = rec.vehicleAssignments ?? []; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
const currency =
|
||||
rec.vehicle?.currency ??
|
||||
assignments[0]?.vehicle?.currency ??
|
||||
rec.booking?.paymentCurrency ??
|
||||
'ETB';
|
||||
const vehicles: MileVehicleSummary[] = assignments.map((a) => ({
|
||||
plate: a.vehicle?.plateNumber ?? null,
|
||||
code: a.vehicle?.code ?? null,
|
||||
driverName: a.vehicle?.assignedDriverName ?? null,
|
||||
containerNumber: a.containerNumber ?? null,
|
||||
distanceKm: num(a.distanceKm),
|
||||
}));
|
||||
if (!vehicles.length && rec.vehicle) {
|
||||
vehicles.push({
|
||||
plate: rec.vehicle.plateNumber ?? null,
|
||||
code: rec.vehicle.code ?? null,
|
||||
driverName: rec.vehicle.assignedDriverName ?? null,
|
||||
containerNumber: null,
|
||||
distanceKm: num(rec.exactKm),
|
||||
});
|
||||
}
|
||||
return {
|
||||
status: rec.status ?? '',
|
||||
exactKm: num(rec.exactKm),
|
||||
remainingPayment: num(rec.remainingPayment),
|
||||
currency,
|
||||
invoiced: Boolean(rec.invoice),
|
||||
vehicles,
|
||||
};
|
||||
}
|
||||
|
||||
@ApiTags("bookings")
|
||||
@Controller("bookings")
|
||||
@ApiBearerAuth()
|
||||
@@ -94,6 +150,8 @@ export class BookingsController {
|
||||
private readonly bookingClearanceService: BookingClearanceService,
|
||||
private readonly customerTruckService: CustomerTruckService,
|
||||
private readonly containerReceiptService: ContainerReceiptService,
|
||||
private readonly firstMileService: FirstMileService,
|
||||
private readonly lastMileService: LastMileService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@@ -290,6 +348,33 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Get(':id/mile-summary')
|
||||
@ApiOperation({
|
||||
summary: 'First/last-mile operational summary for a booking (customer-safe)',
|
||||
})
|
||||
async mileSummary(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
// Customers may only see their own booking's mile summary.
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
if (
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView)
|
||||
) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
}
|
||||
|
||||
const [first, last] = await Promise.all([
|
||||
this.firstMileService.findAll({ bookingId: id, pageSize: 1 }),
|
||||
this.lastMileService.findAll({ bookingId: id, pageSize: 1 }),
|
||||
]);
|
||||
return {
|
||||
firstMile: summarizeMileLeg(first.data[0]),
|
||||
lastMile: summarizeMileLeg(last.data[0]),
|
||||
};
|
||||
}
|
||||
|
||||
@Post(':id/customer-truck-assignment')
|
||||
@ApiOperation({ summary: 'Customer assigns external truck and driver for terminal pickup' })
|
||||
async assignCustomerTruck(
|
||||
|
||||
@@ -12,6 +12,7 @@ import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-se
|
||||
import { SignaturesModule } from '../signatures/signatures.module';
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
import { FirstMileModule } from '../first-mile/first-mile.module';
|
||||
import { LastMileModule } from '../last-mile/last-mile.module';
|
||||
import { BookingContractService } from './booking-contract.service';
|
||||
import { BookingInvoiceService } from './booking-invoice.service';
|
||||
// import { BookingPaymentController } from './booking-payment.controller';
|
||||
@@ -70,6 +71,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
NotificationsModule,
|
||||
NotificationInboxModule,
|
||||
forwardRef(() => FirstMileModule),
|
||||
forwardRef(() => LastMileModule),
|
||||
forwardRef(() => TrainSchedulingModule),
|
||||
forwardRef(() => ContractsModule),
|
||||
forwardRef(() => ContractsModule),
|
||||
|
||||
@@ -546,6 +546,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
if (!statuses.length) return [];
|
||||
return this.repository.find({
|
||||
where: { status: In(statuses) },
|
||||
relations: {
|
||||
company: true,
|
||||
originYard: true,
|
||||
destinationYard: true,
|
||||
},
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
@@ -993,6 +998,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
||||
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
|
||||
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
|
||||
.andWhere('sb.id IS NULL')
|
||||
@@ -1023,6 +1029,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
||||
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
|
||||
.where('booking.origin_yard_id = :originYardId', { originYardId })
|
||||
.andWhere('booking.destination_yard_id = :destinationYardId', {
|
||||
@@ -1061,6 +1068,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
||||
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
|
||||
.where('booking.origin_yard_id IN (:...corridorYardIds)', { corridorYardIds })
|
||||
.andWhere('booking.destination_yard_id IN (:...corridorYardIds)', {
|
||||
@@ -1125,6 +1133,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
||||
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
|
||||
.orderBy('booking.is_government', 'DESC')
|
||||
.addOrderBy('booking.priority_score', 'DESC')
|
||||
@@ -1138,6 +1147,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
||||
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
|
||||
.andWhere(`booking.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`)
|
||||
.getMany();
|
||||
|
||||
@@ -1424,6 +1424,20 @@ export class BookingsService {
|
||||
schedule?.status ?? null;
|
||||
}
|
||||
|
||||
// A generated-but-unsigned SELF_HAUL handover means the customer must approve
|
||||
// delivery from the portal (booking-based, one per booking). EDR last-mile
|
||||
// handovers are per delivering truck and signed by the receiver at the door,
|
||||
// so they never surface the portal "Approve delivery" action.
|
||||
const [pendingHandover] = await this.dataSource.query(
|
||||
`SELECT 1 FROM freight.booking_handovers
|
||||
WHERE booking_id = $1 AND signed_at IS NULL AND deleted_at IS NULL
|
||||
AND mile_type = 'SELF_HAUL'
|
||||
LIMIT 1`,
|
||||
[id],
|
||||
);
|
||||
(booking as Booking & { handoverAwaitingSignature?: boolean }).handoverAwaitingSignature =
|
||||
Boolean(pendingHandover);
|
||||
|
||||
return booking;
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,15 @@ export class CustomerTruckService {
|
||||
|
||||
if (requested.length) {
|
||||
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
|
||||
// Never assign more trucks than the booking has containers.
|
||||
const existingTrucks = await this.dataSource
|
||||
.getRepository(CustomerTruckAssignment)
|
||||
.count({ where: { bookingId } });
|
||||
if (existingTrucks + 1 > bookingNumbers.length) {
|
||||
throw new BadRequestException(
|
||||
`Cannot assign more trucks than containers — this booking has ${bookingNumbers.length} container(s) and ${existingTrucks} truck(s) already assigned.`,
|
||||
);
|
||||
}
|
||||
for (const n of requested) {
|
||||
if (!bookingNumbers.includes(n)) {
|
||||
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
|
||||
@@ -303,6 +312,13 @@ export class CustomerTruckService {
|
||||
if (assignment.departedAt) {
|
||||
throw new ConflictException('This truck has already left — its load is locked');
|
||||
}
|
||||
// Containers can only be loaded after the truck has physically arrived at the
|
||||
// warehouse (arrival weighing recorded). Assignment alone is just planning.
|
||||
if (!assignment.arrivedAt) {
|
||||
throw new BadRequestException(
|
||||
'Record the truck arrival before loading — containers can only be loaded onto an arrived truck',
|
||||
);
|
||||
}
|
||||
|
||||
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
|
||||
if (!requested.length) {
|
||||
@@ -324,12 +340,16 @@ export class CustomerTruckService {
|
||||
const grossTons = await this.vgmTonsForContainers(bookingId, requested);
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
|
||||
// Operator loading the truck: stamp loaded_at so these containers move to
|
||||
// the LOADED stage (customer assignment alone leaves loaded_at null).
|
||||
const loadedAt = new Date();
|
||||
await manager.getRepository(CustomerTruckContainer).save(
|
||||
requested.map((containerNumber) =>
|
||||
manager.getRepository(CustomerTruckContainer).create({
|
||||
assignmentId,
|
||||
bookingId,
|
||||
containerNumber,
|
||||
loadedAt,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -23,4 +23,12 @@ export class CustomerTruckContainer extends BaseEntity {
|
||||
|
||||
@Column({ name: 'container_number', type: 'varchar', length: 64 })
|
||||
containerNumber!: string;
|
||||
|
||||
/**
|
||||
* When the container was actually loaded onto the truck by the operator.
|
||||
* Null = customer-assigned (planned) but not yet loaded. Stage LOADED requires
|
||||
* this to be set, so customer assignment alone does not mark a container loaded.
|
||||
*/
|
||||
@Column({ name: 'loaded_at', type: 'timestamptz', nullable: true })
|
||||
loadedAt?: Date | null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import type { DataSource } from 'typeorm';
|
||||
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import type { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
||||
|
||||
type Status = 'PENDING' | 'COMPLETED' | 'SKIPPED';
|
||||
|
||||
/**
|
||||
* Risk assignment is gated on the T1 being closed (catalog order
|
||||
* T1_CLOSED → RISK_ASSIGNED): customs cannot rate cargo still under transit.
|
||||
*/
|
||||
function makeService(t1Status: Status | 'MISSING') {
|
||||
const rows = new Map<string, ClearanceMilestone>();
|
||||
if (t1Status !== 'MISSING') {
|
||||
rows.set('T1_CLOSED', { milestoneCode: 'T1_CLOSED', status: t1Status } as ClearanceMilestone);
|
||||
}
|
||||
const risk = { milestoneCode: 'RISK_ASSIGNED', status: 'PENDING' } as ClearanceMilestone;
|
||||
rows.set('RISK_ASSIGNED', risk);
|
||||
|
||||
const repo = {
|
||||
findOne: jest.fn(({ where }: { where: { milestoneCode: string } }) =>
|
||||
Promise.resolve(rows.get(where.milestoneCode) ?? null),
|
||||
),
|
||||
save: jest.fn((m: ClearanceMilestone) => Promise.resolve(m)),
|
||||
};
|
||||
const dataSource = { getRepository: () => repo } as unknown as DataSource;
|
||||
return { service: new ClearanceMilestoneService(dataSource), repo, risk };
|
||||
}
|
||||
|
||||
describe('ClearanceMilestoneService.assignRisk', () => {
|
||||
it('rejects the assignment while the T1 is still open', async () => {
|
||||
const { service, repo } = makeService('PENDING');
|
||||
|
||||
await expect(service.assignRisk('b-1', 'GREEN')).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(repo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects the assignment when the booking has no T1_CLOSED milestone', async () => {
|
||||
const { service, repo } = makeService('MISSING');
|
||||
|
||||
await expect(service.assignRisk('b-1', 'GREEN')).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(repo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('assigns the risk level once the T1 is closed', async () => {
|
||||
const { service, risk } = makeService('COMPLETED');
|
||||
|
||||
const saved = await service.assignRisk('b-1', 'RED', 'user-1');
|
||||
|
||||
expect(saved.status).toBe('COMPLETED');
|
||||
expect(saved.metadata?.riskLevel).toBe('RED');
|
||||
expect(risk.triggeredByUserId).toBe('user-1');
|
||||
});
|
||||
|
||||
it('assigns the risk level when the T1 step was skipped', async () => {
|
||||
const { service } = makeService('SKIPPED');
|
||||
|
||||
const saved = await service.assignRisk('b-1', 'YELLOW');
|
||||
|
||||
expect(saved.status).toBe('COMPLETED');
|
||||
expect(saved.metadata?.riskLevel).toBe('YELLOW');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import {
|
||||
@@ -181,6 +181,10 @@ export class ClearanceMilestoneService {
|
||||
* Assign a customs risk level (GREEN/YELLOW/RED) and complete the RISK_ASSIGNED
|
||||
* milestone on a booking (GL Import US-04 / §11.3 #19). Stores the level in the
|
||||
* milestone metadata so the timeline shows it.
|
||||
*
|
||||
* Customs cannot risk-rate cargo still moving under transit: the T1 must be
|
||||
* closed (accepted by GL Ethiopia after the train arrives) first, which is the
|
||||
* catalog order T1_CLOSED → RISK_ASSIGNED.
|
||||
*/
|
||||
async assignRisk(
|
||||
bookingId: string,
|
||||
@@ -188,9 +192,22 @@ export class ClearanceMilestoneService {
|
||||
userId?: string,
|
||||
note?: string,
|
||||
): Promise<ClearanceMilestone> {
|
||||
await this.assertT1Closed(bookingId);
|
||||
return this.completeWithMetadata(bookingId, 'RISK_ASSIGNED', { riskLevel }, userId, note);
|
||||
}
|
||||
|
||||
/** Guard: the booking's T1 must be closed before customs risk can be assigned. */
|
||||
private async assertT1Closed(bookingId: string): Promise<void> {
|
||||
const t1 = await this.repo.findOne({
|
||||
where: { bookingId, milestoneCode: 'T1_CLOSED' },
|
||||
});
|
||||
if (t1?.status !== 'COMPLETED' && t1?.status !== 'SKIPPED') {
|
||||
throw new BadRequestException(
|
||||
'The T1 must be closed before a customs risk level can be assigned.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Advise duty & tax (amount + declaration serial) and complete the
|
||||
* DUTY_TAXES_ADVISED milestone (§11.3 #6). The customer then uploads the
|
||||
|
||||
@@ -122,6 +122,15 @@ export class ContractBookingService {
|
||||
const generalCustoms =
|
||||
contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);
|
||||
|
||||
// GENERAL without customs (Path A) ALSO clears per booking: the customer
|
||||
// uploads his own clearance proof on each booking and Operations reviews it
|
||||
// (legacy AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY →
|
||||
// requestOperation machine). DOMESTIC has no border, so no gate.
|
||||
const generalSelfClear =
|
||||
contract.contractKind === 'GENERAL' &&
|
||||
!contract.customsClearingEnabled &&
|
||||
contract.tradeDirection !== 'DOMESTIC';
|
||||
|
||||
// Intercity (DOMESTIC) bookings ride on a passing import/export train:
|
||||
// there is no window and no date — staff accept them onto a train at
|
||||
// finalize time, so both the window gate and scheduledDate are skipped.
|
||||
@@ -140,9 +149,10 @@ export class ContractBookingService {
|
||||
// Booking-window gate (config-driven): an operations booking may only be
|
||||
// created while the route's booking window is open — import: the day's window
|
||||
// (windowOpenHour EAT, importWindowLeadDays before departure, windowDurationHours);
|
||||
// export: within exportBookingLeadHours of departure. Customs Path B bookings
|
||||
// enter clearance first and are scheduled later, so they are not gated here.
|
||||
if (!generalCustoms && !isIntercity) {
|
||||
// export: within exportBookingLeadHours of departure. Bookings that enter the
|
||||
// clearance gate first (Path B customs AND Path A per-booking self-clearance)
|
||||
// are scheduled later, so they are not gated here.
|
||||
if (!generalCustoms && !generalSelfClear && !isIntercity) {
|
||||
await this.trainSchedulingService.assertBookingWindowOpen({
|
||||
originYardId: route?.originYardId ?? null,
|
||||
destinationYardId: route?.destinationYardId ?? null,
|
||||
@@ -174,7 +184,10 @@ export class ContractBookingService {
|
||||
companyProfileId: contract.companyProfileId ?? null,
|
||||
isGovernment: contract.isGovernment,
|
||||
governmentInstitution: contract.governmentInstitution ?? null,
|
||||
status: generalCustoms ? 'AWAITING_DOCUMENTS' : 'OPERATION_REQUEST_PENDING',
|
||||
status:
|
||||
generalCustoms || generalSelfClear
|
||||
? 'AWAITING_DOCUMENTS'
|
||||
: 'OPERATION_REQUEST_PENDING',
|
||||
bookingType: 'ONE_TIME',
|
||||
contractId: contract.id,
|
||||
contractRouteId: route?.id ?? null,
|
||||
@@ -259,9 +272,10 @@ export class ContractBookingService {
|
||||
const withContainers = await this.bookingsRepository.findByIdWithFiles(
|
||||
booking.id,
|
||||
);
|
||||
const intendedStatus = generalCustoms
|
||||
? 'AWAITING_DOCUMENTS'
|
||||
: 'OPERATION_REQUEST_PENDING';
|
||||
const intendedStatus =
|
||||
generalCustoms || generalSelfClear
|
||||
? 'AWAITING_DOCUMENTS'
|
||||
: 'OPERATION_REQUEST_PENDING';
|
||||
if (
|
||||
withContainers &&
|
||||
freightType === 'CONTAINER' &&
|
||||
|
||||
@@ -632,16 +632,15 @@ export class ContractTransitionService {
|
||||
contract.customsClearingEnabled ?? false,
|
||||
);
|
||||
|
||||
// GENERAL + customs (Path B) runs clearance PER BOOKING, not at the contract
|
||||
// level: there is no contract clearance cycle. The contract just becomes
|
||||
// active; the customer then files shipment requests and GL books + clears
|
||||
// each one. ONE_TIME customs and Path A self-clearance keep the contract
|
||||
// cycle below.
|
||||
const isGeneralCustoms =
|
||||
contract.contractKind === 'GENERAL' &&
|
||||
Boolean(contract.customsClearingEnabled);
|
||||
// GENERAL contracts run clearance PER BOOKING, not at the contract level —
|
||||
// both paths. Customs (Path B): the customer files shipment requests, GL
|
||||
// books each one and the booking carries its own clearance. Self-clearance
|
||||
// (Path A): the customer books, then uploads the clearance docs on that
|
||||
// booking for Operations to review. Only ONE_TIME contracts keep the
|
||||
// contract-level cycle below.
|
||||
const isGeneral = contract.contractKind === 'GENERAL';
|
||||
|
||||
if (clearanceCode && !isGeneralCustoms) {
|
||||
if (clearanceCode && !isGeneral) {
|
||||
// Open a clearance cycle, seed the pre-booking milestones, and route the
|
||||
// customer to upload. Path A is ops-reviewed; Path B is GL-reviewed — the
|
||||
// distinction is enforced at the review/finalize endpoints, not here.
|
||||
@@ -652,8 +651,8 @@ export class ContractTransitionService {
|
||||
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
|
||||
updates.clearanceCycleNumber = cycleNumber;
|
||||
} else {
|
||||
// No contract-level clearance gate — DOMESTIC, or GENERAL+customs (which
|
||||
// clears per booking). Ready for shipment requests / direct booking.
|
||||
// No contract-level clearance gate — DOMESTIC, or any GENERAL contract
|
||||
// (which clears per booking). Ready for shipment requests / direct booking.
|
||||
updates.status =
|
||||
contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED';
|
||||
updates.clearanceStatus = 'NOT_APPLICABLE';
|
||||
|
||||
@@ -9,14 +9,19 @@ import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Matches,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
/** One physical container under a booking line — entered at booking time. */
|
||||
export class CreateContainerUnitDto {
|
||||
@ApiProperty()
|
||||
@ApiProperty({ description: 'ISO 6346 container number, e.g. ABCD1234567' })
|
||||
@IsString()
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim().toUpperCase() : value))
|
||||
@Matches(/^[A-Z]{4}\d{7}$/, {
|
||||
message: 'containerNumber must match ISO container format, e.g. ABCD1234567',
|
||||
})
|
||||
containerNumber!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
|
||||
@@ -11,14 +11,15 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { GpsTrackingService } from './gps-tracking.service';
|
||||
import { RegisterDeviceDto, UpdateDeviceDto } from './dto/gps-device.dto';
|
||||
|
||||
@ApiTags('gps-tracking')
|
||||
@ApiBearerAuth()
|
||||
@Controller('gps')
|
||||
@FleetView()
|
||||
@BookingStaff(FREIGHT_PERMS.tracking.view)
|
||||
export class GpsTrackingController {
|
||||
constructor(private readonly gps: GpsTrackingService) {}
|
||||
|
||||
@@ -44,21 +45,21 @@ export class GpsTrackingController {
|
||||
}
|
||||
|
||||
@Post('devices')
|
||||
@FleetManage()
|
||||
@BookingStaff(FREIGHT_PERMS.tracking.manage)
|
||||
@ApiOperation({ summary: 'Register a GPS tracker' })
|
||||
register(@Body() dto: RegisterDeviceDto) {
|
||||
return this.gps.registerDevice(dto);
|
||||
}
|
||||
|
||||
@Patch('devices/:id')
|
||||
@FleetManage()
|
||||
@BookingStaff(FREIGHT_PERMS.tracking.manage)
|
||||
@ApiOperation({ summary: 'Update a GPS tracker (name / assigned vehicle)' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateDeviceDto) {
|
||||
return this.gps.updateDevice(id, dto);
|
||||
}
|
||||
|
||||
@Delete('devices/:id')
|
||||
@FleetManage()
|
||||
@BookingStaff(FREIGHT_PERMS.tracking.manage)
|
||||
@ApiOperation({ summary: 'Delete a GPS tracker' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.gps.removeDevice(id);
|
||||
|
||||
@@ -49,6 +49,23 @@ export class CreateLocomotiveDto {
|
||||
@Min(0)
|
||||
maxTrainLengthMeters!: number;
|
||||
|
||||
// Allowed deviation above maxPullWeightTons before scheduling blocks the train
|
||||
// (e.g. 90 lets a 3,500T-rated locomotive pull up to 3,590T). Omit/0 = strict cap.
|
||||
@ApiPropertyOptional({ example: 90 })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
overageToleranceTons?: number;
|
||||
|
||||
// Allowed deviation above maxTrainLengthMeters before scheduling blocks the train.
|
||||
@ApiPropertyOptional({ example: 0 })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
overageToleranceMeters?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 4200 })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
|
||||
|
||||
@@ -39,6 +39,26 @@ export class Locomotive extends BaseEntity {
|
||||
@Column({ name: 'max_train_length_meters', type: 'numeric', precision: 10, scale: 3, default: 760 })
|
||||
maxTrainLengthMeters!: number;
|
||||
|
||||
/** Allowed deviation above maxPullWeightTons before a train is blocked (e.g. the 37th PW2 wagon in the fertilizer example runs 90T over 3,500T and is still accepted). Null/0 = no tolerance. */
|
||||
@Column({
|
||||
name: 'overage_tolerance_tons',
|
||||
type: 'numeric',
|
||||
precision: 10,
|
||||
scale: 3,
|
||||
nullable: true,
|
||||
})
|
||||
overageToleranceTons?: number | null;
|
||||
|
||||
/** Allowed deviation above maxTrainLengthMeters before a train is blocked. Null/0 = no tolerance. */
|
||||
@Column({
|
||||
name: 'overage_tolerance_meters',
|
||||
type: 'numeric',
|
||||
precision: 10,
|
||||
scale: 3,
|
||||
nullable: true,
|
||||
})
|
||||
overageToleranceMeters?: number | null;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 20, default: 'AVAILABLE' })
|
||||
status!: LocomotiveStatus;
|
||||
|
||||
|
||||
@@ -64,6 +64,8 @@ export class LocomotivesService {
|
||||
maxPullWeightTons:
|
||||
dto.maxPullWeightTons ?? LocomotivesService.DEFAULT_MAX_PULL_WEIGHT_TONS,
|
||||
maxTrainLengthMeters: dto.maxTrainLengthMeters,
|
||||
overageToleranceTons: dto.overageToleranceTons ?? null,
|
||||
overageToleranceMeters: dto.overageToleranceMeters ?? null,
|
||||
powerKw: dto.powerKw ?? null,
|
||||
tractionForceKn: dto.tractionForceKn ?? null,
|
||||
maxSpeedKmh: dto.maxSpeedKmh ?? null,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { NotificationsService } from './notifications.service';
|
||||
|
||||
/**
|
||||
* Best-effort SMS + email fan-out to a company's contacts. Looks up the
|
||||
* company's phone/email and sends the message over both channels, swallowing
|
||||
* per-channel failures so a missing provider never breaks the caller's flow.
|
||||
*/
|
||||
export async function sendCompanyChannels(
|
||||
dataSource: DataSource,
|
||||
notifications: NotificationsService,
|
||||
companyId: string,
|
||||
message: string,
|
||||
): Promise<void> {
|
||||
const [contact]: Array<{ phone: string | null; email: string | null }> =
|
||||
await dataSource.query(
|
||||
`SELECT COALESCE(phone, etrade_phone) AS phone, email
|
||||
FROM freight.companies
|
||||
WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[companyId],
|
||||
);
|
||||
if (contact?.phone) {
|
||||
try {
|
||||
await notifications.directSend('sms', contact.phone, message);
|
||||
} catch {
|
||||
/* best-effort: SMS provider unavailable */
|
||||
}
|
||||
}
|
||||
if (contact?.email) {
|
||||
try {
|
||||
await notifications.directSend('email', contact.email, message);
|
||||
} catch {
|
||||
/* best-effort: email provider unavailable */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,12 +39,17 @@ export class Route extends BaseEntity {
|
||||
milestones?: RouteMilestone[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable route label: yard names, not yard codes — "Addis Ababa → Dire Dawa",
|
||||
* not "ADDIS_ABABA → DIRE_DAWA". A yard's display name is its `label`; `code` is the
|
||||
* machine identifier and is only a fallback for a yard missing one.
|
||||
*/
|
||||
export function formatRouteLabel(route: {
|
||||
originYard?: { code?: string; name?: string } | null;
|
||||
destinationYard?: { code?: string; name?: string } | null;
|
||||
originYard?: { code?: string; label?: string } | null;
|
||||
destinationYard?: { code?: string; label?: string } | null;
|
||||
}): string {
|
||||
const origin = route.originYard?.code ?? route.originYard?.name ?? 'Origin';
|
||||
const dest = route.destinationYard?.code ?? route.destinationYard?.name ?? 'Destination';
|
||||
const origin = route.originYard?.label ?? route.originYard?.code ?? 'Origin';
|
||||
const dest = route.destinationYard?.label ?? route.destinationYard?.code ?? 'Destination';
|
||||
return `${origin} → ${dest}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,9 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
|
||||
return this.repo(manager).findOne({
|
||||
where: { id },
|
||||
relations: {
|
||||
route: true,
|
||||
// Yards carry the route's display name; without them formatRouteLabel
|
||||
// degrades to the literal "Origin → Destination".
|
||||
route: { originYard: true, destinationYard: true },
|
||||
trainSet: {
|
||||
locomotive: true,
|
||||
locomotives: { locomotive: true },
|
||||
|
||||
@@ -24,3 +24,13 @@ export const DEFAULT_CONTAINER_WAGON_LENGTH_METERS = 14;
|
||||
|
||||
/** Default CW3 covered wagon length for bulk bookings (m). */
|
||||
export const DEFAULT_BULK_WAGON_LENGTH_METERS = 14;
|
||||
|
||||
/**
|
||||
* Fallback tare weights (T) matching the length fallbacks above. The locomotive
|
||||
* pull limit is a GROSS limit, so a booking's weight budget must include the
|
||||
* empty weight of every wagon it occupies — not just its cargo.
|
||||
*/
|
||||
export const DEFAULT_CONTAINER_WAGON_TARE_TONS = 22.4;
|
||||
|
||||
/** Default CW3 gondola tare for bulk bookings (T). */
|
||||
export const DEFAULT_BULK_WAGON_TARE_TONS = 23.4;
|
||||
|
||||
@@ -31,6 +31,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
createMany: jest.Mock;
|
||||
};
|
||||
let trainSchedulesRepository: {
|
||||
findById: jest.Mock;
|
||||
findByIdWithFullGraph: jest.Mock;
|
||||
findAll: jest.Mock;
|
||||
};
|
||||
@@ -65,6 +66,11 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
createMany: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
trainSchedulesRepository = {
|
||||
findById: jest.fn().mockResolvedValue({
|
||||
id: scheduleId,
|
||||
bookingWindowStatus: 'OPEN',
|
||||
windowPhase: null,
|
||||
}),
|
||||
findByIdWithFullGraph: jest.fn().mockResolvedValue({
|
||||
id: scheduleId,
|
||||
maxWagons: 10,
|
||||
@@ -163,7 +169,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
});
|
||||
|
||||
it('processSchedule reconciles PAID-unlinked before wagon allocation', async () => {
|
||||
const fillSpy = jest.spyOn(service, 'fillSchedule').mockResolvedValue(undefined);
|
||||
const fillSpy = jest.spyOn(service, 'fillSchedule').mockResolvedValue(0);
|
||||
const settleSpy = jest.spyOn(service, 'settleDueReservations').mockResolvedValue(undefined);
|
||||
const reconcileSpy = jest.spyOn(service, 'reconcilePaidUnlinked').mockResolvedValue(undefined);
|
||||
|
||||
@@ -181,6 +187,77 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
expect(reconcileOrder).toBeLessThan(wagonOrder);
|
||||
});
|
||||
|
||||
describe('extendPaymentPhaseForTopUp', () => {
|
||||
const schedRepo = () => dataSource.getRepository();
|
||||
|
||||
it('pushes paymentPhaseEndsAt out when a fresh window exceeds it', async () => {
|
||||
const soon = new Date(Date.now() + 5_000); // phase almost over
|
||||
const departure = new Date(Date.now() + 24 * 3_600_000);
|
||||
schedRepo().findOne.mockResolvedValueOnce({
|
||||
id: scheduleId,
|
||||
windowPhase: 'PAYMENT',
|
||||
paymentPhaseEndsAt: soon,
|
||||
scheduledDepartureDate: departure,
|
||||
});
|
||||
|
||||
await service.extendPaymentPhaseForTopUp(scheduleId);
|
||||
|
||||
// paymentWindowMinutes = 60 (mock) → new end ≈ now + 1h, which is > soon.
|
||||
expect(schedRepo().update).toHaveBeenCalledWith(
|
||||
scheduleId,
|
||||
expect.objectContaining({ paymentPhaseEndsAt: expect.any(Date) }),
|
||||
);
|
||||
const [, patch] = schedRepo().update.mock.calls.at(-1)!;
|
||||
expect((patch.paymentPhaseEndsAt as Date).getTime()).toBeGreaterThan(
|
||||
soon.getTime(),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not pull the deadline in when the current end is already later', async () => {
|
||||
const far = new Date(Date.now() + 10 * 3_600_000); // 10h out, beyond a 1h window
|
||||
schedRepo().findOne.mockResolvedValueOnce({
|
||||
id: scheduleId,
|
||||
windowPhase: 'PAYMENT',
|
||||
paymentPhaseEndsAt: far,
|
||||
scheduledDepartureDate: new Date(Date.now() + 24 * 3_600_000),
|
||||
});
|
||||
|
||||
await service.extendPaymentPhaseForTopUp(scheduleId);
|
||||
|
||||
expect(schedRepo().update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is a no-op outside the PAYMENT phase', async () => {
|
||||
schedRepo().findOne.mockResolvedValueOnce({
|
||||
id: scheduleId,
|
||||
windowPhase: 'OPEN',
|
||||
paymentPhaseEndsAt: null,
|
||||
scheduledDepartureDate: new Date(Date.now() + 24 * 3_600_000),
|
||||
});
|
||||
|
||||
await service.extendPaymentPhaseForTopUp(scheduleId);
|
||||
|
||||
expect(schedRepo().update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never extends past departure', async () => {
|
||||
const departure = new Date(Date.now() + 60_000); // 1 min away
|
||||
schedRepo().findOne.mockResolvedValueOnce({
|
||||
id: scheduleId,
|
||||
windowPhase: 'PAYMENT',
|
||||
paymentPhaseEndsAt: new Date(Date.now() + 1_000),
|
||||
scheduledDepartureDate: departure,
|
||||
});
|
||||
|
||||
await service.extendPaymentPhaseForTopUp(scheduleId);
|
||||
|
||||
const [, patch] = schedRepo().update.mock.calls.at(-1)!;
|
||||
expect((patch.paymentPhaseEndsAt as Date).getTime()).toBeLessThanOrEqual(
|
||||
departure.getTime(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fillRouteDay — day-level distribution', () => {
|
||||
const originYardId = 'yard-origin';
|
||||
const destinationYardId = 'yard-dest';
|
||||
@@ -336,6 +413,49 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
// Never reserved — waits for its partner in a later cycle.
|
||||
expect(notifier.payNow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears a stale FULL flag and fills a train whose bookings all expired', async () => {
|
||||
// The deadlock: train A filled once, every booking then expired, but
|
||||
// bookingWindowStatus stayed FULL. isFillable() rejects FULL before it ever
|
||||
// reads the budget, so the batch skipped the train forever — it just cycled
|
||||
// PRE_WINDOW→DOC_REVIEW→PAYMENT with an empty consist, and only the odd
|
||||
// already-pinned booking got settled, one per cycle.
|
||||
const staleFull = {
|
||||
id: trainA,
|
||||
maxWagons: 1,
|
||||
bookingWindowStatus: 'FULL',
|
||||
// The batch runs while the customer window is closed.
|
||||
windowPhase: 'PAYMENT',
|
||||
direction: 'IMPORT',
|
||||
trainSetId: `set-${trainA}`,
|
||||
trainSet: { locomotive: smallLoco },
|
||||
scheduleBookings: [],
|
||||
scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'),
|
||||
originStationId: originYardId,
|
||||
destinationStationId: destinationYardId,
|
||||
};
|
||||
trainSchedulesRepository.findAll.mockResolvedValue([{ ...staleFull }]);
|
||||
// Live capacity says the train is empty: 1 free wagon, nothing allocated.
|
||||
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(staleFull);
|
||||
// refreshWindowStatus writes CLOSED (mid-PAYMENT, not a customer-open phase);
|
||||
// the re-read reports it, and isFillable() admits CLOSED during PAYMENT.
|
||||
trainSchedulesRepository.findById.mockResolvedValue({
|
||||
id: trainA,
|
||||
bookingWindowStatus: 'CLOSED',
|
||||
windowPhase: 'PAYMENT',
|
||||
});
|
||||
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([
|
||||
commercial('waiting', 30),
|
||||
]);
|
||||
|
||||
const touched = await service.fillRouteDay(originYardId, destinationYardId, day);
|
||||
|
||||
// The train was reopened to the batch and actually filled, not skipped.
|
||||
expect(touched).toEqual([trainA]);
|
||||
expect(notifier.payNow).toHaveBeenCalledTimes(1);
|
||||
expect((notifier.payNow.mock.calls[0][0] as Booking).id).toBe('waiting');
|
||||
expect(notifier.unplaced).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('expireUnacceptedForRouteDay — doc-review sweep', () => {
|
||||
@@ -510,4 +630,104 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
expect(check({ ...importGeneral, contractKind: null } as Booking, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('settleDueReservations — expire then promote the waiting list', () => {
|
||||
const originYardId = 'yard-origin';
|
||||
const destinationYardId = 'yard-dest';
|
||||
const trainId = 'train-a';
|
||||
// 14m / 70t default wagon → two wagon slots on this locomotive.
|
||||
const smallLoco = { maxPullWeightTons: 200, maxTrainLengthMeters: 28 };
|
||||
|
||||
const booking = (id: string, priority: number, overrides = {}): Booking =>
|
||||
({
|
||||
id,
|
||||
reference: id,
|
||||
isGovernment: false,
|
||||
priorityScore: priority,
|
||||
status: 'FULLY_EXECUTED',
|
||||
wagonsRequired: 1,
|
||||
cargoTotalWeightVgm: 10,
|
||||
freightType: 'CONTAINER',
|
||||
bookingContainers: [],
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
trainScheduleId: trainId,
|
||||
...overrides,
|
||||
}) as unknown as Booking;
|
||||
|
||||
beforeEach(() => {
|
||||
const scheduleRow = {
|
||||
id: trainId,
|
||||
maxWagons: 2,
|
||||
bookingWindowStatus: 'CLOSED',
|
||||
windowPhase: 'PAYMENT',
|
||||
direction: 'IMPORT',
|
||||
trainSetId: `set-${trainId}`,
|
||||
trainSet: { locomotive: smallLoco },
|
||||
scheduleBookings: [],
|
||||
scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'),
|
||||
originStationId: originYardId,
|
||||
destinationStationId: destinationYardId,
|
||||
};
|
||||
trainSchedulesRepository.findAll.mockResolvedValue([{ ...scheduleRow }]);
|
||||
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(scheduleRow);
|
||||
trainSchedulesRepository.findById.mockResolvedValue({
|
||||
id: trainId,
|
||||
bookingWindowStatus: 'CLOSED',
|
||||
windowPhase: 'PAYMENT',
|
||||
scheduledDepartureDate: scheduleRow.scheduledDepartureDate,
|
||||
originStationId: originYardId,
|
||||
destinationStationId: destinationYardId,
|
||||
});
|
||||
});
|
||||
|
||||
it('promotes a waiting booking into the wagons an expired reservation frees', async () => {
|
||||
// One reservation whose pay window lapsed, and one booking on the waiting list.
|
||||
const lapsed = booking('lapsed', 50, {
|
||||
status: 'SELECTED_FOR_BATCH',
|
||||
paymentDeadline: new Date(Date.now() - 60_000),
|
||||
});
|
||||
const waiting = booking('waiting', 10, { trainScheduleId: null });
|
||||
|
||||
bookingsRepository.findReservedForSchedule
|
||||
.mockResolvedValueOnce([lapsed]) // settleReserved sees the lapsed one
|
||||
.mockResolvedValue([]); // afterwards nothing is reserved
|
||||
// The day pool the top-up draws from: only the waiting booking is eligible.
|
||||
bookingsRepository.findBatchPoolByCorridorDay
|
||||
.mockResolvedValueOnce([waiting])
|
||||
.mockResolvedValue([]);
|
||||
|
||||
await service.settleDueReservations(trainId);
|
||||
|
||||
// The lapsed reservation expired...
|
||||
expect(notifier.expired).toHaveBeenCalledTimes(1);
|
||||
expect((notifier.expired.mock.calls[0][0] as Booking).id).toBe('lapsed');
|
||||
// ...and the waiting booking was promoted in the SAME settle, not next cycle.
|
||||
expect(notifier.payNow).toHaveBeenCalledTimes(1);
|
||||
expect((notifier.payNow.mock.calls[0][0] as Booking).id).toBe('waiting');
|
||||
});
|
||||
|
||||
it('serialises concurrent settles so the same reservation is not settled twice', async () => {
|
||||
const lapsed = booking('lapsed', 50, {
|
||||
status: 'SELECTED_FOR_BATCH',
|
||||
paymentDeadline: new Date(Date.now() - 60_000),
|
||||
});
|
||||
// Both callers read the reservation; the lock must stop the second from
|
||||
// acting on rows the first already expired. (The PAYMENT transition and the
|
||||
// tick's overdue backstop do exactly this, in the same second.)
|
||||
let reads = 0;
|
||||
bookingsRepository.findReservedForSchedule.mockImplementation(() => {
|
||||
reads += 1;
|
||||
return Promise.resolve(reads === 1 ? [lapsed] : []);
|
||||
});
|
||||
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([]);
|
||||
|
||||
await Promise.all([
|
||||
service.settleDueReservations(trainId),
|
||||
service.settleDueReservations(trainId),
|
||||
]);
|
||||
|
||||
expect(notifier.expired).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,8 @@ describe('BookingWindowService — window state machine', () => {
|
||||
expireUnacceptedForRouteDay: jest.Mock;
|
||||
settleDueReservations: jest.Mock;
|
||||
isScheduleFull: jest.Mock;
|
||||
hasLiveReservations: jest.Mock;
|
||||
refreshWindowStatus: jest.Mock;
|
||||
};
|
||||
let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock };
|
||||
let trainSchedulingService: { finalizeSchedule: jest.Mock; getWindowConfig: jest.Mock };
|
||||
@@ -68,6 +70,9 @@ describe('BookingWindowService — window state machine', () => {
|
||||
expireUnacceptedForRouteDay: jest.fn().mockResolvedValue(undefined),
|
||||
settleDueReservations: jest.fn().mockResolvedValue(undefined),
|
||||
isScheduleFull: jest.fn().mockResolvedValue(false),
|
||||
// No reservation is mid-pay-window by default, so the cycle concludes.
|
||||
hasLiveReservations: jest.fn().mockResolvedValue(false),
|
||||
refreshWindowStatus: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
trainSchedulesRepository = {
|
||||
findById: jest.fn().mockResolvedValue(null),
|
||||
@@ -154,6 +159,26 @@ describe('BookingWindowService — window state machine', () => {
|
||||
expect(batch.settleDueReservations).toHaveBeenCalledWith(scheduleId);
|
||||
});
|
||||
|
||||
it('PAYMENT holds the cycle open while a reservation is still inside its pay window', async () => {
|
||||
// `paymentPhaseEndsAt` is stamped when the phase starts; reserve() then sets each
|
||||
// booking's own deadline milliseconds later. So the phase deadline always passes
|
||||
// first, and concluding here would kill customers who still had time to pay — and
|
||||
// leave no cycle for the waiting-list top-up to run in.
|
||||
batch.hasLiveReservations.mockResolvedValue(true);
|
||||
const s = baseSchedule({
|
||||
windowPhase: 'PAYMENT',
|
||||
paymentPhaseEndsAt: new Date('2026-07-01T02:30:00.000Z'),
|
||||
});
|
||||
|
||||
const advanced = await advanceImport(s, new Date('2026-07-01T02:30:01.000Z'));
|
||||
|
||||
expect(advanced).toBe(true);
|
||||
expect(batch.settleDueReservations).toHaveBeenCalledWith(scheduleId);
|
||||
// Still PAYMENT — the cycle was NOT concluded and the window did not reopen.
|
||||
expect(s.windowPhase).toBe('PAYMENT');
|
||||
expect(batch.isScheduleFull).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('conclude: train FULL → window FULL + phase DONE + auto-finalize', async () => {
|
||||
batch.isScheduleFull.mockResolvedValue(true);
|
||||
const s = baseSchedule({ windowPhase: 'PAYMENT' });
|
||||
|
||||
@@ -92,8 +92,17 @@ export class BookingWindowService implements OnModuleInit {
|
||||
now,
|
||||
);
|
||||
} catch (err) {
|
||||
// This is THE line to watch when a window freezes mid-phase: the tick
|
||||
// catches a throw here per-schedule and moves on, so a schedule whose
|
||||
// transition keeps throwing stays stuck in its phase forever. Log the
|
||||
// phase + stack so the failing step is obvious.
|
||||
this.logger.error(
|
||||
`Window transition failed for schedule ${schedule.id}: ${(err as Error).message}`,
|
||||
`[WINDOW] transition FAILED for schedule ${schedule.id} ` +
|
||||
`(phase=${schedule.windowPhase}, cycle=${schedule.bookingCycleNo}): ` +
|
||||
`${(err as Error).message}`,
|
||||
);
|
||||
this.logger.error(
|
||||
`[WINDOW] stack: ${((err as Error).stack ?? "").split("\n").slice(0, 5).join(" | ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -236,7 +245,8 @@ export class BookingWindowService implements OnModuleInit {
|
||||
// Fire-and-forget so a slow SMS/email gateway never stalls the tick loop.
|
||||
if (schedule.bookingCycleNo === 1) void this.notifyWindowOpened(schedule);
|
||||
this.logger.log(
|
||||
`Import booking window opened for schedule ${schedule.id} (cycle ${schedule.bookingCycleNo})`,
|
||||
`[WINDOW] ${schedule.id} PRE_WINDOW→OPEN — booking window opened ` +
|
||||
`(cycle ${schedule.bookingCycleNo})`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
@@ -251,7 +261,8 @@ export class BookingWindowService implements OnModuleInit {
|
||||
schedule.bookingWindowStatus = 'CLOSED';
|
||||
}
|
||||
this.logger.log(
|
||||
`Booking stopped for schedule ${schedule.id}; staff document review until ${docReviewEndsAt.toISOString()}`,
|
||||
`[WINDOW] ${schedule.id} OPEN→DOC_REVIEW — booking closed; staff document ` +
|
||||
`review until ${docReviewEndsAt.toISOString()}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
@@ -277,7 +288,8 @@ export class BookingWindowService implements OnModuleInit {
|
||||
// is handled inside the fill (all fit → all reserved → all notified).
|
||||
await this.bookingBatchService.processRouteDay(routeDay);
|
||||
this.logger.log(
|
||||
`Batch ran for schedule ${schedule.id}; payment phase until ${paymentPhaseEndsAt.toISOString()}`,
|
||||
`[WINDOW] ${schedule.id} DOC_REVIEW→PAYMENT — batch ran; payment phase ` +
|
||||
`until ${paymentPhaseEndsAt.toISOString()}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
@@ -287,7 +299,43 @@ export class BookingWindowService implements OnModuleInit {
|
||||
schedule.paymentPhaseEndsAt != null &&
|
||||
now >= schedule.paymentPhaseEndsAt
|
||||
) {
|
||||
this.logger.log(
|
||||
`[WINDOW] ${schedule.id} PAYMENT window ended — settling reservations ` +
|
||||
`(allocate paid / expire unpaid) then concluding the cycle`,
|
||||
);
|
||||
await this.bookingBatchService.settleDueReservations(schedule.id);
|
||||
|
||||
// The settle expires unpaid reservations and promotes the waiting list into
|
||||
// the wagons they free. Those promoted customers get a fresh pay window, and
|
||||
// `extendPaymentPhaseForTopUp` pushes `paymentPhaseEndsAt` past `now` to
|
||||
// cover it. Concluding here on the STALE in-memory timestamp would end the
|
||||
// cycle the top-up just extended and expire them before they could pay — so
|
||||
// re-read, and stay in PAYMENT if the deadline moved.
|
||||
const settled = await this.trainSchedulesRepository.findById(schedule.id);
|
||||
if (settled?.paymentPhaseEndsAt && now < settled.paymentPhaseEndsAt) {
|
||||
schedule.paymentPhaseEndsAt = settled.paymentPhaseEndsAt;
|
||||
this.logger.log(
|
||||
`[WINDOW] ${schedule.id} PAYMENT extended to ` +
|
||||
`${settled.paymentPhaseEndsAt.toISOString()} — waiting-list bookings were ` +
|
||||
`promoted into the freed wagons; not concluding this cycle yet`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
// `paymentPhaseEndsAt` is stamped when the phase starts; each reservation's own
|
||||
// deadline is set milliseconds later, per booking, so the phase always expires
|
||||
// a fraction before the reservations it opened. Concluding here would end the
|
||||
// cycle while customers still had time to pay, and the settle that finally
|
||||
// expires them (next tick) would have no cycle left to promote the waiting
|
||||
// list into. Hold in PAYMENT until every reservation has actually resolved.
|
||||
if (await this.bookingBatchService.hasLiveReservations(schedule.id)) {
|
||||
this.logger.log(
|
||||
`[WINDOW] ${schedule.id} PAYMENT phase past its deadline but reservations ` +
|
||||
`are still within their pay windows — holding the cycle open`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
await this.concludeCycle(schedule, cfg, now);
|
||||
return true;
|
||||
}
|
||||
@@ -306,9 +354,24 @@ export class BookingWindowService implements OnModuleInit {
|
||||
await this.bookingBatchService.setWindow(schedule.id, 'FULL');
|
||||
await this.setPhase(schedule, { windowPhase: 'DONE' });
|
||||
await this.tryAutoFinalize(schedule.id);
|
||||
this.logger.log(
|
||||
`[WINDOW] ${schedule.id} conclude → train FULL — window DONE, finalizing`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Not full, so any FULL flag left over from a batch whose bookings later
|
||||
// expired is stale. Clear it here too: the PRE_WINDOW→OPEN transition below
|
||||
// refuses to reopen a FULL schedule, which is how a train with an empty
|
||||
// consist used to cycle forever without ever being fillable again. Re-read
|
||||
// the flag onto the in-memory row — advanceSchedule keeps looping on this
|
||||
// same object, and PRE_WINDOW→OPEN reads it.
|
||||
if (schedule.bookingWindowStatus === 'FULL') {
|
||||
await this.bookingBatchService.refreshWindowStatus(schedule.id);
|
||||
const fresh = await this.trainSchedulesRepository.findById(schedule.id);
|
||||
if (fresh) schedule.bookingWindowStatus = fresh.bookingWindowStatus;
|
||||
}
|
||||
|
||||
// Doc review + payment have already run, so the desk is ready to reopen NOW —
|
||||
// office hours decide whether that is this afternoon or tomorrow morning. Past
|
||||
// the last cycle before departure, nextCycleOpensAt returns null and we finish.
|
||||
@@ -324,7 +387,8 @@ export class BookingWindowService implements OnModuleInit {
|
||||
if (nextOpensAt == null) {
|
||||
await this.setPhase(schedule, { windowPhase: 'DONE' });
|
||||
this.logger.log(
|
||||
`Schedule ${schedule.id} not full but no cycle fits before departure — window done`,
|
||||
`[WINDOW] ${schedule.id} conclude → not full but no cycle fits before ` +
|
||||
`departure — window DONE`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -347,7 +411,8 @@ export class BookingWindowService implements OnModuleInit {
|
||||
});
|
||||
const sameDay = eatDay(nextOpensAt) === eatDay(now);
|
||||
this.logger.log(
|
||||
`Schedule ${schedule.id} not full — window reopens ${sameDay ? 'today' : 'next booking day'} at ${nextOpensAt.toISOString()}`,
|
||||
`[WINDOW] ${schedule.id} conclude → NOT full, waiting list may remain — ` +
|
||||
`REOPENS ${sameDay ? 'today' : 'next booking day'} at ${nextOpensAt.toISOString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ const nw5: WagonType = {
|
||||
name: 'Flat Wagon',
|
||||
capacityTons: 70,
|
||||
lengthMeters: 14,
|
||||
maxWagonsPerTrain: 53,
|
||||
supportedLoadTypes: ['CONTAINER'],
|
||||
isActive: true,
|
||||
supportsContainer: true,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildBulkWagonPlan,
|
||||
buildContainerWagonPlan,
|
||||
buildMixedWagonPlan,
|
||||
containerWagonsForLines,
|
||||
roundTons,
|
||||
type WagonPlanSlot,
|
||||
} from './wagon-plan.util';
|
||||
@@ -43,11 +44,10 @@ export function wagonsRequiredForBooking(booking: Booking, bulkWagonCapacity?: n
|
||||
return Math.max(1, Math.ceil(weight / capacity));
|
||||
}
|
||||
|
||||
const lineSlots = (booking.bookingContainers ?? []).reduce(
|
||||
(sum, line) => sum + Number(line.wagonsRequired ?? 0),
|
||||
0,
|
||||
);
|
||||
return Math.max(1, lineSlots);
|
||||
// TEU-aware, ceiled once at the booking level (40ft = 1 wagon, two 20ft = 1
|
||||
// wagon). Honors containerType.wagonsPerUnit; falls back to the line's stored
|
||||
// fraction. Ceiling per line would over-count split 20ft lines.
|
||||
return Math.max(1, containerWagonsForLines(booking.bookingContainers ?? []));
|
||||
}
|
||||
|
||||
export function countSlotsByType(wagonPlan: WagonPlanSlot[]): Map<string, { code: string; count: number }> {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { TrainSchedulingService } from './train-scheduling.service';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity';
|
||||
|
||||
type Row = Pick<ClearanceMilestone, 'bookingId' | 'milestoneCode' | 'status'> & {
|
||||
metadata?: Record<string, unknown> | null;
|
||||
triggeredAt?: Date | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The gate pass is secured once per train schedule, but each booking only earns
|
||||
* its GATEPASS_GRANTED milestone after settling freight payment. An unpaid
|
||||
* booking must not ride a paid neighbour's grant — the train proceeds, that
|
||||
* booking stays pending.
|
||||
*/
|
||||
function makeService(bookings: Array<Partial<Booking>>, rows: Row[]) {
|
||||
const milestoneRepo = {
|
||||
find: jest.fn().mockResolvedValue(rows),
|
||||
save: jest.fn((row: Row) => Promise.resolve(row)),
|
||||
};
|
||||
const bookingRepo = { find: jest.fn().mockResolvedValue(bookings) };
|
||||
const dataSource = {
|
||||
getRepository: (entity: unknown) =>
|
||||
entity === Booking ? bookingRepo : milestoneRepo,
|
||||
};
|
||||
|
||||
const service = Object.create(
|
||||
TrainSchedulingService.prototype,
|
||||
) as TrainSchedulingService;
|
||||
Object.assign(service, {
|
||||
dataSource,
|
||||
logger: { warn: jest.fn(), log: jest.fn() },
|
||||
});
|
||||
return { service, milestoneRepo };
|
||||
}
|
||||
|
||||
/** Reach the private bridge write under test. */
|
||||
function grant(service: TrainSchedulingService, at: Date): Promise<void> {
|
||||
return (
|
||||
service as unknown as {
|
||||
completeGatepassMilestoneForSchedule(id: string, at: Date): Promise<void>;
|
||||
}
|
||||
).completeGatepassMilestoneForSchedule('sched-1', at);
|
||||
}
|
||||
|
||||
const securedAt = new Date('2026-07-09T08:00:00.000Z');
|
||||
|
||||
describe('gate pass is withheld from bookings that have not paid freight', () => {
|
||||
it('grants the paid booking and leaves the unpaid one pending', async () => {
|
||||
const rows: Row[] = [
|
||||
{ bookingId: 'paid', milestoneCode: 'FREIGHT_PAYMENT_SETTLED', status: 'COMPLETED' },
|
||||
{ bookingId: 'paid', milestoneCode: 'GATEPASS_GRANTED', status: 'PENDING' },
|
||||
{ bookingId: 'unpaid', milestoneCode: 'FREIGHT_PAYMENT_SETTLED', status: 'PENDING' },
|
||||
{ bookingId: 'unpaid', milestoneCode: 'GATEPASS_GRANTED', status: 'PENDING' },
|
||||
];
|
||||
const { service, milestoneRepo } = makeService(
|
||||
[
|
||||
{ id: 'paid', status: 'CONFIRMED', paymentStatus: 'PENDING' },
|
||||
{ id: 'unpaid', status: 'CONFIRMED', paymentStatus: 'PENDING' },
|
||||
],
|
||||
rows,
|
||||
);
|
||||
|
||||
await grant(service, securedAt);
|
||||
|
||||
const saved = milestoneRepo.save.mock.calls.map(([r]: [Row]) => r);
|
||||
expect(saved).toHaveLength(1);
|
||||
expect(saved[0]!.bookingId).toBe('paid');
|
||||
expect(saved[0]!.status).toBe('COMPLETED');
|
||||
expect(saved[0]!.triggeredAt).toBe(securedAt);
|
||||
|
||||
const unpaid = rows.find(
|
||||
(r) => r.bookingId === 'unpaid' && r.milestoneCode === 'GATEPASS_GRANTED',
|
||||
);
|
||||
expect(unpaid!.status).toBe('PENDING');
|
||||
});
|
||||
|
||||
it('treats a booking paid outside the milestone path as paid', async () => {
|
||||
// Some payment paths settle the invoice without writing the milestone; the
|
||||
// clearance views self-heal it on read, so the gate pass must not lag.
|
||||
const rows: Row[] = [
|
||||
{ bookingId: 'b-1', milestoneCode: 'FREIGHT_PAYMENT_SETTLED', status: 'PENDING' },
|
||||
{ bookingId: 'b-1', milestoneCode: 'GATEPASS_GRANTED', status: 'PENDING' },
|
||||
];
|
||||
const { service, milestoneRepo } = makeService(
|
||||
[{ id: 'b-1', status: 'CONFIRMED', paymentStatus: 'PAID' }],
|
||||
rows,
|
||||
);
|
||||
|
||||
await grant(service, securedAt);
|
||||
|
||||
expect(milestoneRepo.save).toHaveBeenCalledTimes(1);
|
||||
expect(milestoneRepo.save.mock.calls[0]![0].bookingId).toBe('b-1');
|
||||
});
|
||||
|
||||
it('leaves an already-granted milestone untouched', async () => {
|
||||
const rows: Row[] = [
|
||||
{ bookingId: 'b-1', milestoneCode: 'FREIGHT_PAYMENT_SETTLED', status: 'COMPLETED' },
|
||||
{ bookingId: 'b-1', milestoneCode: 'GATEPASS_GRANTED', status: 'COMPLETED' },
|
||||
];
|
||||
const { service, milestoneRepo } = makeService(
|
||||
[{ id: 'b-1', status: 'PAID', paymentStatus: 'PAID' }],
|
||||
rows,
|
||||
);
|
||||
|
||||
await grant(service, securedAt);
|
||||
|
||||
expect(milestoneRepo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when the schedule carries no customs bookings', async () => {
|
||||
const { service, milestoneRepo } = makeService([], []);
|
||||
|
||||
await grant(service, securedAt);
|
||||
|
||||
expect(milestoneRepo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,41 +1,188 @@
|
||||
import {
|
||||
bookingGrossWeightTons,
|
||||
bookingTrainLengthMeters,
|
||||
consistUsage,
|
||||
consistViolations,
|
||||
deriveTrainCapacityFromLocomotive,
|
||||
grossWagonWeightTons,
|
||||
minLocomotiveLimits,
|
||||
} from './train-capacity.util';
|
||||
|
||||
describe('train-capacity.util', () => {
|
||||
const nw5 = { lengthMeters: 14, capacityTons: 70 };
|
||||
// Real EDR wagon specs.
|
||||
const nw5 = { lengthMeters: 13.966, capacityTons: 70, tareWeightTons: 22.4 };
|
||||
const pw2 = { lengthMeters: 17.066, capacityTons: 70, tareWeightTons: 25.2 };
|
||||
const gw2 = { lengthMeters: 12.228, capacityTons: 70, tareWeightTons: 23 };
|
||||
|
||||
it('derives wagon slots from locomotive length and weight, not a fixed 53', () => {
|
||||
const shortLoco = deriveTrainCapacityFromLocomotive(
|
||||
{ maxPullWeightTons: 2000, maxTrainLengthMeters: 280 },
|
||||
[nw5],
|
||||
);
|
||||
expect(shortLoco.maxWagonSlots).toBe(20); // 280 / 14
|
||||
expect(shortLoco.maxWagonSlots).not.toBe(53);
|
||||
|
||||
const heavyLoco = deriveTrainCapacityFromLocomotive(
|
||||
{ maxPullWeightTons: 2100, maxTrainLengthMeters: 760 },
|
||||
[nw5],
|
||||
);
|
||||
expect(heavyLoco.maxWagonSlots).toBe(30); // min(54, 30) from weight 2100/70
|
||||
const caps = (over = {}) => ({
|
||||
maxWeightTons: 3500,
|
||||
maxLengthMeters: 760,
|
||||
maxWagonSlots: 54,
|
||||
...over,
|
||||
});
|
||||
|
||||
it('uses shortest wagon type when mixed types are present', () => {
|
||||
const longBulk = { lengthMeters: 18, capacityTons: 80 };
|
||||
const mixed = deriveTrainCapacityFromLocomotive(
|
||||
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760 },
|
||||
[nw5, longBulk],
|
||||
);
|
||||
expect(mixed.maxWagonSlots).toBe(
|
||||
Math.min(Math.floor(760 / 14), Math.floor(3500 / 70)),
|
||||
);
|
||||
const slots = (n: number, type: typeof nw5, cargoTons: number) =>
|
||||
Array.from({ length: n }, () => ({
|
||||
lengthMeters: type.lengthMeters,
|
||||
tareWeightTons: type.tareWeightTons,
|
||||
cargoTons,
|
||||
}));
|
||||
|
||||
describe('deriveTrainCapacityFromLocomotive', () => {
|
||||
it('derives wagon slots from train length, not a fixed 53', () => {
|
||||
const shortLoco = deriveTrainCapacityFromLocomotive(
|
||||
{ maxPullWeightTons: 2000, maxTrainLengthMeters: 280 },
|
||||
[nw5],
|
||||
);
|
||||
expect(shortLoco.maxWagonSlots).toBe(20); // floor(280 / 13.966)
|
||||
expect(shortLoco.maxWagonSlots).not.toBe(53);
|
||||
});
|
||||
|
||||
it('does not shrink slots by assuming every wagon rides at full payload', () => {
|
||||
// A 2100T loco could only pull 30 fully-laden 70T wagons, but slots are a
|
||||
// LENGTH figure — the cargo that decides weight does not exist yet.
|
||||
const derived = deriveTrainCapacityFromLocomotive(
|
||||
{ maxPullWeightTons: 2100, maxTrainLengthMeters: 760 },
|
||||
[nw5],
|
||||
);
|
||||
expect(derived.maxWagonSlots).toBe(54); // floor(760 / 13.966), not 30
|
||||
expect(derived.maxWeightTons).toBe(2100);
|
||||
});
|
||||
|
||||
it('admits the railway 53-wagon NW5 marshalling figure', () => {
|
||||
const derived = deriveTrainCapacityFromLocomotive(
|
||||
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760 },
|
||||
[nw5],
|
||||
);
|
||||
expect(derived.maxWagonSlots).toBeGreaterThanOrEqual(53);
|
||||
});
|
||||
|
||||
it('uses the shortest wagon type when mixed types are present', () => {
|
||||
const mixed = deriveTrainCapacityFromLocomotive(
|
||||
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760 },
|
||||
[nw5, pw2, gw2],
|
||||
);
|
||||
expect(mixed.maxWagonSlots).toBe(Math.floor(760 / gw2.lengthMeters)); // 62
|
||||
});
|
||||
|
||||
it('extends weight/length caps by the locomotive overage tolerance', () => {
|
||||
const derived = deriveTrainCapacityFromLocomotive(
|
||||
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
|
||||
[pw2],
|
||||
);
|
||||
expect(derived.maxWeightTons).toBe(3590);
|
||||
});
|
||||
|
||||
it('ignores overage tolerance when unset (strict cap)', () => {
|
||||
const derived = deriveTrainCapacityFromLocomotive(
|
||||
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760 },
|
||||
[nw5],
|
||||
);
|
||||
expect(derived.maxWeightTons).toBe(3500);
|
||||
expect(derived.maxLengthMeters).toBe(760);
|
||||
});
|
||||
|
||||
it('floors the locomotive by the global rule caps', () => {
|
||||
const derived = deriveTrainCapacityFromLocomotive(
|
||||
{ maxPullWeightTons: 5000, maxTrainLengthMeters: 900 },
|
||||
[nw5],
|
||||
{ maxTrainWeightTons: 3500, maxTrainLengthMeters: 760 },
|
||||
);
|
||||
expect(derived.maxWeightTons).toBe(3500);
|
||||
expect(derived.maxLengthMeters).toBe(760);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gross weight', () => {
|
||||
it('counts the wagon as well as its cargo', () => {
|
||||
expect(grossWagonWeightTons({ tareWeightTons: 25.2, cargoTons: 70 })).toBe(95.2);
|
||||
});
|
||||
|
||||
it('charges a booking one tare per wagon it occupies', () => {
|
||||
// 3 flat wagons carrying 100T of cargo still drag 3 × 22.4T of steel.
|
||||
expect(bookingGrossWeightTons(100, 3, 22.4)).toBe(167.2);
|
||||
});
|
||||
|
||||
it('is cargo alone when the wagon type has no tare on record', () => {
|
||||
expect(bookingGrossWeightTons(100, 3, 0)).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('consistUsage', () => {
|
||||
it('sums each wagon own length and tare rather than averaging a type', () => {
|
||||
const mixed = [...slots(2, nw5, 10), ...slots(1, pw2, 20)];
|
||||
const usage = consistUsage(mixed, caps());
|
||||
|
||||
expect(usage.wagonCount).toBe(3);
|
||||
expect(usage.usedLengthMeters).toBe(44.998); // 2×13.966 + 17.066
|
||||
expect(usage.usedTareWeightTons).toBe(70); // 2×22.4 + 25.2
|
||||
expect(usage.usedCargoWeightTons).toBe(40);
|
||||
expect(usage.usedGrossWeightTons).toBe(110);
|
||||
expect(usage.remainingGrossWeightTons).toBe(3390);
|
||||
expect(usage.remainingWagons).toBe(51);
|
||||
});
|
||||
|
||||
it('reports an empty consist as fully available', () => {
|
||||
const usage = consistUsage([], caps());
|
||||
expect(usage.usedGrossWeightTons).toBe(0);
|
||||
expect(usage.remainingLengthMeters).toBe(760);
|
||||
expect(usage.remainingWagons).toBe(54);
|
||||
});
|
||||
});
|
||||
|
||||
describe('consistViolations', () => {
|
||||
it('accepts 37 fully-laden PW2 box wagons only via the overage tolerance', () => {
|
||||
// 37 × (25.2 + 70) = 3522.4T — over 3500T, inside 3590T.
|
||||
const consist = slots(37, pw2, 70);
|
||||
|
||||
expect(consistViolations(consist, caps({ maxWagonSlots: 44 }))).toEqual([
|
||||
expect.stringContaining('3522.4T'),
|
||||
]);
|
||||
expect(
|
||||
consistViolations(consist, caps({ maxWeightTons: 3590, maxWagonSlots: 44 })),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('blocks a train the old cargo-only math would have waved through', () => {
|
||||
// Cargo alone is 2590T — comfortably "under" 3500T. Gross is 3522.4T.
|
||||
const consist = slots(37, pw2, 70);
|
||||
const cargoOnly = consist.reduce((sum, s) => sum + s.cargoTons, 0);
|
||||
|
||||
expect(cargoOnly).toBeLessThan(3500);
|
||||
expect(consistViolations(consist, caps({ maxWagonSlots: 44 }))).not.toEqual([]);
|
||||
});
|
||||
|
||||
it('lets 53 NW5 flat wagons pass when the cargo is what the railway really loads', () => {
|
||||
// 53 × 13.966 = 740.2m < 760m; 53 × (22.4 + 40) = 3307.2T < 3500T.
|
||||
expect(consistViolations(slots(53, nw5, 40), caps({ maxWagonSlots: 54 }))).toEqual([]);
|
||||
});
|
||||
|
||||
it('flags an over-length consist', () => {
|
||||
const violations = consistViolations(slots(50, pw2, 5), caps({ maxWagonSlots: 60 }));
|
||||
expect(violations).toEqual([expect.stringContaining('exceeds max train length')]);
|
||||
});
|
||||
|
||||
it('flags an over-count consist', () => {
|
||||
const violations = consistViolations(slots(10, nw5, 1), caps({ maxWagonSlots: 9 }));
|
||||
expect(violations).toEqual([expect.stringContaining('exceeds max wagons per train')]);
|
||||
});
|
||||
|
||||
it('reports every broken axis at once', () => {
|
||||
expect(consistViolations(slots(60, pw2, 70), caps())).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
it('computes booking length by freight type', () => {
|
||||
expect(
|
||||
bookingTrainLengthMeters('CONTAINER', 2, { container: 14, bulk: 14 }),
|
||||
).toBe(28);
|
||||
expect(bookingTrainLengthMeters('CONTAINER', 2, { container: 14, bulk: 14 })).toBe(28);
|
||||
expect(bookingTrainLengthMeters('BULK', 3, { container: 14, bulk: 18 })).toBe(54);
|
||||
});
|
||||
|
||||
it('takes the weakest locomotive across a multi-locomotive set', () => {
|
||||
const limits = minLocomotiveLimits([
|
||||
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
|
||||
{ maxPullWeightTons: 4000, maxTrainLengthMeters: 760, overageToleranceTons: 20 },
|
||||
]);
|
||||
expect(limits?.maxPullWeightTons).toBe(3500);
|
||||
expect(limits?.overageToleranceTons).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,73 +1,212 @@
|
||||
/**
|
||||
* Train capacity is a THREE-AXIS constraint, and the axes are not interchangeable:
|
||||
*
|
||||
* count — how many wagons fit end to end on the longest allowed train
|
||||
* length — Σ wagonType.lengthMeters over the real consist
|
||||
* weight — Σ (wagonType.tareWeightTons + cargoTons) over the real consist
|
||||
*
|
||||
* The weight axis is GROSS: a locomotive pulls the wagon as well as what is in it.
|
||||
* The old code compared the locomotive's pull limit against cargo payload alone
|
||||
* and so overbooked every train by roughly the tare fraction (~27% on PW2).
|
||||
*
|
||||
* The weight axis is also driven by ACTUAL booked cargo, never by an assumed
|
||||
* full payload. That is what makes the real EDR numbers fall out:
|
||||
*
|
||||
* NW5 13.966m tare 22.4T → 760 / 13.966 = 54 slots by length; the 53-wagon
|
||||
* marshalling figure is length-bound, and those trains never carry 53×70T.
|
||||
* PW2 17.066m tare 25.2T → 44 slots by length, but 37 × (25.2 + 70) = 3522.4T,
|
||||
* which clears 3500T only via the locomotive's overage tolerance. Weight
|
||||
* binds first, hence "37 wagons per train".
|
||||
*
|
||||
* So: `maxWagonSlots` is a LENGTH-derived planning number, shown before any cargo
|
||||
* exists. Weight is enforced against the consist as bookings are allocated.
|
||||
*/
|
||||
|
||||
/** Physical dimensions used when deriving how many wagons a locomotive can pull. */
|
||||
export type WagonTypeDimensions = {
|
||||
lengthMeters: number;
|
||||
capacityTons: number;
|
||||
tareWeightTons: number;
|
||||
};
|
||||
|
||||
/** One occupied wagon slot in a real consist. */
|
||||
export type ConsistSlot = {
|
||||
lengthMeters: number;
|
||||
tareWeightTons: number;
|
||||
/** Actual cargo/container weight riding on this wagon, not its rated capacity. */
|
||||
cargoTons: number;
|
||||
};
|
||||
|
||||
export type LocomotiveLimits = {
|
||||
maxPullWeightTons: number;
|
||||
maxTrainLengthMeters: number;
|
||||
/** Allowed deviation above maxPullWeightTons before scheduling blocks the train. */
|
||||
overageToleranceTons?: number | null;
|
||||
/** Allowed deviation above maxTrainLengthMeters before scheduling blocks the train. */
|
||||
overageToleranceMeters?: number | null;
|
||||
};
|
||||
|
||||
export type DerivedTrainCapacity = {
|
||||
/** Gross (tare + cargo) tons the train may weigh, tolerance included. */
|
||||
maxWeightTons: number;
|
||||
maxLengthMeters: number;
|
||||
/** Length-derived slot count. Weight is enforced separately against real cargo. */
|
||||
maxWagonSlots: number;
|
||||
};
|
||||
|
||||
/** What a consist currently uses, and what is left on each axis. */
|
||||
export type ConsistUsage = {
|
||||
wagonCount: number;
|
||||
usedLengthMeters: number;
|
||||
/** Σ (tare + cargo). */
|
||||
usedGrossWeightTons: number;
|
||||
usedTareWeightTons: number;
|
||||
usedCargoWeightTons: number;
|
||||
remainingLengthMeters: number;
|
||||
remainingGrossWeightTons: number;
|
||||
remainingWagons: number;
|
||||
};
|
||||
|
||||
export const MAX_FALLBACK_WEIGHT = 3500;
|
||||
export const MAX_FALLBACK_LENGTH = 760;
|
||||
|
||||
const DEFAULT_WAGON_LENGTH_M = 14;
|
||||
const DEFAULT_WAGON_CAPACITY_T = 70;
|
||||
/** NW5's tare — the commonest wagon — used only when a type predates the NOT NULL backfill. */
|
||||
const DEFAULT_WAGON_TARE_T = 22.4;
|
||||
|
||||
function num(value: unknown, fallback = 0): number {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
/** Gross weight of one loaded wagon: it hauls itself plus its cargo. */
|
||||
export function grossWagonWeightTons(slot: Pick<ConsistSlot, 'tareWeightTons' | 'cargoTons'>): number {
|
||||
return num(slot.tareWeightTons) + num(slot.cargoTons);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive train capacity from locomotive pull weight and train length.
|
||||
* Wagon count is NOT a fixed 53 — it is the minimum of:
|
||||
* - floor(maxLength / shortest wagon type length)
|
||||
* - floor(maxWeight / lightest wagon type capacity)
|
||||
* Hard caps for a train: the locomotive's own limits, floored by the global rule
|
||||
* caps, then widened by the locomotive's overage tolerance.
|
||||
*/
|
||||
export function trainHardCaps(
|
||||
locomotive: LocomotiveLimits,
|
||||
ruleCaps?: { maxTrainWeightTons?: number; maxTrainLengthMeters?: number },
|
||||
): { maxWeightTons: number; maxLengthMeters: number } {
|
||||
const overageTons = num(locomotive.overageToleranceTons);
|
||||
const overageMeters = num(locomotive.overageToleranceMeters);
|
||||
|
||||
const weight =
|
||||
Math.min(
|
||||
num(locomotive.maxPullWeightTons, Infinity) || Infinity,
|
||||
ruleCaps?.maxTrainWeightTons ?? Infinity,
|
||||
) + overageTons;
|
||||
const length =
|
||||
Math.min(
|
||||
num(locomotive.maxTrainLengthMeters, Infinity) || Infinity,
|
||||
ruleCaps?.maxTrainLengthMeters ?? Infinity,
|
||||
) + overageMeters;
|
||||
|
||||
return {
|
||||
maxWeightTons: Number.isFinite(weight) ? weight : MAX_FALLBACK_WEIGHT,
|
||||
maxLengthMeters: Number.isFinite(length) ? length : MAX_FALLBACK_LENGTH,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the planning capacity of a train from its locomotive.
|
||||
*
|
||||
* `maxWagonSlots` counts how many of the SHORTEST allowed wagon type fit within
|
||||
* the train-length cap — the optimistic slot count, since a mixed consist of
|
||||
* longer wagons will hit the length cap sooner. It is deliberately NOT reduced by
|
||||
* weight: with no bookings yet there is no cargo, and assuming every wagon rides
|
||||
* at full rated payload would report 37 NW5 slots where the railway marshals 53.
|
||||
* Weight is enforced by {@link consistUsage} / {@link consistViolations} against
|
||||
* the cargo actually allocated.
|
||||
*/
|
||||
export function deriveTrainCapacityFromLocomotive(
|
||||
locomotive: LocomotiveLimits,
|
||||
wagonTypes: WagonTypeDimensions[],
|
||||
ruleCaps?: { maxTrainWeightTons?: number; maxTrainLengthMeters?: number },
|
||||
): DerivedTrainCapacity {
|
||||
const maxWeightTons = Math.min(
|
||||
Number(locomotive.maxPullWeightTons) || Infinity,
|
||||
ruleCaps?.maxTrainWeightTons ?? Infinity,
|
||||
);
|
||||
const maxLengthMeters = Math.min(
|
||||
Number(locomotive.maxTrainLengthMeters) || Infinity,
|
||||
ruleCaps?.maxTrainLengthMeters ?? Infinity,
|
||||
);
|
||||
const { maxWeightTons, maxLengthMeters } = trainHardCaps(locomotive, ruleCaps);
|
||||
|
||||
const types =
|
||||
wagonTypes.length > 0
|
||||
? wagonTypes
|
||||
: [{ lengthMeters: DEFAULT_WAGON_LENGTH_M, capacityTons: DEFAULT_WAGON_CAPACITY_T }];
|
||||
const lengths = wagonTypes
|
||||
.map((w) => num(w.lengthMeters))
|
||||
.filter((l) => l > 0);
|
||||
const minLength = lengths.length ? Math.min(...lengths) : DEFAULT_WAGON_LENGTH_M;
|
||||
|
||||
const minLength = Math.min(...types.map((w) => Number(w.lengthMeters) || DEFAULT_WAGON_LENGTH_M));
|
||||
const minCapacity = Math.min(
|
||||
...types.map((w) => Number(w.capacityTons) || DEFAULT_WAGON_CAPACITY_T),
|
||||
);
|
||||
const maxWagonSlots =
|
||||
minLength > 0 ? Math.max(0, Math.floor(maxLengthMeters / minLength)) : 0;
|
||||
|
||||
const byLength =
|
||||
minLength > 0 && Number.isFinite(maxLengthMeters)
|
||||
? Math.floor(maxLengthMeters / minLength)
|
||||
: 0;
|
||||
const byWeight =
|
||||
minCapacity > 0 && Number.isFinite(maxWeightTons)
|
||||
? Math.floor(maxWeightTons / minCapacity)
|
||||
: byLength;
|
||||
return { maxWeightTons, maxLengthMeters, maxWagonSlots };
|
||||
}
|
||||
|
||||
const maxWagonSlots = Math.max(0, Math.min(byLength, byWeight));
|
||||
/**
|
||||
* What a real, mixed-type consist uses on all three axes, and what is left.
|
||||
* Every wagon contributes its own length and its own tare — no averaging over a
|
||||
* representative wagon type.
|
||||
*/
|
||||
export function consistUsage(
|
||||
slots: ConsistSlot[],
|
||||
caps: { maxWeightTons: number; maxLengthMeters: number; maxWagonSlots: number },
|
||||
): ConsistUsage {
|
||||
let usedLengthMeters = 0;
|
||||
let usedTareWeightTons = 0;
|
||||
let usedCargoWeightTons = 0;
|
||||
|
||||
for (const slot of slots) {
|
||||
usedLengthMeters += num(slot.lengthMeters);
|
||||
usedTareWeightTons += num(slot.tareWeightTons);
|
||||
usedCargoWeightTons += num(slot.cargoTons);
|
||||
}
|
||||
|
||||
const usedGrossWeightTons = usedTareWeightTons + usedCargoWeightTons;
|
||||
|
||||
return {
|
||||
maxWeightTons: Number.isFinite(maxWeightTons) ? maxWeightTons : MAX_FALLBACK_WEIGHT,
|
||||
maxLengthMeters: Number.isFinite(maxLengthMeters) ? maxLengthMeters : MAX_FALLBACK_LENGTH,
|
||||
maxWagonSlots,
|
||||
wagonCount: slots.length,
|
||||
usedLengthMeters: round3(usedLengthMeters),
|
||||
usedGrossWeightTons: round3(usedGrossWeightTons),
|
||||
usedTareWeightTons: round3(usedTareWeightTons),
|
||||
usedCargoWeightTons: round3(usedCargoWeightTons),
|
||||
remainingLengthMeters: round3(caps.maxLengthMeters - usedLengthMeters),
|
||||
remainingGrossWeightTons: round3(caps.maxWeightTons - usedGrossWeightTons),
|
||||
remainingWagons: caps.maxWagonSlots - slots.length,
|
||||
};
|
||||
}
|
||||
|
||||
export const MAX_FALLBACK_WEIGHT = 3500;
|
||||
export const MAX_FALLBACK_LENGTH = 760;
|
||||
/** Human-readable reasons a consist breaks its train's limits. Empty = it fits. */
|
||||
export function consistViolations(
|
||||
slots: ConsistSlot[],
|
||||
caps: { maxWeightTons: number; maxLengthMeters: number; maxWagonSlots: number },
|
||||
): string[] {
|
||||
const usage = consistUsage(slots, caps);
|
||||
const violations: string[] = [];
|
||||
|
||||
if (usage.usedGrossWeightTons > caps.maxWeightTons) {
|
||||
violations.push(
|
||||
`Total train gross weight ${usage.usedGrossWeightTons}T ` +
|
||||
`(${usage.usedTareWeightTons}T tare + ${usage.usedCargoWeightTons}T cargo) ` +
|
||||
`exceeds max pull weight ${round3(caps.maxWeightTons)}T`,
|
||||
);
|
||||
}
|
||||
if (usage.usedLengthMeters > caps.maxLengthMeters) {
|
||||
violations.push(
|
||||
`Total wagon length ${usage.usedLengthMeters}m exceeds max train length ${round3(caps.maxLengthMeters)}m`,
|
||||
);
|
||||
}
|
||||
if (usage.wagonCount > caps.maxWagonSlots) {
|
||||
violations.push(
|
||||
`Wagon count ${usage.wagonCount} exceeds max wagons per train (${caps.maxWagonSlots})`,
|
||||
);
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
function round3(value: number): number {
|
||||
return Number.isFinite(value) ? Number(value.toFixed(3)) : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective pull limits for a train set with multiple locomotives: the weakest
|
||||
@@ -75,16 +214,22 @@ export const MAX_FALLBACK_LENGTH = 760;
|
||||
* across all assigned locomotives. Returns null when no locomotives are given.
|
||||
*/
|
||||
export function minLocomotiveLimits(
|
||||
locomotives: Array<Pick<LocomotiveLimits, 'maxPullWeightTons' | 'maxTrainLengthMeters'>>,
|
||||
locomotives: Array<
|
||||
Pick<LocomotiveLimits, 'maxPullWeightTons' | 'maxTrainLengthMeters'> &
|
||||
Partial<Pick<LocomotiveLimits, 'overageToleranceTons' | 'overageToleranceMeters'>>
|
||||
>,
|
||||
): LocomotiveLimits | null {
|
||||
if (!locomotives.length) return null;
|
||||
return {
|
||||
maxPullWeightTons: Math.min(
|
||||
...locomotives.map((l) => Number(l.maxPullWeightTons) || Infinity),
|
||||
...locomotives.map((l) => num(l.maxPullWeightTons, Infinity) || Infinity),
|
||||
),
|
||||
maxTrainLengthMeters: Math.min(
|
||||
...locomotives.map((l) => Number(l.maxTrainLengthMeters) || Infinity),
|
||||
...locomotives.map((l) => num(l.maxTrainLengthMeters, Infinity) || Infinity),
|
||||
),
|
||||
// Weakest locomotive's tolerance governs the set, same as its caps.
|
||||
overageToleranceTons: Math.min(...locomotives.map((l) => num(l.overageToleranceTons))),
|
||||
overageToleranceMeters: Math.min(...locomotives.map((l) => num(l.overageToleranceMeters))),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -98,12 +243,27 @@ export function bookingTrainLengthMeters(
|
||||
return wagonCount * perWagon;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gross weight a booking adds to its train: its cargo plus the tare of every
|
||||
* wagon it occupies. A booking is never weightless just because it is light —
|
||||
* the empty wagons still have to be pulled.
|
||||
*/
|
||||
export function bookingGrossWeightTons(
|
||||
cargoTons: number,
|
||||
wagonCount: number,
|
||||
tarePerWagonTons: number,
|
||||
): number {
|
||||
return round3(num(cargoTons) + wagonCount * num(tarePerWagonTons));
|
||||
}
|
||||
|
||||
export function wagonTypeDimensionsFromEntity(wt: {
|
||||
lengthMeters?: number | string | null;
|
||||
capacityTons?: number | string | null;
|
||||
tareWeightTons?: number | string | null;
|
||||
}): WagonTypeDimensions {
|
||||
return {
|
||||
lengthMeters: Number(wt.lengthMeters) || DEFAULT_WAGON_LENGTH_M,
|
||||
capacityTons: Number(wt.capacityTons) || DEFAULT_WAGON_CAPACITY_T,
|
||||
lengthMeters: num(wt.lengthMeters) || DEFAULT_WAGON_LENGTH_M,
|
||||
capacityTons: num(wt.capacityTons) || DEFAULT_WAGON_CAPACITY_T,
|
||||
tareWeightTons: num(wt.tareWeightTons) || DEFAULT_WAGON_TARE_T,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ const nw5 = {
|
||||
name: 'Flat Wagon',
|
||||
capacityTons: 70,
|
||||
lengthMeters: 14,
|
||||
maxWagonsPerTrain: 53,
|
||||
supportedLoadTypes: ['CONTAINER'],
|
||||
isActive: true,
|
||||
supportsContainer: true,
|
||||
@@ -35,7 +34,6 @@ const cw3 = {
|
||||
name: 'Covered Wagon',
|
||||
capacityTons: 60,
|
||||
lengthMeters: 14,
|
||||
maxWagonsPerTrain: 53,
|
||||
supportedLoadTypes: ['BULK'],
|
||||
isActive: true,
|
||||
supportsContainer: false,
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource, EntityManager, In, IsNull, Not, QueryFailedError } from 'typeorm';
|
||||
import { DataSource, EntityManager, In, Not, QueryFailedError } from 'typeorm';
|
||||
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
@@ -43,8 +43,6 @@ import { WagonAllocationBulkLoadsRepository } from '../train-schedules/wagon-all
|
||||
import { WagonAllocationContainerItemsRepository } from '../train-schedules/wagon-allocation-container-items.repository';
|
||||
import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-booking-allocations.repository';
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
|
||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||
import { WagonTypesRepository } from '../wagon-types/wagon-types.repository';
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
import { AssignBookingsDto } from './dto/assign-bookings.dto';
|
||||
@@ -104,10 +102,13 @@ import {
|
||||
deriveTrainCapacityFromLocomotive,
|
||||
minLocomotiveLimits,
|
||||
wagonTypeDimensionsFromEntity,
|
||||
WagonTypeDimensions,
|
||||
} from './train-capacity.util';
|
||||
import {
|
||||
DEFAULT_BULK_WAGON_LENGTH_METERS,
|
||||
DEFAULT_BULK_WAGON_TARE_TONS,
|
||||
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||||
DEFAULT_CONTAINER_WAGON_TARE_TONS,
|
||||
} from './booking-batch.constants';
|
||||
import {
|
||||
computeExportWindowTimes,
|
||||
@@ -1004,13 +1005,19 @@ export class TrainSchedulingService {
|
||||
throw new BadRequestException('Schedule train set has no locomotives');
|
||||
}
|
||||
// forceAssign lets staff overload the locomotive set knowingly — the
|
||||
// validator has already surfaced it as a warning in that case.
|
||||
if (!dto.forceAssign && limitLoco.maxPullWeightTons < totalWeightTons) {
|
||||
// validator has already surfaced it as a warning in that case. Each
|
||||
// locomotive's overageToleranceTons/Meters extends the hard cap before that
|
||||
// override is even needed (e.g. the fertilizer example's +90T deviation).
|
||||
const weightCapWithOverage =
|
||||
limitLoco.maxPullWeightTons + (Number(limitLoco.overageToleranceTons) || 0);
|
||||
const lengthCapWithOverage =
|
||||
limitLoco.maxTrainLengthMeters + (Number(limitLoco.overageToleranceMeters) || 0);
|
||||
if (!dto.forceAssign && weightCapWithOverage < totalWeightTons) {
|
||||
throw new BadRequestException(
|
||||
`Train set locomotives cannot pull ${totalWeightTons}T`,
|
||||
);
|
||||
}
|
||||
if (!dto.forceAssign && limitLoco.maxTrainLengthMeters < totalLengthMeters) {
|
||||
if (!dto.forceAssign && lengthCapWithOverage < totalLengthMeters) {
|
||||
throw new BadRequestException(
|
||||
`Train set locomotives cannot support ${totalLengthMeters}m`,
|
||||
);
|
||||
@@ -1648,6 +1655,13 @@ export class TrainSchedulingService {
|
||||
* clearance views still reading that milestone (older deployed builds) see
|
||||
* the gate pass as done. Drop once every clearance-api deployment reads
|
||||
* ImportDjiboutiOperation.gatepassGrantedAt directly.
|
||||
*
|
||||
* A booking only earns its gate pass once the customer has settled the freight
|
||||
* charges (FREIGHT_PAYMENT_SETTLED). The gate pass itself is secured per train
|
||||
* schedule, so an unpaid booking must not ride a paid neighbour's grant: it
|
||||
* keeps GATEPASS_GRANTED pending — and therefore cannot upload T1 — while the
|
||||
* train and its paid bookings proceed. Re-securing the gate pass after payment
|
||||
* settles picks the booking up; so does any later call to this bridge.
|
||||
*/
|
||||
private async completeGatepassMilestoneForSchedule(
|
||||
scheduleId: string,
|
||||
@@ -1659,20 +1673,49 @@ export class TrainSchedulingService {
|
||||
if (bookings.length === 0) return;
|
||||
|
||||
const milestoneRepo = this.dataSource.getRepository(ClearanceMilestone);
|
||||
const bookingIds = bookings.map((b) => b.id);
|
||||
const rows = await milestoneRepo.find({
|
||||
where: {
|
||||
bookingId: In(bookings.map((b) => b.id)),
|
||||
milestoneCode: 'GATEPASS_GRANTED',
|
||||
bookingId: In(bookingIds),
|
||||
milestoneCode: In(['GATEPASS_GRANTED', 'FREIGHT_PAYMENT_SETTLED']),
|
||||
},
|
||||
});
|
||||
|
||||
const paidBookingIds = new Set(
|
||||
rows
|
||||
.filter(
|
||||
(r) => r.milestoneCode === 'FREIGHT_PAYMENT_SETTLED' && r.status === 'COMPLETED',
|
||||
)
|
||||
.map((r) => r.bookingId),
|
||||
);
|
||||
// A booking whose payment settled through a path that never wrote the
|
||||
// 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') {
|
||||
paidBookingIds.add(booking.id);
|
||||
}
|
||||
}
|
||||
|
||||
const skipped: string[] = [];
|
||||
for (const row of rows) {
|
||||
if (row.milestoneCode !== 'GATEPASS_GRANTED') continue;
|
||||
if (row.status === 'COMPLETED') continue;
|
||||
if (!row.bookingId || !paidBookingIds.has(row.bookingId)) {
|
||||
skipped.push(row.bookingId ?? '(unknown)');
|
||||
continue;
|
||||
}
|
||||
row.status = 'COMPLETED';
|
||||
row.triggeredAt = securedAt;
|
||||
row.metadata = { ...(row.metadata ?? {}), gatepassAt: securedAt.toISOString() };
|
||||
await milestoneRepo.save(row);
|
||||
}
|
||||
|
||||
if (skipped.length > 0) {
|
||||
this.logger.warn(
|
||||
`Gate pass secured for schedule ${scheduleId}, but ${skipped.length} booking(s) have not settled freight payment and stay pending: ${skipped.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async markImportReadyForLoading(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
|
||||
@@ -1857,7 +1900,7 @@ export class TrainSchedulingService {
|
||||
<td>${esc(wagon.physicalWagon?.wagonNumber)}</td>
|
||||
<td>${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)}</td>
|
||||
<td class="num">${esc(Number(wagon.lengthMeters || 0).toFixed(3))}</td>
|
||||
<td class="num">${esc(Number(wagon.physicalWagon?.tareWeight ?? 0).toFixed(2))}</td>
|
||||
<td class="num">${esc(Number(wagon.wagonType?.tareWeightTons ?? 0).toFixed(2))}</td>
|
||||
<td class="num">${esc(Number(wagon.capacityTons || 0).toFixed(3))}</td>
|
||||
<td>${esc(company?.name ?? company?.legalName ?? company?.tradeName ?? booking?.companyId)}</td>
|
||||
<td>${esc(booking?.companyId)}</td>
|
||||
@@ -2591,7 +2634,8 @@ export class TrainSchedulingService {
|
||||
const schedules = await this.trainSchedulesRepository.findAll({
|
||||
relations: {
|
||||
trainSet: { locomotive: true, locomotives: { locomotive: true } },
|
||||
route: true,
|
||||
// Yards carry the route's display name used by mapScheduleListItem.
|
||||
route: { originYard: true, destinationYard: true },
|
||||
originStation: true,
|
||||
destinationStation: true,
|
||||
scheduleBookings: { booking: true },
|
||||
@@ -2947,8 +2991,10 @@ export class TrainSchedulingService {
|
||||
}
|
||||
if (
|
||||
setLimits &&
|
||||
(setLimits.maxPullWeightTons < totalWeightTons ||
|
||||
setLimits.maxTrainLengthMeters < totalLengthMeters)
|
||||
(setLimits.maxPullWeightTons + (Number(setLimits.overageToleranceTons) || 0) <
|
||||
totalWeightTons ||
|
||||
setLimits.maxTrainLengthMeters + (Number(setLimits.overageToleranceMeters) || 0) <
|
||||
totalLengthMeters)
|
||||
) {
|
||||
pushLimit([
|
||||
'Assigned locomotives cannot support the total train weight and length',
|
||||
@@ -2966,8 +3012,10 @@ export class TrainSchedulingService {
|
||||
if (
|
||||
!inServiceLocomotives.some(
|
||||
(l) =>
|
||||
Number(l.maxPullWeightTons) >= totalWeightTons &&
|
||||
Number(l.maxTrainLengthMeters) >= totalLengthMeters,
|
||||
Number(l.maxPullWeightTons) + (Number(l.overageToleranceTons) || 0) >=
|
||||
totalWeightTons &&
|
||||
Number(l.maxTrainLengthMeters) + (Number(l.overageToleranceMeters) || 0) >=
|
||||
totalLengthMeters,
|
||||
)
|
||||
) {
|
||||
pushLimit(['No locomotive can support the total train weight and length']);
|
||||
@@ -3022,7 +3070,10 @@ export class TrainSchedulingService {
|
||||
maxTrainLengthMeters?: number;
|
||||
maxWagonsPerTrain?: number;
|
||||
},
|
||||
locomotive?: Pick<Locomotive, 'maxPullWeightTons' | 'maxTrainLengthMeters'>,
|
||||
locomotive?: Pick<
|
||||
Locomotive,
|
||||
'maxPullWeightTons' | 'maxTrainLengthMeters' | 'overageToleranceTons' | 'overageToleranceMeters'
|
||||
>,
|
||||
): Promise<Required<TrainLimitConfig>> {
|
||||
const row = await this.loadGlobalRulesRow();
|
||||
const configured = this.configService?.get<{
|
||||
@@ -3049,6 +3100,8 @@ export class TrainSchedulingService {
|
||||
{
|
||||
maxPullWeightTons: Number(locomotive.maxPullWeightTons),
|
||||
maxTrainLengthMeters: Number(locomotive.maxTrainLengthMeters),
|
||||
overageToleranceTons: Number(locomotive.overageToleranceTons) || 0,
|
||||
overageToleranceMeters: Number(locomotive.overageToleranceMeters) || 0,
|
||||
},
|
||||
wagonTypes,
|
||||
{
|
||||
@@ -3112,16 +3165,27 @@ export class TrainSchedulingService {
|
||||
};
|
||||
}
|
||||
|
||||
private async loadSchedulingWagonTypeDimensions(): Promise<
|
||||
Array<{ lengthMeters: number; capacityTons: number }>
|
||||
> {
|
||||
const types = await this.dataSource.getRepository(WagonType).find({
|
||||
where: [{ code: 'NW5' }, { code: 'CW3' }],
|
||||
});
|
||||
/**
|
||||
* Every active wagon type: the slot count derives from the shortest wagon the
|
||||
* fleet can marshal, so sampling only NW5/CW3 would miss a shorter type (GW2 at
|
||||
* 12.228m) and under-report how many wagons the train length allows.
|
||||
*/
|
||||
private async loadSchedulingWagonTypeDimensions(): Promise<WagonTypeDimensions[]> {
|
||||
const types = await this.dataSource
|
||||
.getRepository(WagonType)
|
||||
.find({ where: { isActive: true } });
|
||||
if (types.length) return types.map(wagonTypeDimensionsFromEntity);
|
||||
return [
|
||||
{ lengthMeters: DEFAULT_CONTAINER_WAGON_LENGTH_METERS, capacityTons: 70 },
|
||||
{ lengthMeters: DEFAULT_BULK_WAGON_LENGTH_METERS, capacityTons: 60 },
|
||||
{
|
||||
lengthMeters: DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||||
capacityTons: 70,
|
||||
tareWeightTons: DEFAULT_CONTAINER_WAGON_TARE_TONS,
|
||||
},
|
||||
{
|
||||
lengthMeters: DEFAULT_BULK_WAGON_LENGTH_METERS,
|
||||
capacityTons: 60,
|
||||
tareWeightTons: DEFAULT_BULK_WAGON_TARE_TONS,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -3431,37 +3495,6 @@ export class TrainSchedulingService {
|
||||
return wagonType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft wagon-type resolution for the customer-facing availability preview
|
||||
* (getAvailableDaysForCargo). Reads the configured FK by cargo/container type;
|
||||
* returns null (→ "no days") instead of throwing when nothing is configured,
|
||||
* since this only estimates which days have wagons and creates no booking.
|
||||
*/
|
||||
private async resolveWagonTypeForPreview(
|
||||
freightType: 'CONTAINER' | 'BULK',
|
||||
cargoTypeCode: string | null,
|
||||
): Promise<WagonType | null> {
|
||||
if (freightType === 'BULK') {
|
||||
if (!cargoTypeCode) return null;
|
||||
const cargoType = await this.dataSource.getRepository(CargoType).findOne({
|
||||
where: { code: cargoTypeCode },
|
||||
relations: { wagonType: true },
|
||||
});
|
||||
return cargoType?.wagonType?.isActive ? cargoType.wagonType : null;
|
||||
}
|
||||
|
||||
// Container preview: the input carries no specific container type, so use the
|
||||
// wagon type of the first configured (active) container type.
|
||||
const containerType = await this.dataSource
|
||||
.getRepository(ContainerType)
|
||||
.findOne({
|
||||
where: { isActive: true, wagonTypeId: Not(IsNull()) },
|
||||
relations: { wagonType: true },
|
||||
order: { displayOrder: 'ASC' },
|
||||
});
|
||||
return containerType?.wagonType?.isActive ? containerType.wagonType : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp each plan slot with the leg it occupies (dynamic consist): the
|
||||
* boarding/alighting yards of the bookings it carries. Null means the
|
||||
@@ -3719,10 +3752,17 @@ export class TrainSchedulingService {
|
||||
if (locomotive.status !== 'AVAILABLE') {
|
||||
throw new BadRequestException(`Locomotive ${locomotive.code} is not available`);
|
||||
}
|
||||
if (Number(locomotive.maxPullWeightTons) < totalWeightTons) {
|
||||
if (
|
||||
Number(locomotive.maxPullWeightTons) + (Number(locomotive.overageToleranceTons) || 0) <
|
||||
totalWeightTons
|
||||
) {
|
||||
throw new BadRequestException(`Locomotive ${locomotive.code} cannot pull ${totalWeightTons}T`);
|
||||
}
|
||||
if (Number(locomotive.maxTrainLengthMeters) < totalLengthMeters) {
|
||||
if (
|
||||
Number(locomotive.maxTrainLengthMeters) +
|
||||
(Number(locomotive.overageToleranceMeters) || 0) <
|
||||
totalLengthMeters
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`Locomotive ${locomotive.code} cannot support ${totalLengthMeters}m`,
|
||||
);
|
||||
@@ -4215,13 +4255,14 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Cargo-aware day pool: the EAT days that are actually FEASIBLE for the given
|
||||
* cargo. A day is selectable only when ≥1 OPEN schedule on the route that day
|
||||
* has BOTH (a) enough AVAILABLE wagons of the cargo's matching type at that
|
||||
* schedule's origin yard, and (b) remaining train capacity (not fully
|
||||
* allocated). Days with trains but not enough matching wagons are excluded.
|
||||
* Same `{ days: string[] }` shape as getAvailableDays — the customer still
|
||||
* picks a DAY, not a train.
|
||||
* Cargo-aware day pool: the EAT days a customer may pick for this cargo. A day
|
||||
* is selectable when ≥1 OPEN schedule on the route that day still has remaining
|
||||
* train capacity (not fully allocated). Wagon availability is deliberately NOT
|
||||
* checked here: whether a matching wagon currently sits in the right yard is an
|
||||
* operational question staff resolve when they approve or reject the booking,
|
||||
* not something the customer can act on while choosing a date. Same
|
||||
* `{ days: string[] }` shape as getAvailableDays — the customer picks a DAY,
|
||||
* not a train.
|
||||
*/
|
||||
async getAvailableDaysForCargo(input: {
|
||||
originYardId?: string;
|
||||
@@ -4237,85 +4278,17 @@ export class TrainSchedulingService {
|
||||
);
|
||||
if (schedules.length === 0) return { days: [] };
|
||||
|
||||
// Resolve the wagon type this cargo needs via the cargo/container-type FK.
|
||||
// Soft (customer availability preview): no days if unresolved, never throws.
|
||||
const requiredType = await this.resolveWagonTypeForPreview(
|
||||
input.freightType,
|
||||
input.cargoTypeCode ?? null,
|
||||
);
|
||||
if (!requiredType) return { days: [] };
|
||||
|
||||
// How many wagons of that type the cargo needs.
|
||||
const slotsNeeded = this.wagonsNeededForCargo(input, requiredType);
|
||||
void slotsNeeded; // TEMP: unused while the wagon-availability filter is off.
|
||||
|
||||
// TEMP (per request): wagon-availability filtering is DISABLED. A day is now
|
||||
// offered whenever a bookable schedule that day has remaining train capacity
|
||||
// — regardless of whether matching wagons are actually available at the
|
||||
// origin / boarding yard. This surfaces days even when no wagon is on hand.
|
||||
// Restore the block below to bring back the "enough matching wagons" gate.
|
||||
//
|
||||
// // AVAILABLE wagons of the required type, counted once per origin yard.
|
||||
// const availableByYard = new Map<string, number>();
|
||||
// const availableAt = async (yardId: string): Promise<number> => {
|
||||
// const cached = availableByYard.get(yardId);
|
||||
// if (cached !== undefined) return cached;
|
||||
// const counts = await this.countFleetAvailability(yardId);
|
||||
// const n =
|
||||
// counts.find((c) => c.wagonTypeId === requiredType.id)?.available ?? 0;
|
||||
// availableByYard.set(yardId, n);
|
||||
// return n;
|
||||
// };
|
||||
|
||||
const days = new Set<string>();
|
||||
for (const s of schedules) {
|
||||
const hasCapacity =
|
||||
Math.max(0, (s.maxWagons ?? 0) - (s.trainSet?.wagonCount ?? 0)) > 0;
|
||||
if (!hasCapacity) continue;
|
||||
// TEMP (per request): wagon-availability check commented out — see note
|
||||
// above. Dynamic consist: wagons may ride from the train's origin OR
|
||||
// already sit at the booking's own boarding yard and attach when the train
|
||||
// arrives — either pool can serve a sub-corridor booking.
|
||||
// let enoughWagons = (await availableAt(s.originStationId)) >= slotsNeeded;
|
||||
// if (
|
||||
// !enoughWagons &&
|
||||
// input.originYardId &&
|
||||
// input.originYardId !== s.originStationId
|
||||
// ) {
|
||||
// enoughWagons = (await availableAt(input.originYardId)) >= slotsNeeded;
|
||||
// }
|
||||
// if (!enoughWagons) continue;
|
||||
if (s.scheduledDepartureDate)
|
||||
days.add(eatDay(new Date(s.scheduledDepartureDate)));
|
||||
}
|
||||
return { days: [...days].sort() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wagons needed for a cargo (pre-booking estimate). BULK: ceil(weight /
|
||||
* capacity). CONTAINER: TEU packing — 40ft = 2 TEU, 20ft = 1 TEU, 2 TEU per
|
||||
* wagon. Mirrors wagon-plan.util without fabricating Booking entities.
|
||||
*/
|
||||
private wagonsNeededForCargo(
|
||||
input: {
|
||||
freightType: 'CONTAINER' | 'BULK';
|
||||
totalWeightTons?: number;
|
||||
containers?: Array<{ containerSize: string; quantity: number }>;
|
||||
},
|
||||
wagonType: WagonType,
|
||||
): number {
|
||||
if (input.freightType === 'BULK') {
|
||||
const capacity = Number(wagonType.capacityTons) || 1;
|
||||
const weight = Number(input.totalWeightTons ?? 0);
|
||||
return Math.max(1, Math.ceil(weight / capacity));
|
||||
}
|
||||
const teu = (input.containers ?? []).reduce((sum, c) => {
|
||||
const per = c.containerSize === '40ft' ? 2 : 1;
|
||||
return sum + per * Math.max(0, Number(c.quantity ?? 0));
|
||||
}, 0);
|
||||
return Math.max(1, Math.ceil(teu / 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered stop yards of a schedule's route: origin → milestones → destination,
|
||||
* de-duplicated. Falls back to the two-endpoint pseudo-route when the schedule
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
buildBulkWagonPlan,
|
||||
buildContainerWagonPlan,
|
||||
buildMixedWagonPlan,
|
||||
containerWagonsForLines,
|
||||
expandBookingContainerUnits,
|
||||
expandContainerItems,
|
||||
roundTons,
|
||||
@@ -20,7 +21,6 @@ const nw5: WagonType = {
|
||||
name: 'Flat Wagon',
|
||||
capacityTons: 70,
|
||||
lengthMeters: 14,
|
||||
maxWagonsPerTrain: 53,
|
||||
supportedLoadTypes: ['CONTAINER'],
|
||||
isActive: true,
|
||||
supportsContainer: true,
|
||||
@@ -32,7 +32,6 @@ const cw3: WagonType = {
|
||||
name: 'Covered Wagon',
|
||||
capacityTons: 60,
|
||||
lengthMeters: 14,
|
||||
maxWagonsPerTrain: 53,
|
||||
supportedLoadTypes: ['BULK'],
|
||||
isActive: true,
|
||||
supportsContainer: false,
|
||||
@@ -200,3 +199,60 @@ describe('wagon-plan.util', () => {
|
||||
expect(buildBulkWagonPlan([bulkBooking], cw3)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('containerWagonsForLines — TEU-aware, ceil booking total once', () => {
|
||||
const line = (quantity: number, wagonsPerUnit: number, wagonsRequired?: number) => ({
|
||||
quantity,
|
||||
wagonsRequired: wagonsRequired ?? quantity * wagonsPerUnit,
|
||||
containerType: { wagonsPerUnit, sizeFt: wagonsPerUnit >= 1 ? 40 : 20 },
|
||||
});
|
||||
|
||||
it('20×20ft = 10 wagons (not 20)', () => {
|
||||
expect(containerWagonsForLines([line(20, 0.5)])).toBe(10);
|
||||
});
|
||||
|
||||
it('38×20ft = 19 wagons', () => {
|
||||
expect(containerWagonsForLines([line(38, 0.5)])).toBe(19);
|
||||
});
|
||||
|
||||
it('2×20ft = 1 wagon', () => {
|
||||
expect(containerWagonsForLines([line(2, 0.5)])).toBe(1);
|
||||
});
|
||||
|
||||
it('odd 3×20ft = 2 wagons (single line ceils)', () => {
|
||||
expect(containerWagonsForLines([line(3, 0.5)])).toBe(2);
|
||||
});
|
||||
|
||||
it('3×20ft + 3×20ft = 3 wagons (ceil TOTAL, not per line)', () => {
|
||||
// per-line ceil would give 2 + 2 = 4; the booking total is ceil(1.5+1.5)=3.
|
||||
expect(containerWagonsForLines([line(3, 0.5), line(3, 0.5)])).toBe(3);
|
||||
});
|
||||
|
||||
it('three 1×20ft lines = 2 wagons (ceil TOTAL)', () => {
|
||||
// per-line ceil would give 1+1+1 = 3; total is ceil(0.5*3)=ceil(1.5)=2.
|
||||
expect(
|
||||
containerWagonsForLines([line(1, 0.5), line(1, 0.5), line(1, 0.5)]),
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it('5×20ft + 2×40ft = 5 wagons', () => {
|
||||
expect(containerWagonsForLines([line(5, 0.5), line(2, 1)])).toBe(5);
|
||||
});
|
||||
|
||||
it('21×40ft = 21 wagons', () => {
|
||||
expect(containerWagonsForLines([line(21, 1)])).toBe(21);
|
||||
});
|
||||
|
||||
it('falls back to line wagonsRequired when containerType/wagonsPerUnit missing', () => {
|
||||
// No containerType relation loaded → use the stored (0.5-aware) fraction.
|
||||
expect(
|
||||
containerWagonsForLines([
|
||||
{ quantity: 20, wagonsRequired: 10 } as never,
|
||||
]),
|
||||
).toBe(10);
|
||||
});
|
||||
|
||||
it('empty line set = 0 wagons', () => {
|
||||
expect(containerWagonsForLines([])).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { AllocationLoadType } from '@edr/types';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { consistViolations } from './train-capacity.util';
|
||||
|
||||
export const MAX_TRAIN_WEIGHT_TONS = 3500;
|
||||
export const MAX_TRAIN_LENGTH_METERS = 760;
|
||||
@@ -35,6 +36,9 @@ export type WagonPlanSlot = {
|
||||
wagonTypeCode: string;
|
||||
capacityTons: number;
|
||||
lengthMeters: number;
|
||||
/** Empty weight of this wagon — the locomotive pulls it whether or not it is loaded. */
|
||||
tareWeightTons: number;
|
||||
/** Cargo tons on this wagon. Gross weight = tareWeightTons + assignedWeightTons. */
|
||||
assignedWeightTons: number;
|
||||
allocations: WagonAllocationRecord[];
|
||||
slotLoadType?: SlotLoadType;
|
||||
@@ -78,6 +82,14 @@ export function roundTons(value: number | string | null | undefined): number {
|
||||
return Number(numericValue.toFixed(3));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tare of a wagon type. Nullable only on rows predating the NOT NULL backfill;
|
||||
* a missing tare must read as 0 rather than silently inventing dead weight.
|
||||
*/
|
||||
export function tareTonsOf(wagonType: Pick<WagonType, 'tareWeightTons'>): number {
|
||||
return roundTons(wagonType.tareWeightTons ?? 0);
|
||||
}
|
||||
|
||||
/** TEU slots on a wagon: 40ft = 2, 20ft = 1 (max 2 TEU / wagon). */
|
||||
export function teuSlotsForSizeFt(sizeFt: number): number {
|
||||
return sizeFt >= 40 ? 2 : 1;
|
||||
@@ -89,18 +101,38 @@ export function containersPerWagonFromType(wagonsPerUnit: number): number {
|
||||
return Math.max(1, Math.round(1 / wpu));
|
||||
}
|
||||
|
||||
function lineWagonsRequired(line: {
|
||||
type ContainerLine = {
|
||||
quantity?: number | null;
|
||||
wagonsRequired?: number | null;
|
||||
containerType?: { wagonsPerUnit?: number | null; sizeFt?: number | null } | null;
|
||||
}): number {
|
||||
};
|
||||
|
||||
/**
|
||||
* RAW (un-ceiled) wagon fraction one container line occupies: qty × wagonsPerUnit
|
||||
* (40ft = 1, 20ft = 0.5). Two 20ft = 1.0, three 20ft = 1.5. Kept fractional so
|
||||
* the BOOKING total is ceiled once — ceiling per line over-counts a booking that
|
||||
* splits its 20ft units across several lines (3×20 + 3×20 = 3 wagons, not 4).
|
||||
*/
|
||||
function lineWagonsRaw(line: ContainerLine): number {
|
||||
const qty = Number(line.quantity ?? 0);
|
||||
if (qty <= 0) return 0;
|
||||
const wpu = Number(line.containerType?.wagonsPerUnit);
|
||||
if (Number.isFinite(wpu) && wpu > 0) {
|
||||
return Math.ceil(qty * wpu);
|
||||
return qty * wpu;
|
||||
}
|
||||
return Math.max(1, Math.ceil(Number(line.wagonsRequired ?? 1)));
|
||||
// No wagonsPerUnit on the type: fall back to the line's stored fraction, else
|
||||
// treat the whole line as one wagon.
|
||||
const stored = Number(line.wagonsRequired);
|
||||
return Number.isFinite(stored) && stored > 0 ? stored : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole wagons a set of container lines needs: ceil the summed RAW fraction so a
|
||||
* half-full 20ft wagon rounds up ONCE at the booking level. Empty set → 0.
|
||||
*/
|
||||
export function containerWagonsForLines(lines: ContainerLine[]): number {
|
||||
const raw = lines.reduce((sum, line) => sum + lineWagonsRaw(line), 0);
|
||||
return raw > 0 ? Math.ceil(raw) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -110,21 +142,23 @@ export function buildContainerWagonPlan(
|
||||
bookings: Booking[],
|
||||
wagonType: WagonType,
|
||||
): WagonPlanSlot[] {
|
||||
// Whole wagons PER BOOKING (ceil each booking's total TEU once — a 20ft unit
|
||||
// can share a wagon with another 20ft of the SAME booking, never across
|
||||
// bookings), then sum. Ceiling per line instead would over-count a booking
|
||||
// that splits its 20ft units across several lines.
|
||||
const totalSlots = bookings.reduce((sum, booking) => {
|
||||
const lineSlots = (booking.bookingContainers ?? []).reduce(
|
||||
(lineSum, line) => lineSum + lineWagonsRequired(line),
|
||||
0,
|
||||
);
|
||||
return sum + Math.max(lineSlots, 1);
|
||||
const bookingSlots = containerWagonsForLines(booking.bookingContainers ?? []);
|
||||
return sum + Math.max(bookingSlots, 1);
|
||||
}, 0);
|
||||
|
||||
const slots = Math.max(1, Math.ceil(totalSlots));
|
||||
const slots = Math.max(1, totalSlots);
|
||||
const basePlan: WagonPlanSlot[] = Array.from({ length: slots }, (_, index) => ({
|
||||
sequenceNo: index + 1,
|
||||
wagonTypeId: wagonType.id,
|
||||
wagonTypeCode: wagonType.code,
|
||||
capacityTons: Number(wagonType.capacityTons),
|
||||
lengthMeters: Number(wagonType.lengthMeters),
|
||||
tareWeightTons: tareTonsOf(wagonType),
|
||||
assignedWeightTons: 0,
|
||||
allocations: [],
|
||||
}));
|
||||
@@ -154,6 +188,7 @@ export function buildBulkWagonPlan(
|
||||
wagonTypeCode: wagonType.code,
|
||||
capacityTons: capacity,
|
||||
lengthMeters: Number(wagonType.lengthMeters),
|
||||
tareWeightTons: tareTonsOf(wagonType),
|
||||
assignedWeightTons: 0,
|
||||
allocations: [],
|
||||
}));
|
||||
@@ -193,6 +228,7 @@ export function buildMixedWagonPlan(
|
||||
wagonTypeCode: containerWagonType.code,
|
||||
capacityTons: Number(containerWagonType.capacityTons),
|
||||
lengthMeters: Number(containerWagonType.lengthMeters),
|
||||
tareWeightTons: tareTonsOf(containerWagonType),
|
||||
assignedWeightTons: 0,
|
||||
allocations: [],
|
||||
slotLoadType: 'CONTAINER',
|
||||
@@ -422,47 +458,46 @@ export function validateBulkWagonSlotWeights(wagonPlan: WagonPlanSlot[]): string
|
||||
return violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a consist against its train's three limits. Weight is GROSS — every slot
|
||||
* contributes its own tare plus the cargo assigned to it — because the locomotive
|
||||
* pull limit governs what it drags, not what was sold. Length and tare are summed
|
||||
* per slot, so a mixed consist is measured as it actually stands rather than
|
||||
* through one representative wagon type.
|
||||
*
|
||||
* `wagonType` only supplies the fallback wagon count when `limits.maxWagonsPerTrain`
|
||||
* is absent; slot dimensions always win over it.
|
||||
*/
|
||||
export function validateTrainLimits(
|
||||
wagonPlan: WagonPlanSlot[],
|
||||
wagonType: WagonType,
|
||||
wagonType: Pick<WagonType, 'lengthMeters'>,
|
||||
limits?: TrainLimitConfig,
|
||||
): string[] {
|
||||
const violations: string[] = [];
|
||||
const maxWeightTons = limits?.maxWeightTons ?? MAX_TRAIN_WEIGHT_TONS;
|
||||
const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS;
|
||||
const wagonLength = Number(wagonType.lengthMeters) || 14;
|
||||
const maxWagonsPerTrain =
|
||||
limits?.maxWagonsPerTrain ??
|
||||
Math.floor(maxLengthMeters / wagonLength);
|
||||
const maxWagonSlots =
|
||||
limits?.maxWagonsPerTrain ?? Math.floor(maxLengthMeters / wagonLength);
|
||||
|
||||
const totalWeightTons = roundTons(
|
||||
wagonPlan.reduce((sum, w) => sum + w.assignedWeightTons, 0),
|
||||
const violations = consistViolations(
|
||||
wagonPlan.map((slot) => ({
|
||||
lengthMeters: Number(slot.lengthMeters),
|
||||
tareWeightTons: Number(slot.tareWeightTons ?? 0),
|
||||
cargoTons: Number(slot.assignedWeightTons),
|
||||
})),
|
||||
{ maxWeightTons, maxLengthMeters, maxWagonSlots },
|
||||
);
|
||||
const totalLengthMeters = roundTons(
|
||||
wagonPlan.reduce((sum, w) => sum + w.lengthMeters, 0),
|
||||
);
|
||||
|
||||
if (totalWeightTons > maxWeightTons) {
|
||||
violations.push(
|
||||
`Total booking weight ${totalWeightTons}T exceeds max train weight ${maxWeightTons}T`,
|
||||
);
|
||||
}
|
||||
if (totalLengthMeters > maxLengthMeters) {
|
||||
violations.push(
|
||||
`Total wagon length ${totalLengthMeters}m exceeds max train length ${maxLengthMeters}m`,
|
||||
);
|
||||
}
|
||||
if (wagonPlan.length > maxWagonsPerTrain) {
|
||||
violations.push(
|
||||
`Wagon count ${wagonPlan.length} exceeds max wagons per train (${maxWagonsPerTrain})`,
|
||||
);
|
||||
}
|
||||
|
||||
violations.push(...validateBulkWagonSlotWeights(wagonPlan));
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mixed consist: the wagon-count fallback uses the shortest type present, since
|
||||
* that is the most wagons that could ever fit. Weight and length still come from
|
||||
* the slots themselves.
|
||||
*/
|
||||
export function validateMixedTrainLimits(
|
||||
wagonPlan: WagonPlanSlot[],
|
||||
wagonTypes: WagonType[],
|
||||
@@ -478,7 +513,7 @@ export function validateMixedTrainLimits(
|
||||
|
||||
return validateTrainLimits(
|
||||
wagonPlan,
|
||||
{ maxWagonsPerTrain } as WagonType,
|
||||
{ lengthMeters: minWagonLength },
|
||||
{ ...limits, maxWagonsPerTrain },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Transform } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
@@ -14,9 +13,6 @@ import {
|
||||
const toNumber = ({ value }: { value: unknown }) =>
|
||||
value === '' || value == null ? value : Number(value);
|
||||
|
||||
const toOptionalNumber = ({ value }: { value: unknown }) =>
|
||||
value === '' || value == null ? undefined : Number(value);
|
||||
|
||||
const toBoolean = ({ value }: { value: unknown }) => {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (value === 'true') return true;
|
||||
@@ -60,12 +56,16 @@ export class CreateWagonTypeDto {
|
||||
@Min(0.001)
|
||||
lengthMeters!: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Maximum wagons of this type per train', example: 53 })
|
||||
@IsOptional()
|
||||
@Transform(toOptionalNumber)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
maxWagonsPerTrain?: number;
|
||||
@ApiProperty({
|
||||
description:
|
||||
'Empty (unladen) wagon weight in metric tons. Required: the locomotive pull ' +
|
||||
'limit applies to gross weight (tare + cargo), so capacity cannot be computed without it.',
|
||||
example: 22.4,
|
||||
})
|
||||
@Transform(toNumber)
|
||||
@IsNumber()
|
||||
@Min(0.001)
|
||||
tareWeightTons!: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Supported load types, e.g. CONTAINER,BULK',
|
||||
|
||||
@@ -19,9 +19,6 @@ export class WagonType extends BaseEntity {
|
||||
@Column({ name: 'length_meters', type: 'numeric', precision: 10, scale: 3 })
|
||||
lengthMeters!: number;
|
||||
|
||||
@Column({ name: 'max_wagons_per_train', type: 'int', nullable: true })
|
||||
maxWagonsPerTrain?: number | null;
|
||||
|
||||
@Column({ name: 'supported_load_types', type: 'text', array: true, default: '{}' })
|
||||
supportedLoadTypes!: string[];
|
||||
|
||||
@@ -31,8 +28,9 @@ export class WagonType extends BaseEntity {
|
||||
@Column({ name: 'equated_length_m', type: 'numeric', precision: 10, scale: 3, nullable: true })
|
||||
equatedLengthM?: number | null;
|
||||
|
||||
@Column({ name: 'tare_weight_tons', type: 'numeric', precision: 10, scale: 3, nullable: true })
|
||||
tareWeightTons?: number | null;
|
||||
/** Empty wagon weight. Required: the locomotive's pull limit is a gross limit. */
|
||||
@Column({ name: 'tare_weight_tons', type: 'numeric', precision: 10, scale: 3 })
|
||||
tareWeightTons!: number;
|
||||
|
||||
@Column({ name: 'supports_container', type: 'boolean', default: false })
|
||||
supportsContainer!: boolean;
|
||||
|
||||
@@ -80,7 +80,7 @@ export class WagonTypesService {
|
||||
name: dto.name.trim(),
|
||||
capacityTons: dto.capacityTons,
|
||||
lengthMeters: dto.lengthMeters,
|
||||
maxWagonsPerTrain: dto.maxWagonsPerTrain ?? null,
|
||||
tareWeightTons: dto.tareWeightTons ?? null,
|
||||
supportedLoadTypes: dto.supportedLoadTypes ?? [],
|
||||
isActive: dto.isActive ?? true,
|
||||
});
|
||||
@@ -101,8 +101,6 @@ export class WagonTypesService {
|
||||
...dto,
|
||||
...(nextCode ? { code: nextCode } : {}),
|
||||
...(dto.name ? { name: dto.name.trim() } : {}),
|
||||
maxWagonsPerTrain:
|
||||
dto.maxWagonsPerTrain === undefined ? undefined : dto.maxWagonsPerTrain ?? null,
|
||||
supportedLoadTypes: dto.supportedLoadTypes ?? undefined,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { WagonStatus } from '@edr/types';
|
||||
import { IsString, IsUUID, IsOptional, IsInt, Min, IsNumber, IsEnum } from 'class-validator';
|
||||
import { IsString, IsUUID, IsOptional, IsInt, Min, IsEnum } from 'class-validator';
|
||||
|
||||
export class CreateWagonDto {
|
||||
@IsString()
|
||||
@@ -17,13 +17,8 @@ export class CreateWagonDto {
|
||||
@Min(1)
|
||||
sequenceNumber?: number;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
tareWeight!: number;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxPayloadWeight!: number;
|
||||
// Tare weight and payload capacity are not accepted here: they belong to the
|
||||
// wagon type and are resolved through wagonTypeId.
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(WagonStatus)
|
||||
|
||||
@@ -7,6 +7,7 @@ import { TrainSchedule } from '../../train-schedules/entities/train-schedule.ent
|
||||
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
|
||||
import { Container } from '../../container-management/entities/container.entity';
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||
|
||||
export const WAGON_STATUSES = [
|
||||
WagonStatus.Available,
|
||||
@@ -28,17 +29,19 @@ export class Wagon extends BaseEntity {
|
||||
@Column({ name: 'wagon_type_id', type: 'uuid' })
|
||||
wagonTypeId!: string;
|
||||
|
||||
/** Owns this wagon's spec: tare weight, payload capacity, length. */
|
||||
@ManyToOne(() => WagonType)
|
||||
@JoinColumn({ name: 'wagon_type_id' })
|
||||
wagonType?: WagonType;
|
||||
|
||||
@Column({ name: 'train_id', type: 'uuid', nullable: true })
|
||||
trainId!: string | null;
|
||||
|
||||
@Column({ name: 'sequence_number', type: 'int', nullable: true })
|
||||
sequenceNumber!: number | null;
|
||||
|
||||
@Column({ name: 'tare_weight', type: 'decimal', precision: 10, scale: 2 })
|
||||
tareWeight!: number;
|
||||
|
||||
@Column({ name: 'max_payload_weight', type: 'decimal', precision: 10, scale: 2 })
|
||||
maxPayloadWeight!: number;
|
||||
// Tare weight and payload capacity are properties of the wagon TYPE — read them
|
||||
// through `wagonType`, never off the individual wagon.
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: WagonStatus.Available })
|
||||
status!: WagonStatusType;
|
||||
|
||||
@@ -52,14 +52,23 @@ export class WagonsService {
|
||||
});
|
||||
}
|
||||
|
||||
const sortBy = ['wagonNumber', 'tareWeight', 'maxPayloadWeight', 'status', 'currentYardId', 'sequenceNumber'].includes(query.sortBy ?? '')
|
||||
// Spec columns (tare, payload) are no longer sortable here — they live on the
|
||||
// wagon type, so sorting by them is sorting by wagonTypeId.
|
||||
const sortable: Array<keyof Wagon> = [
|
||||
'wagonNumber',
|
||||
'status',
|
||||
'currentYardId',
|
||||
'sequenceNumber',
|
||||
'wagonTypeId',
|
||||
];
|
||||
const sortBy = sortable.includes((query.sortBy ?? '') as keyof Wagon)
|
||||
? (query.sortBy as keyof Wagon)
|
||||
: 'wagonNumber';
|
||||
const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
||||
|
||||
return this.wagonRepo.find({
|
||||
where: search ? where : filters,
|
||||
relations: { currentYard: true },
|
||||
relations: { currentYard: true, wagonType: true },
|
||||
order: { [sortBy]: sortOrder } as FindOptionsOrder<Wagon>,
|
||||
skip: query.page && query.limit ? (Number(query.page) - 1) * Number(query.limit) : undefined,
|
||||
take: query.limit ? Number(query.limit) : undefined,
|
||||
@@ -69,7 +78,7 @@ export class WagonsService {
|
||||
async findById(id: string): Promise<Wagon> {
|
||||
const wagon = await this.wagonRepo.findOne({
|
||||
where: { id },
|
||||
relations: { currentYard: true },
|
||||
relations: { currentYard: true, wagonType: true },
|
||||
});
|
||||
if (!wagon) throw new NotFoundException(`Wagon ${id} not found`);
|
||||
return wagon;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, Matches, MaxLength, Min } from 'class-validator';
|
||||
|
||||
import { WAREHOUSE_TYPES, WarehouseType } from '../entities/warehouse.entity';
|
||||
import { WAREHOUSE_STATUSES, WAREHOUSE_TYPES, WarehouseStatus, WarehouseType } from '../entities/warehouse.entity';
|
||||
|
||||
export class CreateWarehouseDto {
|
||||
@ApiProperty()
|
||||
@@ -58,4 +58,9 @@ export class CreateWarehouseDto {
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxVolume?: number;
|
||||
|
||||
@ApiPropertyOptional({ enum: WAREHOUSE_STATUSES, default: 'ACTIVE' })
|
||||
@IsOptional()
|
||||
@IsEnum(WAREHOUSE_STATUSES)
|
||||
status?: WarehouseStatus;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
/**
|
||||
* Optional explicit storage location. When warehouse/yard/zone are all provided,
|
||||
* the item is stored there directly; otherwise store() falls back to the
|
||||
* allocation-rule / capacity-balanced auto pick.
|
||||
*/
|
||||
export class StoreInventoryDto {
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
warehouseId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
yardId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
zoneId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
performedBy?: string;
|
||||
}
|
||||
@@ -34,7 +34,9 @@ export const WAREHOUSE_INVENTORY_TRANSITIONS: Record<WarehouseInventoryStatus, W
|
||||
UNLOADED: ['STORED', 'READY_FOR_PICKUP'],
|
||||
UNLOADED_AT_DJIBOUTI_PORT: [],
|
||||
RECEIVED: ['STORED', 'READY_FOR_PICKUP'],
|
||||
STORED: ['RESERVED'],
|
||||
// Reserve is retired from the operator flow — a stored export item advances
|
||||
// straight to loading prep. RESERVED kept for any in-flight/legacy items.
|
||||
STORED: ['RESERVED', 'READY_FOR_LOADING'],
|
||||
RESERVED: ['READY_FOR_LOADING'],
|
||||
READY_FOR_LOADING: ['LOADED'],
|
||||
LOADED: ['DISPATCHED'],
|
||||
|
||||
@@ -4,6 +4,8 @@ import { DataSource, EntityManager, IsNull } from 'typeorm';
|
||||
|
||||
import { BookingHandover } from './entities/booking-handover.entity';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { sendCompanyChannels } from '../notifications/notify-company.util';
|
||||
|
||||
/**
|
||||
* Import handover records. A booking has one handover per truck (single truck ⇒
|
||||
@@ -18,6 +20,7 @@ export class HandoverService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
|
||||
/** Tell the customer a handover is ready and needs their signature. */
|
||||
@@ -28,15 +31,17 @@ export class HandoverService {
|
||||
[bookingId],
|
||||
);
|
||||
if (!b?.companyId) return;
|
||||
const body = `Your import handover ${reference} for booking ${b.reference} is ready. Please review and sign it from the portal before the truck leaves.`;
|
||||
await this.inbox.notify({
|
||||
recipients: { companyId: b.companyId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.DOCUMENT_ACTION,
|
||||
title: 'Handover — signature needed',
|
||||
body: `Your import handover ${reference} for booking ${b.reference} is ready. Please review and sign it from the portal before the truck leaves.`,
|
||||
body,
|
||||
link: `/bookings/${bookingId}`,
|
||||
data: { bookingId, reference },
|
||||
});
|
||||
await sendCompanyChannels(this.dataSource, this.notifications, b.companyId, body);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to notify handover sign for ${bookingId}: ${(err as Error).message}`);
|
||||
}
|
||||
@@ -49,6 +54,32 @@ export class HandoverService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the customer to sign the booking's handover. Ensures a handover exists
|
||||
* (creates a booking-level self-haul one if none yet), then fires the
|
||||
* sign-needed notification (in-app + SMS + email). Idempotent to re-send.
|
||||
*/
|
||||
async requestSignature(
|
||||
bookingId: string,
|
||||
): Promise<{ notified: boolean; reference: string | null; alreadySigned: boolean }> {
|
||||
const repo = this.dataSource.getRepository(BookingHandover);
|
||||
const existing = await repo.find({ where: { bookingId }, order: { generatedAt: 'ASC' } });
|
||||
|
||||
if (existing.length === 0) {
|
||||
// No handover yet (truck not arrived): create a booking-level one so the
|
||||
// customer has something to sign. ensureForArrivedTruck notifies on create.
|
||||
const created = await this.ensureForArrivedTruck(bookingId, {});
|
||||
return { notified: true, reference: created.reference, alreadySigned: false };
|
||||
}
|
||||
|
||||
const unsigned = existing.find((h) => !h.signedAt);
|
||||
if (!unsigned) {
|
||||
return { notified: false, reference: existing[0].reference, alreadySigned: true };
|
||||
}
|
||||
await this.notifySignNeeded(bookingId, unsigned.reference);
|
||||
return { notified: true, reference: unsigned.reference, alreadySigned: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-haul: ensure a handover exists for a customer truck that just arrived.
|
||||
* Idempotent — one per (booking, truck). Runs inside the caller's transaction
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { NotificationAudience, NotificationType } from '@edr/types';
|
||||
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { LastMileService } from '../last-mile/last-mile.service';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { sendCompanyChannels } from '../notifications/notify-company.util';
|
||||
import { CreateInspectionReportDto } from './dto/create-inspection-report.dto';
|
||||
import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
|
||||
import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity';
|
||||
@@ -13,11 +18,15 @@ const INSPECTION_RESOURCE = 'warehouse-inspection-report';
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseInspectionService {
|
||||
private readonly logger = new Logger(WarehouseInspectionService.name);
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly inspectionRepository: WarehouseInspectionRepository,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly lastMileService: LastMileService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
|
||||
/** Create or update the inspection report for an inventory item and sync its inspectionStatus. */
|
||||
@@ -83,8 +92,10 @@ export class WarehouseInspectionService {
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT inv.booking_id AS "bookingId",
|
||||
b.reference AS "bookingReference",
|
||||
b.company_id AS "companyId",
|
||||
b.trade_direction AS "tradeDirection",
|
||||
b.last_mile_delivery_address AS "lastMileDeliveryAddress",
|
||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
||||
COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile"
|
||||
FROM freight.warehouse_inventory inv
|
||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
|
||||
@@ -105,6 +116,36 @@ export class WarehouseInspectionService {
|
||||
|
||||
if (row.bookingReference && hasLastMile) {
|
||||
await this.lastMileService.acceptBooking(row.bookingReference);
|
||||
} else if (!hasLastMile && !row.customerTruckAssignedAt) {
|
||||
// Self-haul import: goods are pickup-ready but no collection truck is
|
||||
// assigned yet — nudge the customer to assign one from the portal.
|
||||
void this.notifyTruckAssignmentNeeded(row);
|
||||
}
|
||||
}
|
||||
|
||||
/** Portal nudge: import goods are ready for pickup but no customer truck is assigned. */
|
||||
private async notifyTruckAssignmentNeeded(row: {
|
||||
bookingId?: string | null;
|
||||
bookingReference?: string | null;
|
||||
companyId?: string | null;
|
||||
}): Promise<void> {
|
||||
if (!row.companyId || !row.bookingId) return;
|
||||
const body = `Booking ${row.bookingReference ?? row.bookingId} has passed inspection and is ready for pickup. Please assign your collection truck(s) from the portal to proceed.`;
|
||||
try {
|
||||
await this.inbox.notify({
|
||||
recipients: { companyId: row.companyId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.BOOKING_STATUS,
|
||||
title: 'Assign a truck for pickup',
|
||||
body,
|
||||
link: `/bookings/${row.bookingId}`,
|
||||
data: { bookingId: row.bookingId, action: 'ASSIGN_TRUCK' },
|
||||
});
|
||||
await sendCompanyChannels(this.dataSource, this.notifications, row.companyId, body);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Truck-assignment notify failed for ${row.bookingId}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto';
|
||||
import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
|
||||
import { LoadInventoryDto } from './dto/load-inventory.dto';
|
||||
import { MoveInventoryDto } from './dto/move-inventory.dto';
|
||||
import { StoreInventoryDto } from './dto/store-inventory.dto';
|
||||
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
|
||||
import { ReleaseOrderDto } from './dto/release-order.dto';
|
||||
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
|
||||
@@ -267,9 +268,9 @@ export class WarehouseInventoryController {
|
||||
}
|
||||
|
||||
@Post(':id/store')
|
||||
@ApiOperation({ summary: 'Mark received inventory as STORED' })
|
||||
store(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
||||
return this.inventoryService.store(id, performedBy);
|
||||
@ApiOperation({ summary: 'Mark received inventory as STORED (optional explicit warehouse/yard/zone)' })
|
||||
store(@Param('id', ParseUUIDPipe) id: string, @Body() dto: StoreInventoryDto) {
|
||||
return this.inventoryService.store(id, dto.performedBy, dto);
|
||||
}
|
||||
|
||||
@Post(':id/ready-for-loading')
|
||||
@@ -354,12 +355,34 @@ export class WarehouseInventoryController {
|
||||
return this.handoverService.list(bookingId);
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/request-handover-signature')
|
||||
@ApiOperation({ summary: 'Ask the customer to sign the handover (creates one if none, then notifies)' })
|
||||
requestHandoverSignature(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
return this.handoverService.requestSignature(bookingId);
|
||||
}
|
||||
|
||||
@Get('bookings/:bookingId/handover-document')
|
||||
@ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking)' })
|
||||
async bookingHandoverDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) {
|
||||
const { filename, buffer } = await this.inventoryService.handoverDocumentForBooking(bookingId);
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
||||
res.setHeader('Content-Length', buffer.length);
|
||||
return res.send(buffer);
|
||||
}
|
||||
|
||||
@Get('bookings/:bookingId/container-items')
|
||||
@ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' })
|
||||
containerItems(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
return this.inventoryService.containerItems(bookingId);
|
||||
}
|
||||
|
||||
@Get('bookings/:bookingId/container-weights')
|
||||
@ApiOperation({ summary: "A booking's containers + VGM cargo weight (tonnes) for exit weighing" })
|
||||
containerWeights(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
return this.inventoryService.bookingContainerWeights(bookingId);
|
||||
}
|
||||
|
||||
@Post(':id/deliver')
|
||||
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
|
||||
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { InterchangeDocumentsService } from '../interchange-documents/interchang
|
||||
import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity';
|
||||
import { LastMileService } from '../last-mile/last-mile.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { sendCompanyChannels } from '../notifications/notify-company.util';
|
||||
import { SignaturesService } from '../signatures/signatures.service';
|
||||
import { BulkInspectDto } from './dto/bulk-inspect.dto';
|
||||
import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto';
|
||||
@@ -358,12 +359,14 @@ export interface ImportUnloadedRow {
|
||||
customerTruckType: string | null;
|
||||
customerTruckContainerNumber: string | null;
|
||||
customerTruckAssignedAt: string | null;
|
||||
hasAssignedTruck: boolean;
|
||||
currentStatus: string;
|
||||
releaseDate: string | null;
|
||||
releaseOrderReference: string | null;
|
||||
handoverDocumentReference: string | null;
|
||||
handoverDocumentDate: string | null;
|
||||
deliveredAt: string | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -403,16 +406,18 @@ export class WarehouseInventoryService {
|
||||
if (!booking.companyId) return;
|
||||
if (booking.hasFirstMile || booking.hasLastMile) return; // EDR mile — no customer truck
|
||||
if (booking.customerTruckAssignedAt) return; // already assigned
|
||||
const body = `Booking ${booking.reference ?? bookingId} has been received at the warehouse. Please assign your collection truck(s) from the portal to proceed.`;
|
||||
try {
|
||||
await this.inbox.notify({
|
||||
recipients: { companyId: booking.companyId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.BOOKING_STATUS,
|
||||
title: 'Assign a truck for pickup',
|
||||
body: `Booking ${booking.reference ?? bookingId} has been received at the warehouse. Please assign your collection truck(s) from the portal to proceed.`,
|
||||
body,
|
||||
link: `/bookings/${bookingId}`,
|
||||
data: { bookingId, action: 'ASSIGN_TRUCK' },
|
||||
});
|
||||
await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Truck-assignment notify failed for ${bookingId}: ${(err as Error).message}`);
|
||||
}
|
||||
@@ -1338,12 +1343,18 @@ export class WarehouseInventoryService {
|
||||
b.customer_truck_type AS "customerTruckType",
|
||||
b.customer_truck_container_number AS "customerTruckContainerNumber",
|
||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
||||
(b.customer_truck_assigned_at IS NOT NULL
|
||||
OR EXISTS (SELECT 1 FROM freight.last_mile lm
|
||||
WHERE lm.booking_id = b.id
|
||||
AND lm.vehicle_id IS NOT NULL
|
||||
AND lm.deleted_at IS NULL)) AS "hasAssignedTruck",
|
||||
inv.status AS "currentStatus",
|
||||
inv.release_date AS "releaseDate",
|
||||
inv.release_order_reference AS "releaseOrderReference",
|
||||
substring(inv.notes FROM 'Handover Reference: ([^\\n\\r]+)') AS "handoverDocumentReference",
|
||||
substring(inv.notes FROM 'Generated At: ([^\\n\\r]+)') AS "handoverDocumentDate",
|
||||
inv.delivered_at AS "deliveredAt",
|
||||
inv.notes AS "notes",
|
||||
oy.country AS "originCountry",
|
||||
dy.country AS "destinationCountry"
|
||||
FROM freight.warehouse_inventory inv
|
||||
@@ -2141,13 +2152,30 @@ export class WarehouseInventoryService {
|
||||
|
||||
// ── Lifecycle transitions ────────────────────────────────────────────────
|
||||
|
||||
async store(id: string, performedBy?: string): Promise<WarehouseInventory> {
|
||||
async store(
|
||||
id: string,
|
||||
performedBy?: string,
|
||||
chosen?: { warehouseId?: string; yardId?: string; zoneId?: string },
|
||||
): Promise<WarehouseInventory> {
|
||||
const item = await this.findById(id);
|
||||
this.assertTransition(item.status, 'STORED');
|
||||
|
||||
// Explicit location wins when the operator picked warehouse + yard + zone;
|
||||
// otherwise fall back to the allocation-rule / capacity-balanced auto pick.
|
||||
const manualLocation =
|
||||
chosen?.warehouseId && chosen?.yardId && chosen?.zoneId
|
||||
? {
|
||||
warehouseId: chosen.warehouseId,
|
||||
yardId: chosen.yardId,
|
||||
zoneId: chosen.zoneId,
|
||||
path: undefined as string | undefined,
|
||||
}
|
||||
: null;
|
||||
|
||||
const criteria = await this.getInventoryAllocationCriteria(item);
|
||||
const ruleLocation = await this.allocation.resolveLocation(criteria);
|
||||
const location = ruleLocation ?? (await this.pickCapacityBalancedStorageLocation(item, criteria));
|
||||
const ruleLocation = manualLocation ? null : await this.allocation.resolveLocation(criteria);
|
||||
const location =
|
||||
manualLocation ?? ruleLocation ?? (await this.pickCapacityBalancedStorageLocation(item, criteria));
|
||||
|
||||
if (!location) {
|
||||
throw new BadRequestException('No active warehouse yard/zone is available for this inventory item');
|
||||
@@ -2191,18 +2219,19 @@ export class WarehouseInventoryService {
|
||||
await this.applyCapacityDelta(manager, location, weight, volume, containerCount);
|
||||
}
|
||||
|
||||
const storedReason = manualLocation
|
||||
? `Stored at operator-selected location -> ${location.path ?? 'chosen yard/zone'}`
|
||||
: ruleLocation?.rule
|
||||
? `Stored by allocation rule "${ruleLocation.rule.name}" -> ${ruleLocation.path}`
|
||||
: `Stored by capacity-balanced allocation -> ${location.path ?? 'assigned yard/zone'}`;
|
||||
|
||||
await manager.getRepository(WarehouseInventory).update(id, {
|
||||
status: 'STORED',
|
||||
storedAt: new Date(),
|
||||
warehouseId: location.warehouseId,
|
||||
yardId: location.yardId,
|
||||
zoneId: location.zoneId,
|
||||
notes: this.appendNote(
|
||||
locked.notes,
|
||||
ruleLocation?.rule
|
||||
? `Stored by allocation rule "${ruleLocation.rule.name}" -> ${ruleLocation.path}`
|
||||
: `Stored by capacity-balanced allocation -> ${location.path ?? 'assigned yard/zone'}`,
|
||||
),
|
||||
notes: this.appendNote(locked.notes, storedReason),
|
||||
});
|
||||
|
||||
await this.activityLog.record(
|
||||
@@ -2210,9 +2239,7 @@ export class WarehouseInventoryService {
|
||||
activityType: 'INVENTORY_STORED',
|
||||
inventoryId: id,
|
||||
warehouseId: location.warehouseId,
|
||||
description: ruleLocation?.rule
|
||||
? `Inventory stored by rule "${ruleLocation.rule.name}" at ${ruleLocation.path}`
|
||||
: `Inventory stored at ${location.path ?? 'assigned yard/zone'}`,
|
||||
description: storedReason.replace(/^Stored/, 'Inventory stored'),
|
||||
performedBy,
|
||||
},
|
||||
manager,
|
||||
@@ -2331,6 +2358,26 @@ export class WarehouseInventoryService {
|
||||
'Customer must sign the handover before the exit paper can be generated',
|
||||
);
|
||||
}
|
||||
|
||||
// Authoritative weight match: the truck's net (gross − tare) must equal the
|
||||
// total VGM cargo weight of the containers selected as loaded on it.
|
||||
if (dto.containerNumber && dto.grossWeight != null && dto.tareWeight != null) {
|
||||
const selected = dto.containerNumber
|
||||
.split(/[,;\n]+/)
|
||||
.map((n) => n.trim())
|
||||
.filter(Boolean);
|
||||
if (selected.length) {
|
||||
const weights = await this.bookingContainerWeights(item.bookingId);
|
||||
const byNumber = new Map(weights.map((w) => [w.containerNumber.toUpperCase(), w.weightTons]));
|
||||
const expected = selected.reduce((sum, n) => sum + (byNumber.get(n.toUpperCase()) ?? 0), 0);
|
||||
const computedNet = Number((dto.grossWeight - dto.tareWeight).toFixed(3));
|
||||
if (expected > 0 && Math.abs(computedNet - expected) > 0.001) {
|
||||
throw new BadRequestException(
|
||||
`Weight mismatch: gross − tare (${computedNet} t) must equal the selected containers' cargo weight (${expected} t).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const releaseDate = isTruckLeaving
|
||||
@@ -2556,15 +2603,17 @@ export class WarehouseInventoryService {
|
||||
Array<{
|
||||
containerNumber: string;
|
||||
goods: string | null;
|
||||
stage: 'PENDING' | 'RECEIVED' | 'GRN' | 'LOADED' | 'LEFT' | 'DELIVERED';
|
||||
stage: 'PENDING' | 'RECEIVED' | 'GRN' | 'ASSIGNED' | 'LOADED' | 'LEFT' | 'DELIVERED';
|
||||
grnNumber: string | null;
|
||||
truckAssignmentId: string | null;
|
||||
truckPlate: string | null;
|
||||
truckArrived: boolean;
|
||||
truckLeft: boolean;
|
||||
loaded: boolean;
|
||||
bookingReference: string | null;
|
||||
contractId: string | null;
|
||||
hasLastMile: boolean;
|
||||
handoverSigned: boolean;
|
||||
}>
|
||||
> {
|
||||
const rows: Array<{
|
||||
@@ -2576,6 +2625,7 @@ export class WarehouseInventoryService {
|
||||
truckPlate: string | null;
|
||||
truckArrived: boolean;
|
||||
truckLeft: boolean;
|
||||
loaded: boolean;
|
||||
bookingReference: string | null;
|
||||
contractId: string | null;
|
||||
hasLastMile: boolean;
|
||||
@@ -2589,6 +2639,7 @@ export class WarehouseInventoryService {
|
||||
a.plate_number AS "truckPlate",
|
||||
(a.arrived_at IS NOT NULL) AS "truckArrived",
|
||||
(a.departed_at IS NOT NULL) AS "truckLeft",
|
||||
(ctc.loaded_at IS NOT NULL) AS loaded,
|
||||
b.reference AS "bookingReference",
|
||||
b.contract_id AS "contractId",
|
||||
(b.last_mile_delivery_address IS NOT NULL) AS "hasLastMile",
|
||||
@@ -2611,28 +2662,64 @@ export class WarehouseInventoryService {
|
||||
[bookingId],
|
||||
);
|
||||
|
||||
// Booking-level gate: the per-truck exit paper is blocked until the handover
|
||||
// is fully signed, so the UI can disable "Exit Paper" with a clear reason.
|
||||
const handoverSigned = await this.handover.isFullySigned(bookingId);
|
||||
|
||||
return rows.map((r) => ({
|
||||
containerNumber: r.containerNumber,
|
||||
goods: r.goods,
|
||||
// A container the customer assigned to a truck is ASSIGNED (planned); it
|
||||
// only becomes LOADED once the operator loads it (loaded_at) on truck
|
||||
// leaving. Departed → LEFT, delivered → DELIVERED.
|
||||
stage: r.delivered
|
||||
? 'DELIVERED'
|
||||
: r.truckLeft
|
||||
? 'LEFT'
|
||||
: r.truckAssignmentId
|
||||
: r.loaded
|
||||
? 'LOADED'
|
||||
: r.grnNumber
|
||||
? 'GRN'
|
||||
: r.received
|
||||
? 'RECEIVED'
|
||||
: 'PENDING',
|
||||
: r.truckAssignmentId
|
||||
? 'ASSIGNED'
|
||||
: r.grnNumber
|
||||
? 'GRN'
|
||||
: r.received
|
||||
? 'RECEIVED'
|
||||
: 'PENDING',
|
||||
grnNumber: r.grnNumber,
|
||||
truckAssignmentId: r.truckAssignmentId,
|
||||
truckPlate: r.truckPlate,
|
||||
truckArrived: r.truckArrived,
|
||||
truckLeft: r.truckLeft,
|
||||
loaded: r.loaded,
|
||||
bookingReference: r.bookingReference,
|
||||
contractId: r.contractId,
|
||||
hasLastMile: r.hasLastMile,
|
||||
handoverSigned,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The booking's containers with their VGM cargo weight (tonnes), keyed by
|
||||
* container number. Drives the truck-leaving exit weighing: the selected
|
||||
* containers' total cargo weight must match (gross − tare).
|
||||
*/
|
||||
async bookingContainerWeights(
|
||||
bookingId: string,
|
||||
): Promise<Array<{ containerNumber: string; weightTons: number }>> {
|
||||
const rows: Array<{ containerNumber: string; weightTons: string }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT bcu.container_number AS "containerNumber",
|
||||
COALESCE(bcu.vgm_tons, 0) AS "weightTons"
|
||||
FROM freight.booking_container_units bcu
|
||||
JOIN freight.booking_container bc
|
||||
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL
|
||||
ORDER BY bcu.container_number`,
|
||||
[bookingId],
|
||||
);
|
||||
return rows.map((r) => ({
|
||||
containerNumber: r.containerNumber,
|
||||
weightTons: Number(r.weightTons) || 0,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -2938,6 +3025,21 @@ export class WarehouseInventoryService {
|
||||
};
|
||||
}
|
||||
|
||||
/** Handover PDF resolved by booking (for the portal, which only has bookingId). */
|
||||
async handoverDocumentForBooking(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const [inv]: Array<{ id: string }> = await this.dataSource.query(
|
||||
`SELECT id FROM freight.warehouse_inventory
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL
|
||||
ORDER BY updated_at DESC NULLS LAST, created_at DESC
|
||||
LIMIT 1`,
|
||||
[bookingId],
|
||||
);
|
||||
if (!inv) {
|
||||
throw new NotFoundException(`No warehouse inventory found for booking ${bookingId}`);
|
||||
}
|
||||
return this.handoverDocument(inv.id);
|
||||
}
|
||||
|
||||
async handoverDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT inv.id,
|
||||
@@ -3164,7 +3266,22 @@ export class WarehouseInventoryService {
|
||||
[item.bookingId],
|
||||
);
|
||||
} else {
|
||||
await this.handover.ensureAtDelivery(item.bookingId, {}, manager);
|
||||
// EDR last-mile: the handover is per delivering truck. Resolve the
|
||||
// vehicle that carried this item's container so each truck gets its own
|
||||
// handover (falls back to a booking-level one when unresolvable).
|
||||
let truckPlate: string | null = null;
|
||||
if (item.containerId) {
|
||||
const [veh]: Array<{ plate: string | null }> = await manager.query(
|
||||
`SELECT COALESCE(v.power_plate_no, v.plate_number) AS plate
|
||||
FROM freight.last_mile_container_allocations lca
|
||||
JOIN freight.vehicles v ON v.id = lca.vehicle_id
|
||||
WHERE lca.container_id = $1 AND lca.vehicle_id IS NOT NULL
|
||||
LIMIT 1`,
|
||||
[item.containerId],
|
||||
);
|
||||
truckPlate = veh?.plate ?? null;
|
||||
}
|
||||
await this.handover.ensureAtDelivery(item.bookingId, { truckPlate }, manager);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -64,8 +64,8 @@ export class WarehousesService {
|
||||
currentWeight: 0,
|
||||
currentContainers: 0,
|
||||
currentVolume: 0,
|
||||
status: 'ACTIVE',
|
||||
isActive: true,
|
||||
status: dto.status ?? 'ACTIVE',
|
||||
isActive: (dto.status ?? 'ACTIVE') === 'ACTIVE',
|
||||
});
|
||||
} catch (error) {
|
||||
this.mapDbError(error);
|
||||
|
||||
Reference in New Issue
Block a user