mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
74
apps/edr-freight-api/docs/priority-batch-window-flow.md
Normal file
74
apps/edr-freight-api/docs/priority-batch-window-flow.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# Priority & Batch Window Flow (Import, Freight)
|
||||
|
||||
Export = no batch, no priority. Pure first-come-first-served (`booking-batch.service.ts:462-467, 625-628`). Everything below is import only.
|
||||
|
||||
## Step by step
|
||||
|
||||
**1. Booking submitted → priority score computed**
|
||||
`booking-transition.service.ts:110-111,196-197` → `booking-pricing.service.ts:403-407` `computeSubmitPriorityScore()` → `rule-engine.service.ts:118`.
|
||||
- Government booking: `+50,000` (`government-priority.constants.ts:2`, applied `rule-engine.service.ts:201`)
|
||||
- Plus cargo/weight modifiers
|
||||
- Stored on `booking.priorityScore`
|
||||
|
||||
**2. Window opens (PRE_WINDOW → OPEN)**
|
||||
Cron tick every 10s: `booking-window.service.ts:63` → `advanceImport` → `booking-window.service.ts:232-251`.
|
||||
Times computed by `computeImportWindowTimes` (`batch-window.util.ts:248-283`).
|
||||
|
||||
**3. Customers book during OPEN**
|
||||
Booking lands as:
|
||||
- Commercial → `FULLY_EXECUTED`
|
||||
- Government → `APPROVED/PAID` (skips contract flow)
|
||||
|
||||
**4. Window closes (OPEN → DOC_REVIEW)**
|
||||
`booking-window.service.ts:254-268`. Staff review docs for `docReviewMinutes`.
|
||||
|
||||
**5. Doc review ends**
|
||||
Staff `completeDocReview()` (`booking-window.service.ts:124-159`) or timeout → `booking-window.service.ts:270-295`.
|
||||
Before batch runs: `expireUnacceptedForRouteDay` (`booking-batch.service.ts:1853-1883`) kills never-accepted bookings so they can't compete.
|
||||
|
||||
**6. Batch fill runs**
|
||||
`processRouteDay` → `fillRouteDay` (`booking-batch.service.ts:1138-1319`), or single-schedule `fillSchedule` (`:1018-1128`).
|
||||
|
||||
- Pool pulled pre-sorted: `findBatchPool`/`findBatchPoolByCorridorDay` (`bookings.repository.ts:991-1008, 1055-1083`)
|
||||
`ORDER BY is_government DESC, priority_score DESC, fully_executed_at ASC, created_at ASC`
|
||||
- Consolidated pairs grouped as one atomic unit: `groupConsolidatedPool` (`:1962-1987`) — never split.
|
||||
- Greedy placement, earliest-departing fitting train first: loop at `:1218-1306`.
|
||||
- No fit + government booking → `preemptForGovernment` (`:1891-1910`): bumps lowest-`priorityScore` commercial victim first, only if legs overlap (`:1920`).
|
||||
- No fit + commercial import (GENERAL/ONE_TIME) → maybe partial "split" offer: `maybeOfferPartial`/`isSplitEligible` (`:1326-1370`).
|
||||
- Still no fit → stays pooled, `notifier.unplaced` (`:1278-1280`).
|
||||
|
||||
**7. Placed bookings get reserved/allocated**
|
||||
- Commercial: `reserve()` (`:1673-1703`) → `SELECTED_FOR_BATCH`, payment deadline set, DOC_REVIEW→PAYMENT (`booking-window.service.ts:275-294`).
|
||||
- Government: `allocate()` directly (`:1706-1746`), no payment step.
|
||||
|
||||
**8. Payment phase ends**
|
||||
`booking-window.service.ts:297-309` → `settleDueReservations` → `settleReserved` (`:1437-1491`):
|
||||
- paid → allocated
|
||||
- unpaid → expired, capacity freed
|
||||
|
||||
Then `concludeCycle` (`:315-373`):
|
||||
- Train full → `DONE` + auto-finalize (`:320-329`)
|
||||
- Not full → reopen same/next day (`nextCycleOpensAt` / office hours, `:331-372`, `batch-window.util.ts:217-224`) or `DONE` if no cycle fits before departure.
|
||||
|
||||
**9. Backstop**
|
||||
`settleOverdueReservations` (`booking-window.service.ts:388-406`) catches any reservation whose deadline passed outside the normal tick.
|
||||
|
||||
## Phase enum
|
||||
|
||||
`PRE_WINDOW → OPEN → DOC_REVIEW → PAYMENT → (reopen PRE_WINDOW | DONE)`
|
||||
(`booking-window.config.ts:27-34`)
|
||||
|
||||
## What decides priority
|
||||
|
||||
1. `is_government` — always first, both in SQL sort and `compareSchedulingPriority` util (`compare-scheduling-priority.util.ts:9-23`)
|
||||
2. `priority_score` DESC (rule engine: government bonus + cargo/weight modifiers)
|
||||
3. `fully_executed_at` ASC (earlier wins)
|
||||
4. `created_at` ASC
|
||||
|
||||
## Edge cases
|
||||
|
||||
- Government preemption only bumps if legs overlap; picks lowest-priority victim first.
|
||||
- Consolidated pairs are both-or-neither, never split (`:1326-1334, 1793`).
|
||||
- Only GENERAL/ONE_TIME import bookings are eligible for partial "split" offers.
|
||||
- Per-unit try/catch around reserve — one failure can't cause silent trickle/stagger allocation (comment at `:1283-1288`).
|
||||
- Each train freezes its own rule snapshot at window-open time, not live config (`booking-window.service.ts:85-93`).
|
||||
@@ -0,0 +1,27 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Adds locomotives.overage_tolerance_tons / overage_tolerance_meters: an
|
||||
* optional per-locomotive deviation allowance above max_pull_weight_tons /
|
||||
* max_train_length_meters. Nullable, defaults to no tolerance so existing
|
||||
* strict-cap behavior is unchanged until staff sets a value.
|
||||
*/
|
||||
export class AddLocomotiveOverageTolerance2040000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.locomotives
|
||||
ADD COLUMN IF NOT EXISTS overage_tolerance_tons NUMERIC(10, 3),
|
||||
ADD COLUMN IF NOT EXISTS overage_tolerance_meters NUMERIC(10, 3);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.locomotives
|
||||
DROP COLUMN IF EXISTS overage_tolerance_tons,
|
||||
DROP COLUMN IF EXISTS overage_tolerance_meters;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Adds customer_truck_containers.loaded_at so an assignment (customer planning
|
||||
* which containers ride which truck) is distinct from the container actually
|
||||
* being loaded. Stage LOADED now requires loaded_at; customer assignment alone
|
||||
* keeps the container at its prior stage (RECEIVED/GRN) with its planned truck
|
||||
* shown. Backfills containers on already-departed trucks (they left loaded).
|
||||
*/
|
||||
export class AddCustomerTruckContainerLoadedAt2050000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.customer_truck_containers
|
||||
ADD COLUMN IF NOT EXISTS loaded_at TIMESTAMPTZ;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.customer_truck_containers ctc
|
||||
SET loaded_at = a.departed_at
|
||||
FROM freight.customer_truck_assignments a
|
||||
WHERE a.id = ctc.assignment_id
|
||||
AND a.departed_at IS NOT NULL
|
||||
AND ctc.deleted_at IS NULL
|
||||
AND ctc.loaded_at IS NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.customer_truck_containers DROP COLUMN IF EXISTS loaded_at;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Drops wagon_types.max_wagons_per_train. Train wagon-count caps are already
|
||||
* derived from locomotive + wagon length/weight (train-capacity.util.ts) and
|
||||
* the global train_scheduling_global_rules row — this per-wagon-type override
|
||||
* was unused by that derivation and only added a confusing "Max / train"
|
||||
* field to the wagon type form.
|
||||
*/
|
||||
export class DropWagonTypeMaxWagonsPerTrain2050000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagon_types
|
||||
DROP COLUMN IF EXISTS max_wagons_per_train;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagon_types
|
||||
ADD COLUMN IF NOT EXISTS max_wagons_per_train INT;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Upserts the 10 real EDR wagon types (code, name, capacity, length, tare
|
||||
* weight) by code. Overwrites any existing row with the same code so
|
||||
* previously-seeded demo values (e.g. NW5/PW2/CW3 from demo-bookings.seeder)
|
||||
* are replaced with the real spec.
|
||||
*/
|
||||
export class SeedRailWagonTypes2060000000000 implements MigrationInterface {
|
||||
private readonly wagonTypes = [
|
||||
{ code: 'NW7', name: 'Double deck sedan wagon', capacityTons: 22, lengthMeters: 26.066, tareWeightTons: 37.1 },
|
||||
{ code: 'NW5', name: 'Flat wagon', capacityTons: 70, lengthMeters: 13.966, tareWeightTons: 22.4 },
|
||||
{ code: 'PW2', name: 'Box wagon', capacityTons: 70, lengthMeters: 17.066, tareWeightTons: 25.2 },
|
||||
{ code: 'GW2', name: 'Tank wagon', capacityTons: 70, lengthMeters: 12.228, tareWeightTons: 23 },
|
||||
{ code: 'CW4', name: 'Gondola covered wagon', capacityTons: 70, lengthMeters: 13.976, tareWeightTons: 24.8 },
|
||||
{ code: 'CW3', name: 'Gondola open wagon', capacityTons: 70, lengthMeters: 13.976, tareWeightTons: 23.4 },
|
||||
{ code: 'KW2', name: 'Hopper covered wagon', capacityTons: 69, lengthMeters: 16.466, tareWeightTons: 25.2 },
|
||||
{ code: 'KW3', name: 'Hopper wagon open', capacityTons: 70, lengthMeters: 14.4, tareWeightTons: 24 },
|
||||
{ code: 'NW6', name: 'Flat wagon (long)', capacityTons: 70, lengthMeters: 18.56, tareWeightTons: 25.3 },
|
||||
{ code: 'BW1', name: 'Refrigerated wagon', capacityTons: 38, lengthMeters: 21.996, tareWeightTons: 32.1 },
|
||||
];
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
for (const wt of this.wagonTypes) {
|
||||
await queryRunner.query(
|
||||
`
|
||||
INSERT INTO freight.wagon_types (code, name, capacity_tons, length_meters, tare_weight_tons, is_active)
|
||||
VALUES ($1, $2, $3, $4, $5, true)
|
||||
ON CONFLICT (code) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
capacity_tons = EXCLUDED.capacity_tons,
|
||||
length_meters = EXCLUDED.length_meters,
|
||||
tare_weight_tons = EXCLUDED.tare_weight_tons;
|
||||
`,
|
||||
[wt.code, wt.name, wt.capacityTons, wt.lengthMeters, wt.tareWeightTons],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DELETE FROM freight.wagon_types WHERE code = ANY($1);`,
|
||||
[this.wagonTypes.map((wt) => wt.code)],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Tare weight becomes mandatory on a wagon type.
|
||||
*
|
||||
* The locomotive's pull limit is a GROSS limit — it drags the wagon as well as
|
||||
* the cargo — so capacity math cannot run without a tare. A NULL tare silently
|
||||
* read as zero and let trains overbook by the tare fraction (~27% on a PW2
|
||||
* consist), so the column is now NOT NULL.
|
||||
*
|
||||
* Any row still missing a tare predates 2060000000000-SeedRailWagonTypes (which
|
||||
* upserts the ten real EDR types). Backfill those by code first, and give any
|
||||
* remaining custom/demo type the NW5 flat-wagon tare rather than fail the
|
||||
* migration — a wrong-but-plausible tare is recoverable in the admin UI; a
|
||||
* blocked deploy is not.
|
||||
*/
|
||||
export class MakeWagonTypeTareWeightRequired2070000000000 implements MigrationInterface {
|
||||
private readonly tareByCode: Array<[string, number]> = [
|
||||
['NW7', 37.1],
|
||||
['NW5', 22.4],
|
||||
['PW2', 25.2],
|
||||
['GW2', 23],
|
||||
['CW4', 24.8],
|
||||
['CW3', 23.4],
|
||||
['KW2', 25.2],
|
||||
['KW3', 24],
|
||||
['NW6', 25.3],
|
||||
['BW1', 32.1],
|
||||
];
|
||||
|
||||
/** NW5 flat wagon — the commonest type in the fleet (550 of 1100). */
|
||||
private readonly fallbackTareTons = 22.4;
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
for (const [code, tareWeightTons] of this.tareByCode) {
|
||||
await queryRunner.query(
|
||||
`UPDATE freight.wagon_types
|
||||
SET tare_weight_tons = $2
|
||||
WHERE code = $1 AND tare_weight_tons IS NULL;`,
|
||||
[code, tareWeightTons],
|
||||
);
|
||||
}
|
||||
|
||||
await queryRunner.query(
|
||||
`UPDATE freight.wagon_types
|
||||
SET tare_weight_tons = $1
|
||||
WHERE tare_weight_tons IS NULL;`,
|
||||
[this.fallbackTareTons],
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.wagon_types
|
||||
ALTER COLUMN tare_weight_tons SET NOT NULL;`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.wagon_types
|
||||
ALTER COLUMN tare_weight_tons DROP NOT NULL;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -993,6 +993,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 +1024,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 +1063,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 +1128,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 +1142,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,12 +1424,14 @@ export class BookingsService {
|
||||
schedule?.status ?? null;
|
||||
}
|
||||
|
||||
// A generated-but-unsigned handover means the customer must approve delivery.
|
||||
// Surfaced so the portal shows "Approve delivery" as soon as the handover
|
||||
// exists, independent of the truck-arrival flag.
|
||||
// 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],
|
||||
);
|
||||
|
||||
@@ -312,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) {
|
||||
@@ -333,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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -30,19 +30,27 @@ import { BillingService } from "../billing/billing.service";
|
||||
|
||||
import {
|
||||
DEFAULT_BULK_WAGON_LENGTH_METERS,
|
||||
DEFAULT_BULK_WAGON_TARE_TONS,
|
||||
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||||
DEFAULT_CONTAINER_WAGON_TARE_TONS,
|
||||
DEFAULT_WAGONS_PER_BOOKING,
|
||||
} from "./booking-batch.constants";
|
||||
import {
|
||||
WagonTypeDimensions,
|
||||
bookingGrossWeightTons,
|
||||
bookingTrainLengthMeters,
|
||||
deriveTrainCapacityFromLocomotive,
|
||||
trainHardCaps,
|
||||
wagonTypeDimensionsFromEntity,
|
||||
} from './train-capacity.util';
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
||||
import { BookingSplitService } from './booking-split.service';
|
||||
import { BookingWindowGateway } from './booking-window.gateway';
|
||||
import { MAX_TEU_SLOTS_PER_WAGON } from './wagon-plan.util';
|
||||
import {
|
||||
MAX_TEU_SLOTS_PER_WAGON,
|
||||
containerWagonsForLines,
|
||||
} from './wagon-plan.util';
|
||||
import {
|
||||
Capacity,
|
||||
CorridorBudget,
|
||||
@@ -60,7 +68,14 @@ interface RouteDayGroup {
|
||||
day: string;
|
||||
}
|
||||
|
||||
type WagonLengths = { container: number; bulk: number };
|
||||
/**
|
||||
* Per-freight-type wagon dimensions used to size a booking's capacity draw:
|
||||
* its length on the train and the tare it adds to the locomotive's gross load.
|
||||
*/
|
||||
type WagonDims = {
|
||||
container: { lengthMeters: number; tareWeightTons: number };
|
||||
bulk: { lengthMeters: number; tareWeightTons: number };
|
||||
};
|
||||
|
||||
export type BatchBoardBookingState =
|
||||
| "ALLOCATED"
|
||||
@@ -498,8 +513,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
|
||||
const rules = await this.loadGlobalRules();
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
const required = need ?? this.needFor(booking, wagonLengths);
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const required = need ?? this.needFor(booking, wagonDims);
|
||||
let corridorMatched = false;
|
||||
for (const candidate of candidates) {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||
@@ -508,7 +523,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const locomotive = schedule?.trainSet?.locomotive;
|
||||
if (!schedule || !locomotive) continue;
|
||||
const limits = await this.capacityLimits(locomotive, rules);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonLengths);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
|
||||
if (!leg) continue; // this train's route doesn't carry the booking's leg
|
||||
corridorMatched = true;
|
||||
@@ -546,8 +561,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
if (!partner || partner.status !== 'FULLY_EXECUTED') {
|
||||
return;
|
||||
}
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
const need = this.combinedNeed(booking, partner, wagonLengths);
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const need = this.combinedNeed(booking, partner, wagonDims);
|
||||
const scheduleId = await this.pickExportSchedule(booking, need);
|
||||
await this.reserveOnExport([booking, partner], scheduleId);
|
||||
}
|
||||
@@ -617,7 +632,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
order: { scheduledDepartureDate: "ASC" },
|
||||
});
|
||||
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const rules = await this.loadGlobalRules();
|
||||
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
|
||||
|
||||
const board: BatchBoardSchedule[] = [];
|
||||
@@ -632,7 +648,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const bookings = await this.bookingsRepository.findAllBySchedule(s.id);
|
||||
|
||||
const items: BatchBoardBooking[] = bookings.map((b) => {
|
||||
const need = this.needFor(b, wagonLengths);
|
||||
const need = this.needFor(b, wagonDims);
|
||||
return {
|
||||
id: b.id,
|
||||
reference: b.reference ?? b.id.slice(0, 8),
|
||||
@@ -652,7 +668,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
};
|
||||
});
|
||||
|
||||
board.push(this.buildScheduleSummary(s, items));
|
||||
board.push(this.buildScheduleSummary(s, items, rules));
|
||||
}
|
||||
return board;
|
||||
}
|
||||
@@ -675,7 +691,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
);
|
||||
}
|
||||
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const rules = await this.loadGlobalRules();
|
||||
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
|
||||
const links = await linkRepo.find({ where: { trainScheduleId: s.id } });
|
||||
const linkedIds = new Set(links.map((l) => l.bookingId));
|
||||
@@ -721,7 +738,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
|
||||
const items: BatchBoardBookingDetail[] = bookings.map((b) => {
|
||||
const need = this.needFor(b, wagonLengths);
|
||||
const need = this.needFor(b, wagonDims);
|
||||
const alloc = allocationByBooking.get(b.id);
|
||||
return {
|
||||
id: b.id,
|
||||
@@ -868,7 +885,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
||||
}
|
||||
: null,
|
||||
capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null),
|
||||
capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null, rules),
|
||||
counts: {
|
||||
allocated: items.filter((i) => i.state === "ALLOCATED").length,
|
||||
selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH")
|
||||
@@ -899,6 +916,13 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Board capacity figures. `usedWeightTons` is GROSS (each item's weight already
|
||||
* includes the tare of the wagons it occupies), so the ceiling it is measured
|
||||
* against must be the same one the fill loop spends from: the locomotive floored
|
||||
* by the global rule caps and widened by its overage tolerance. Reading the raw
|
||||
* `loco.maxPullWeightTons` here showed staff a ceiling the batch engine did not use.
|
||||
*/
|
||||
private computeBoardCapacity(
|
||||
items: Array<{
|
||||
state: BatchBoardBookingState;
|
||||
@@ -908,22 +932,40 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}>,
|
||||
loco: Locomotive | null,
|
||||
maxWagons: number | null,
|
||||
rules: TrainSchedulingGlobalRules | null,
|
||||
): BatchBoardSchedule["capacity"] {
|
||||
const allocated = items.filter((i) => i.state === "ALLOCATED");
|
||||
const committed = items.filter(
|
||||
(i) => i.state === "ALLOCATED" || i.state === "SELECTED_FOR_BATCH",
|
||||
);
|
||||
const caps = loco
|
||||
? trainHardCaps(
|
||||
{
|
||||
maxPullWeightTons: Number(loco.maxPullWeightTons),
|
||||
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
||||
overageToleranceTons: Number(loco.overageToleranceTons) || 0,
|
||||
overageToleranceMeters: Number(loco.overageToleranceMeters) || 0,
|
||||
},
|
||||
{
|
||||
maxTrainWeightTons: rules?.maxTrainWeightTons
|
||||
? Number(rules.maxTrainWeightTons)
|
||||
: undefined,
|
||||
maxTrainLengthMeters: rules?.maxTrainLengthMeters
|
||||
? Number(rules.maxTrainLengthMeters)
|
||||
: undefined,
|
||||
},
|
||||
)
|
||||
: null;
|
||||
const round2 = (value: number) => Math.round(value * 100) / 100;
|
||||
|
||||
return {
|
||||
allocatedWagons: allocated.reduce((sum, i) => sum + i.wagons, 0),
|
||||
allocatedLengthMeters:
|
||||
Math.round(
|
||||
allocated.reduce((sum, i) => sum + i.lengthMeters, 0) * 100,
|
||||
) / 100,
|
||||
maxLengthMeters: loco ? Number(loco.maxTrainLengthMeters) : null,
|
||||
usedWeightTons:
|
||||
Math.round(committed.reduce((sum, i) => sum + i.weightTons, 0) * 100) /
|
||||
100,
|
||||
maxWeightTons: loco ? Number(loco.maxPullWeightTons) : null,
|
||||
allocatedLengthMeters: round2(
|
||||
allocated.reduce((sum, i) => sum + i.lengthMeters, 0),
|
||||
),
|
||||
maxLengthMeters: caps ? caps.maxLengthMeters : null,
|
||||
usedWeightTons: round2(committed.reduce((sum, i) => sum + i.weightTons, 0)),
|
||||
maxWeightTons: caps ? caps.maxWeightTons : null,
|
||||
maxWagons: maxWagons ?? null,
|
||||
};
|
||||
}
|
||||
@@ -931,6 +973,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
private buildScheduleSummary(
|
||||
s: TrainSchedule,
|
||||
items: BatchBoardBooking[],
|
||||
rules: TrainSchedulingGlobalRules | null,
|
||||
): BatchBoardSchedule {
|
||||
const loco = s.trainSet?.locomotive ?? null;
|
||||
|
||||
@@ -963,7 +1006,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
||||
}
|
||||
: null,
|
||||
capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null),
|
||||
capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null, rules),
|
||||
counts: {
|
||||
allocated: items.filter((i) => i.state === "ALLOCATED").length,
|
||||
selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH")
|
||||
@@ -1014,33 +1057,39 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return schedule.windowPhase === "DOC_REVIEW" || schedule.windowPhase === "PAYMENT";
|
||||
}
|
||||
|
||||
/** Fill one schedule from its priority-ordered pool until full. */
|
||||
async fillSchedule(scheduleId: string): Promise<void> {
|
||||
/**
|
||||
* Fill one schedule from its priority-ordered pool until full. Returns the
|
||||
* number of commercial units it RESERVED this pass (0 for government-only or
|
||||
* no-fit passes) so a top-up caller can extend the payment phase only when a
|
||||
* fresh pay window actually opened.
|
||||
*/
|
||||
async fillSchedule(scheduleId: string): Promise<number> {
|
||||
const schedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule || !this.isFillable(schedule)) return;
|
||||
if (!schedule || !this.isFillable(schedule)) return 0;
|
||||
const locomotive = schedule.trainSet?.locomotive;
|
||||
if (!schedule.trainSetId || !locomotive) {
|
||||
this.logger.warn(
|
||||
`Schedule ${scheduleId} has no locomotive/train set — skipped.`,
|
||||
);
|
||||
return;
|
||||
return 0;
|
||||
}
|
||||
|
||||
const rules = await this.loadGlobalRules();
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const limits = await this.capacityLimits(locomotive, rules);
|
||||
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonLengths);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||
if (budget.maxRemaining().wagons <= 0) {
|
||||
await this.setWindow(scheduleId, "FULL");
|
||||
return;
|
||||
return 0;
|
||||
}
|
||||
|
||||
const pool = await this.bookingsRepository.findBatchPool(scheduleId);
|
||||
const units = this.groupConsolidatedPool(pool);
|
||||
let armed = false;
|
||||
let reservedThisPass = 0;
|
||||
let commercialReserved = 0;
|
||||
|
||||
// Batch fill trace: caps + pool at entry. Kept on debug level — invaluable when
|
||||
// reservations trickle instead of landing in one pass (a reserve() throwing
|
||||
@@ -1055,8 +1104,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const { primary: booking, partner } = unit;
|
||||
const isPair = partner != null;
|
||||
const need = isPair
|
||||
? this.combinedNeed(booking, partner, wagonLengths)
|
||||
: this.needFor(booking, wagonLengths);
|
||||
? this.combinedNeed(booking, partner, wagonDims)
|
||||
: this.needFor(booking, wagonDims);
|
||||
const isGov = booking.isGovernment || (partner?.isGovernment ?? false);
|
||||
// Consolidated partners always share one corridor, so the primary's leg
|
||||
// stands for the pair.
|
||||
@@ -1075,7 +1124,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
need,
|
||||
leg,
|
||||
budget,
|
||||
wagonLengths,
|
||||
wagonDims,
|
||||
);
|
||||
if (!freed) continue; // still doesn't fit even after preempt
|
||||
} else {
|
||||
@@ -1106,6 +1155,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
await this.reserve(booking, scheduleId);
|
||||
if (partner) await this.reserve(partner, scheduleId);
|
||||
armed = true;
|
||||
commercialReserved += 1;
|
||||
}
|
||||
budget.subtract(need, leg);
|
||||
reservedThisPass += 1;
|
||||
@@ -1125,6 +1175,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
if (budget.maxRemaining().wagons <= 0) await this.setWindow(scheduleId, "FULL");
|
||||
if (armed) this.armSettle(scheduleId);
|
||||
void this.triggerWagonAllocation(scheduleId);
|
||||
return commercialReserved;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1140,6 +1191,42 @@ export class BookingBatchService implements OnModuleInit {
|
||||
destinationYardId: string,
|
||||
day: string,
|
||||
): Promise<string[]> {
|
||||
const { scheduleIds } = await this.fillRouteDayInternal(
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
day,
|
||||
);
|
||||
return scheduleIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route-day top-up for a single schedule: re-run the DAY pool over the whole
|
||||
* corridor the schedule belongs to, and report how many commercial units got a
|
||||
* fresh pay window.
|
||||
*
|
||||
* `fillSchedule` cannot do this job. Its pool (`findBatchPool`) is keyed on
|
||||
* `booking.train_schedule_id = :scheduleId`, but under day-level pooling a
|
||||
* booking that has not been reserved yet has a NULL `train_schedule_id` — it is
|
||||
* only pinned by `reserve()`. So the schedule-scoped top-up returned zero rows
|
||||
* and the waiting list never boarded after an expiry freed capacity; bookings
|
||||
* trickled in one per window cycle instead.
|
||||
*/
|
||||
private async topUpFill(scheduleId: string): Promise<number> {
|
||||
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
|
||||
if (!schedule?.scheduledDepartureDate) return 0;
|
||||
const { commercialReserved } = await this.fillRouteDayInternal(
|
||||
schedule.originStationId,
|
||||
schedule.destinationStationId,
|
||||
eatDay(schedule.scheduledDepartureDate),
|
||||
);
|
||||
return commercialReserved;
|
||||
}
|
||||
|
||||
private async fillRouteDayInternal(
|
||||
originYardId: string,
|
||||
destinationYardId: string,
|
||||
day: string,
|
||||
): Promise<{ scheduleIds: string[]; commercialReserved: number }> {
|
||||
// The day's fillable schedules on this exact corridor, earliest first. Fillable
|
||||
// covers legacy OPEN trains and window-cycle trains in DOC_REVIEW/PAYMENT —
|
||||
// the batch must run while the customer window is closed.
|
||||
@@ -1157,23 +1244,36 @@ export class BookingBatchService implements OnModuleInit {
|
||||
},
|
||||
],
|
||||
});
|
||||
const scheduleIds = corridor
|
||||
const onDay = corridor
|
||||
.filter(
|
||||
(s) =>
|
||||
s.scheduledDepartureDate != null &&
|
||||
eatDay(s.scheduledDepartureDate) === day &&
|
||||
this.isFillable(s),
|
||||
eatDay(s.scheduledDepartureDate) === day,
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.scheduledDepartureDate.getTime() - b.scheduledDepartureDate.getTime(),
|
||||
)
|
||||
.map((s) => s.id);
|
||||
);
|
||||
|
||||
if (scheduleIds.length === 0) return [];
|
||||
// A schedule flagged FULL is rejected by isFillable() before its budget is
|
||||
// ever consulted. Re-derive that flag from live capacity first, so a train
|
||||
// whose bookings all expired is not skipped forever with an empty consist.
|
||||
for (const s of onDay) {
|
||||
if (s.bookingWindowStatus === "FULL") {
|
||||
await this.refreshWindowStatus(s.id);
|
||||
const fresh = await this.trainSchedulesRepository.findById(s.id);
|
||||
if (fresh) s.bookingWindowStatus = fresh.bookingWindowStatus;
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleIds = onDay.filter((s) => this.isFillable(s)).map((s) => s.id);
|
||||
|
||||
if (scheduleIds.length === 0) {
|
||||
return { scheduleIds: [], commercialReserved: 0 };
|
||||
}
|
||||
|
||||
const rules = await this.loadGlobalRules();
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
|
||||
// Live per-schedule corridor budget + arm flag, in departure order.
|
||||
const trains: Array<{ id: string; budget: CorridorBudget; armed: boolean }> = [];
|
||||
@@ -1189,10 +1289,10 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
const limits = await this.capacityLimits(locomotive, rules);
|
||||
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonLengths);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||
trains.push({ id, budget, armed: false });
|
||||
}
|
||||
if (trains.length === 0) return [];
|
||||
if (trains.length === 0) return { scheduleIds, commercialReserved: 0 };
|
||||
|
||||
// The day pool covers every booking whose leg lies somewhere on one of the
|
||||
// day's corridors — full-route AND sub-corridor (e.g. Dire→Djibouti on an
|
||||
@@ -1214,13 +1314,14 @@ export class BookingBatchService implements OnModuleInit {
|
||||
`poolSize=${pool.length} units=${units.length}`,
|
||||
);
|
||||
let reservedThisPass = 0;
|
||||
let commercialReserved = 0;
|
||||
|
||||
for (const unit of units) {
|
||||
const { primary: booking, partner } = unit;
|
||||
const isPair = partner != null;
|
||||
const need = isPair
|
||||
? this.combinedNeed(booking, partner, wagonLengths)
|
||||
: this.needFor(booking, wagonLengths);
|
||||
? this.combinedNeed(booking, partner, wagonDims)
|
||||
: this.needFor(booking, wagonDims);
|
||||
const isGov = booking.isGovernment || (partner?.isGovernment ?? false);
|
||||
|
||||
const legOn = (t: { budget: CorridorBudget }): CorridorLeg | null =>
|
||||
@@ -1256,7 +1357,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
need,
|
||||
leg,
|
||||
t.budget,
|
||||
wagonLengths,
|
||||
wagonDims,
|
||||
);
|
||||
if (freed) {
|
||||
target = t;
|
||||
@@ -1273,7 +1374,12 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// non-import never split — isSplitEligible guards that. Passing the live
|
||||
// `trains` entries lets maybeOfferPartial mutate the chosen budget/armed.
|
||||
const offered = await this.maybeOfferPartial(booking, isPair, trains, need);
|
||||
if (offered) continue;
|
||||
if (offered) {
|
||||
// A partial offer opens a real commercial pay window, same as reserve().
|
||||
commercialReserved += 1;
|
||||
reservedThisPass += 1;
|
||||
continue;
|
||||
}
|
||||
// Stays in the pool, retried next batch/window cycle.
|
||||
this.notifier.unplaced(booking, day);
|
||||
if (partner) this.notifier.unplaced(partner, day);
|
||||
@@ -1294,6 +1400,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
await this.reserve(booking, target.id);
|
||||
if (partner) await this.reserve(partner, target.id);
|
||||
target.armed = true;
|
||||
commercialReserved += 1;
|
||||
}
|
||||
target.budget.subtract(need, legOn(target)!);
|
||||
reservedThisPass += 1;
|
||||
@@ -1315,7 +1422,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
void this.triggerWagonAllocation(t.id);
|
||||
}
|
||||
|
||||
return trains.map((t) => t.id);
|
||||
return { scheduleIds: trains.map((t) => t.id), commercialReserved };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1385,7 +1492,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
if (booking.consolidationPartnerId) return null;
|
||||
if (await this.splitService.findOpenOffer(booking.id)) return null;
|
||||
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const bulkCapacityTons = await this.loadBulkWagonCapacityTons();
|
||||
const sized = await this.splitService.sizeOffer(
|
||||
booking,
|
||||
@@ -1397,11 +1504,16 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
const offeredNeed: Capacity = {
|
||||
wagons: sized.offeredWagons,
|
||||
weightTons: sized.offeredWeightTons,
|
||||
lengthMeters: bookingTrainLengthMeters(booking.freightType, sized.offeredWagons, {
|
||||
container: wagonLengths.container,
|
||||
bulk: wagonLengths.bulk,
|
||||
}),
|
||||
weightTons: bookingGrossWeightTons(
|
||||
sized.offeredWeightTons,
|
||||
sized.offeredWagons,
|
||||
this.tareFor(booking.freightType, wagonDims),
|
||||
),
|
||||
lengthMeters: bookingTrainLengthMeters(
|
||||
booking.freightType,
|
||||
sized.offeredWagons,
|
||||
this.lengthsOf(wagonDims),
|
||||
),
|
||||
};
|
||||
if (!this.fits(offeredNeed, budget)) return null;
|
||||
|
||||
@@ -1499,7 +1611,13 @@ export class BookingBatchService implements OnModuleInit {
|
||||
this.logger.log(
|
||||
`[BATCH] settle changed state on ${scheduleId} — running top-up fill for the waiting list`,
|
||||
);
|
||||
await this.fillSchedule(scheduleId);
|
||||
const topUpReserved = await this.topUpFill(scheduleId);
|
||||
// A top-up opened a fresh pay window for waiting bookings — push the
|
||||
// schedule's PAYMENT phase out so the window tick's concludeCycle doesn't
|
||||
// fire before those customers' new deadlines and expire them prematurely.
|
||||
if (topUpReserved > 0) {
|
||||
await this.extendPaymentPhaseForTopUp(scheduleId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1509,7 +1627,10 @@ export class BookingBatchService implements OnModuleInit {
|
||||
async settleBatch(scheduleId: string): Promise<void> {
|
||||
this.removeTimeout(scheduleId);
|
||||
await this.settleReserved(scheduleId, true);
|
||||
await this.fillSchedule(scheduleId);
|
||||
const topUpReserved = await this.topUpFill(scheduleId);
|
||||
if (topUpReserved > 0) {
|
||||
await this.extendPaymentPhaseForTopUp(scheduleId);
|
||||
}
|
||||
void this.triggerWagonAllocation(scheduleId);
|
||||
}
|
||||
|
||||
@@ -1612,9 +1733,16 @@ export class BookingBatchService implements OnModuleInit {
|
||||
.getRepository(Booking)
|
||||
.findOne({ where: { id: bookingId } });
|
||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
// Capture the train before expire() detaches the booking from it — the
|
||||
// top-up has to run against the schedule whose wagons were just freed.
|
||||
const freedScheduleId = booking.trainScheduleId;
|
||||
await this.expire(booking);
|
||||
if (booking.trainScheduleId)
|
||||
await this.fillSchedule(booking.trainScheduleId);
|
||||
if (freedScheduleId) {
|
||||
const topUpReserved = await this.topUpFill(freedScheduleId);
|
||||
if (topUpReserved > 0) {
|
||||
await this.extendPaymentPhaseForTopUp(freedScheduleId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- intercity ride-along API ---------------------------------------------
|
||||
@@ -1635,10 +1763,10 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const locomotive = schedule?.trainSet?.locomotive;
|
||||
if (!schedule || !locomotive) return null;
|
||||
const rules = await this.loadGlobalRules();
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const limits = await this.capacityLimits(locomotive, rules);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonLengths);
|
||||
return { budget, needFor: (booking) => this.needFor(booking, wagonLengths) };
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||
return { budget, needFor: (booking) => this.needFor(booking, wagonDims) };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1671,6 +1799,28 @@ export class BookingBatchService implements OnModuleInit {
|
||||
* so the engine sets it as it picks the train.
|
||||
*/
|
||||
private async reserve(booking: Booking, scheduleId: string): Promise<void> {
|
||||
// Idempotency guard: a booking already reserved (pay window open) or already
|
||||
// paid on THIS schedule must never be re-reserved — that would fire a second
|
||||
// `payNow` and reset its deadline, the "asked to pay again after paying"
|
||||
// symptom. Read fresh state (the in-memory `booking` may be stale from the
|
||||
// pooled query). Only bookings not yet committed to this train pass through.
|
||||
const fresh = await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.findOne({ where: { id: booking.id } });
|
||||
if (
|
||||
fresh &&
|
||||
fresh.trainScheduleId === scheduleId &&
|
||||
(fresh.status === "SELECTED_FOR_BATCH" ||
|
||||
fresh.status === "AWAITING_PAYMENT" ||
|
||||
fresh.status === "PAID" ||
|
||||
fresh.paymentStatus === "PAID")
|
||||
) {
|
||||
this.logger.debug(
|
||||
`[BATCH] reserve skipped for ${booking.reference} — already ` +
|
||||
`${fresh.status}/${fresh.paymentStatus} on schedule ${scheduleId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const now = new Date();
|
||||
const deadline = new Date(now.getTime() + (await this.paymentWindowMs()));
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
@@ -1781,6 +1931,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
* it failed to pay for — it's back in the day pool for staff to act on.
|
||||
*/
|
||||
private async expire(booking: Booking): Promise<void> {
|
||||
const freedScheduleId = booking.trainScheduleId;
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
trainScheduleId: null,
|
||||
status: "EXPIRED",
|
||||
@@ -1789,6 +1940,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
selectedForBatchAt: null,
|
||||
} as never);
|
||||
booking.trainScheduleId = null;
|
||||
// The wagons this reservation held are back — a schedule parked at FULL
|
||||
// because of it must reopen, or it can never be filled again.
|
||||
if (freedScheduleId) await this.refreshWindowStatus(freedScheduleId);
|
||||
// An unpaid partial offer dies with the reservation — the booking stays whole.
|
||||
if (this.splitService) {
|
||||
await this.splitService.expireOpenOffer(booking.id);
|
||||
@@ -1893,7 +2047,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
need: Capacity,
|
||||
leg: CorridorLeg,
|
||||
budget: CorridorBudget,
|
||||
wagonLengths: WagonLengths,
|
||||
wagonDims: WagonDims,
|
||||
): Promise<boolean> {
|
||||
if (budget.fits(need, leg)) return true;
|
||||
const reservedCommercial = (
|
||||
@@ -1941,7 +2095,10 @@ export class BookingBatchService implements OnModuleInit {
|
||||
);
|
||||
});
|
||||
this.notifier.displaced(victim);
|
||||
budget.add(this.needFor(victim, wagonLengths), victimLeg);
|
||||
budget.add(this.needFor(victim, wagonDims), victimLeg);
|
||||
// Displacing frees wagons the same way an expiry does — don't leave the
|
||||
// schedule stuck at FULL.
|
||||
await this.refreshWindowStatus(scheduleId);
|
||||
}
|
||||
return budget.fits(need, leg);
|
||||
}
|
||||
@@ -1995,7 +2152,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
private combinedNeed(
|
||||
primary: Booking,
|
||||
partner: Booking,
|
||||
wagonLengths: WagonLengths,
|
||||
wagonDims: WagonDims,
|
||||
): Capacity {
|
||||
const containers = (b: Booking): number =>
|
||||
(b.bookingContainers ?? []).reduce((sum, c) => sum + Number(c.quantity ?? 0), 0);
|
||||
@@ -2004,15 +2161,36 @@ export class BookingBatchService implements OnModuleInit {
|
||||
totalContainers > 0
|
||||
? Math.ceil(totalContainers / MAX_TEU_SLOTS_PER_WAGON)
|
||||
: this.wagonsFor(primary) + this.wagonsFor(partner);
|
||||
const weightTons =
|
||||
const cargoTons =
|
||||
Number(primary.cargoTotalWeightVgm ?? 0) + Number(partner.cargoTotalWeightVgm ?? 0);
|
||||
return {
|
||||
wagons: sharedWagons,
|
||||
weightTons,
|
||||
lengthMeters: bookingTrainLengthMeters(primary.freightType, sharedWagons, {
|
||||
container: wagonLengths.container,
|
||||
bulk: wagonLengths.bulk,
|
||||
}),
|
||||
// Consolidation saves tare as well as slots: the pair rides `sharedWagons`
|
||||
// wagons, so it is charged `sharedWagons` tares, not one per booking.
|
||||
weightTons: bookingGrossWeightTons(
|
||||
cargoTons,
|
||||
sharedWagons,
|
||||
this.tareFor(primary.freightType, wagonDims),
|
||||
),
|
||||
lengthMeters: bookingTrainLengthMeters(
|
||||
primary.freightType,
|
||||
sharedWagons,
|
||||
this.lengthsOf(wagonDims),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Per-wagon tare for the wagon type this freight rides on. */
|
||||
private tareFor(freightType: string | null | undefined, wagonDims: WagonDims): number {
|
||||
return freightType === 'BULK'
|
||||
? wagonDims.bulk.tareWeightTons
|
||||
: wagonDims.container.tareWeightTons;
|
||||
}
|
||||
|
||||
private lengthsOf(wagonDims: WagonDims): { container: number; bulk: number } {
|
||||
return {
|
||||
container: wagonDims.container.lengthMeters,
|
||||
bulk: wagonDims.bulk.lengthMeters,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2020,26 +2198,39 @@ export class BookingBatchService implements OnModuleInit {
|
||||
if (booking.wagonsRequired && booking.wagonsRequired > 0) {
|
||||
return Math.ceil(booking.wagonsRequired);
|
||||
}
|
||||
const fromContainers = (booking.bookingContainers ?? []).reduce(
|
||||
(sum, c) => sum + Number(c.quantity ?? 0),
|
||||
0,
|
||||
);
|
||||
return Math.max(
|
||||
DEFAULT_WAGONS_PER_BOOKING,
|
||||
fromContainers || DEFAULT_WAGONS_PER_BOOKING,
|
||||
// booking.wagonsRequired is NULL for most rows (only set on certain
|
||||
// scheduling paths). Derive from the container lines, TEU-aware: two 20ft
|
||||
// share one wagon (wagonsPerUnit = 0.5). The old fallback summed raw
|
||||
// container QUANTITY, so 20×20ft counted as 20 wagons instead of 10 and
|
||||
// wrongly filled the train.
|
||||
const fromContainers = containerWagonsForLines(
|
||||
booking.bookingContainers ?? [],
|
||||
);
|
||||
return Math.max(DEFAULT_WAGONS_PER_BOOKING, fromContainers);
|
||||
}
|
||||
|
||||
/** What one booking consumes along all three capacity axes. */
|
||||
private needFor(booking: Booking, wagonLengths: WagonLengths): Capacity {
|
||||
/**
|
||||
* What one booking consumes along all three capacity axes.
|
||||
*
|
||||
* The weight axis is GROSS — cargo plus the tare of every wagon the booking
|
||||
* occupies — because it is spent against the locomotive's pull limit, which
|
||||
* governs the whole train and not just its payload. Charging cargo alone let a
|
||||
* 37-wagon box-wagon train read 2590T when it really weighed 3522T.
|
||||
*/
|
||||
private needFor(booking: Booking, wagonDims: WagonDims): Capacity {
|
||||
const wagons = this.wagonsFor(booking);
|
||||
return {
|
||||
wagons,
|
||||
weightTons: Number(booking.cargoTotalWeightVgm ?? 0),
|
||||
lengthMeters: bookingTrainLengthMeters(booking.freightType, wagons, {
|
||||
container: wagonLengths.container,
|
||||
bulk: wagonLengths.bulk,
|
||||
}),
|
||||
weightTons: bookingGrossWeightTons(
|
||||
Number(booking.cargoTotalWeightVgm ?? 0),
|
||||
wagons,
|
||||
this.tareFor(booking.freightType, wagonDims),
|
||||
),
|
||||
lengthMeters: bookingTrainLengthMeters(
|
||||
booking.freightType,
|
||||
wagons,
|
||||
this.lengthsOf(wagonDims),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2051,7 +2242,11 @@ export class BookingBatchService implements OnModuleInit {
|
||||
);
|
||||
}
|
||||
|
||||
/** Locomotive + wagon-type-derived caps (weight, length, wagon slots — not a fixed 53). */
|
||||
/**
|
||||
* Hard caps for a schedule's train: gross pull weight, train length, and the
|
||||
* length-derived wagon slot count (never a fixed 53). Bookings spend against
|
||||
* these via {@link needFor}, whose weight axis is gross.
|
||||
*/
|
||||
private async capacityLimits(
|
||||
locomotive: Locomotive,
|
||||
rules: TrainSchedulingGlobalRules | null,
|
||||
@@ -2061,6 +2256,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
{
|
||||
maxPullWeightTons: Number(locomotive.maxPullWeightTons),
|
||||
maxTrainLengthMeters: Number(locomotive.maxTrainLengthMeters),
|
||||
overageToleranceTons: Number(locomotive.overageToleranceTons) || 0,
|
||||
overageToleranceMeters: Number(locomotive.overageToleranceMeters) || 0,
|
||||
},
|
||||
wagonTypes,
|
||||
{
|
||||
@@ -2094,31 +2291,48 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
private async loadWagonTypeDimensions(): Promise<
|
||||
Array<{ lengthMeters: number; capacityTons: number }>
|
||||
> {
|
||||
const types = await this.dataSource.getRepository(WagonType).find({
|
||||
where: [{ code: "NW5" }, { code: "CW3" }],
|
||||
});
|
||||
/**
|
||||
* Every active wagon type, so the slot count is derived from the shortest wagon
|
||||
* the fleet can actually marshal rather than from an arbitrary two-code sample.
|
||||
*/
|
||||
private async loadWagonTypeDimensions(): 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,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private async loadWagonLengths(): Promise<WagonLengths> {
|
||||
/** Representative wagon per freight type: NW5 flat for containers, CW3 gondola for bulk. */
|
||||
private async loadWagonDims(): Promise<WagonDims> {
|
||||
const types = await this.dataSource.getRepository(WagonType).find({
|
||||
where: [{ code: "NW5" }, { code: "CW3" }],
|
||||
});
|
||||
const byCode = new Map(
|
||||
types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)]),
|
||||
);
|
||||
const nw5 = byCode.get("NW5");
|
||||
const cw3 = byCode.get("CW3");
|
||||
return {
|
||||
container:
|
||||
byCode.get("NW5")?.lengthMeters ??
|
||||
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||||
bulk: byCode.get("CW3")?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS,
|
||||
container: {
|
||||
lengthMeters: nw5?.lengthMeters ?? DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||||
tareWeightTons: nw5?.tareWeightTons ?? DEFAULT_CONTAINER_WAGON_TARE_TONS,
|
||||
},
|
||||
bulk: {
|
||||
lengthMeters: cw3?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS,
|
||||
tareWeightTons: cw3?.tareWeightTons ?? DEFAULT_BULK_WAGON_TARE_TONS,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2155,7 +2369,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
private async remainingBudget(
|
||||
schedule: TrainSchedule,
|
||||
limits: Capacity,
|
||||
wagonLengths: WagonLengths,
|
||||
wagonDims: WagonDims,
|
||||
): Promise<CorridorBudget> {
|
||||
const stops = await this.stopsForSchedule(schedule);
|
||||
const budget = new CorridorBudget(stops, limits);
|
||||
@@ -2167,7 +2381,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
);
|
||||
for (const b of [...allocated, ...reserved]) {
|
||||
budget.subtract(
|
||||
this.needFor(b, wagonLengths),
|
||||
this.needFor(b, wagonDims),
|
||||
budget.legForYards(b.originYardId, b.destinationYardId),
|
||||
);
|
||||
}
|
||||
@@ -2179,7 +2393,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
* ≤ 0 means no leg can take another booking — the train-wide FULL signal.
|
||||
*/
|
||||
private async remainingWagons(schedule: TrainSchedule): Promise<number> {
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const budget = await this.remainingBudget(
|
||||
schedule,
|
||||
{
|
||||
@@ -2187,7 +2401,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
weightTons: Number.POSITIVE_INFINITY,
|
||||
lengthMeters: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
wagonLengths,
|
||||
wagonDims,
|
||||
);
|
||||
return budget.maxRemaining().wagons;
|
||||
}
|
||||
@@ -2220,6 +2434,35 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return (await this.remainingWagons(schedule)) <= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-derive `bookingWindowStatus` from live capacity after wagons were freed
|
||||
* (a reservation expired, a booking was displaced, a link was removed).
|
||||
*
|
||||
* FULL used to be a one-way door: `isFillable()` rejects a FULL schedule before
|
||||
* it ever looks at the budget, and the only writers of OPEN skip a FULL row. So
|
||||
* a train that filled once and then lost every booking to expiry stayed FULL
|
||||
* with all its wagons free — permanently unfillable, cycling PRE_WINDOW→PAYMENT
|
||||
* forever while `concludeCycle` (which reads real capacity, not the flag) kept
|
||||
* reopening it. Clearing FULL here is what lets the next batch actually run.
|
||||
*
|
||||
* Only the customer-facing OPEN phases may go back to OPEN; a schedule mid
|
||||
* DOC_REVIEW/PAYMENT drops to CLOSED, which `isFillable()` still admits.
|
||||
*/
|
||||
async refreshWindowStatus(scheduleId: string): Promise<void> {
|
||||
const schedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule || schedule.bookingWindowStatus !== "FULL") return;
|
||||
if ((await this.remainingWagons(schedule)) <= 0) return;
|
||||
|
||||
const customerWindowOpen =
|
||||
schedule.windowPhase == null || schedule.windowPhase === "OPEN";
|
||||
await this.setWindow(scheduleId, customerWindowOpen ? "OPEN" : "CLOSED");
|
||||
this.logger.log(
|
||||
`[BATCH] ${scheduleId} cleared stale FULL — wagons freed, window is now ` +
|
||||
`${customerWindowOpen ? "OPEN" : "CLOSED"} and the batch can fill it again`,
|
||||
);
|
||||
}
|
||||
|
||||
// ---- timer plumbing -------------------------------------------------------
|
||||
|
||||
/** Configured customer pay window in ms (global rules, with defaults). */
|
||||
@@ -2256,6 +2499,45 @@ export class BookingBatchService implements OnModuleInit {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A top-up reservation (settle freed capacity mid-cycle, so the next waiting
|
||||
* booking got a fresh pay window) sets a NEW paymentDeadline. But the schedule's
|
||||
* `paymentPhaseEndsAt` — which the window tick watches to end PAYMENT and run
|
||||
* concludeCycle — was frozen when the phase started. Without this, concludeCycle
|
||||
* fires before the top-up customer's deadline and expires a booking that still
|
||||
* had time to pay. Push `paymentPhaseEndsAt` to at least cover a full payment
|
||||
* window from now, but never past departure. Only while the schedule is still
|
||||
* in the PAYMENT phase (a reopened cycle manages its own phase).
|
||||
*/
|
||||
async extendPaymentPhaseForTopUp(scheduleId: string): Promise<void> {
|
||||
const schedule = await this.dataSource
|
||||
.getRepository(TrainSchedule)
|
||||
.findOne({ where: { id: scheduleId } });
|
||||
if (!schedule || schedule.windowPhase !== "PAYMENT") return;
|
||||
const windowMs = await this.paymentWindowMs();
|
||||
let target = new Date(Date.now() + windowMs);
|
||||
if (
|
||||
schedule.scheduledDepartureDate &&
|
||||
target > schedule.scheduledDepartureDate
|
||||
) {
|
||||
target = schedule.scheduledDepartureDate;
|
||||
}
|
||||
// Only ever push the deadline OUT, never pull it in.
|
||||
if (
|
||||
schedule.paymentPhaseEndsAt &&
|
||||
schedule.paymentPhaseEndsAt.getTime() >= target.getTime()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await this.dataSource
|
||||
.getRepository(TrainSchedule)
|
||||
.update(scheduleId, { paymentPhaseEndsAt: target });
|
||||
this.logger.log(
|
||||
`[BATCH] extended PAYMENT phase for ${scheduleId} to ${target.toISOString()} ` +
|
||||
`(top-up reservation opened a fresh pay window)`,
|
||||
);
|
||||
}
|
||||
|
||||
private removeTimeout(scheduleId: string): void {
|
||||
const name = this.timeoutName(scheduleId);
|
||||
try {
|
||||
|
||||
@@ -328,6 +328,18 @@ export class BookingWindowService implements OnModuleInit {
|
||||
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.
|
||||
|
||||
@@ -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 }> {
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
@@ -2947,8 +2954,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 +2975,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 +3033,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 +3063,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 +3128,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 +3458,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 +3715,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 +4218,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 +4241,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,
|
||||
});
|
||||
|
||||
|
||||
@@ -361,6 +361,16 @@ export class WarehouseInventoryController {
|
||||
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) {
|
||||
|
||||
@@ -2603,12 +2603,13 @@ 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;
|
||||
@@ -2624,6 +2625,7 @@ export class WarehouseInventoryService {
|
||||
truckPlate: string | null;
|
||||
truckArrived: boolean;
|
||||
truckLeft: boolean;
|
||||
loaded: boolean;
|
||||
bookingReference: string | null;
|
||||
contractId: string | null;
|
||||
hasLastMile: boolean;
|
||||
@@ -2637,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",
|
||||
@@ -2666,22 +2669,28 @@ export class WarehouseInventoryService {
|
||||
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,
|
||||
@@ -3016,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,
|
||||
@@ -3242,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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -227,7 +227,6 @@ async function ensureReferences(manager: any) {
|
||||
name: 'Gate Pass Demo Flat Wagon',
|
||||
capacityTons: 70,
|
||||
lengthMeters: 14,
|
||||
maxWagonsPerTrain: 53,
|
||||
supportedLoadTypes: ['CONTAINER'],
|
||||
isActive: true,
|
||||
equatedLengthM: 14,
|
||||
|
||||
@@ -102,7 +102,6 @@ async function main() {
|
||||
name: 'Negad Demo Flat Wagon',
|
||||
capacityTons: 70,
|
||||
lengthMeters: 14,
|
||||
maxWagonsPerTrain: 53,
|
||||
supportedLoadTypes: ['CONTAINER'],
|
||||
isActive: true,
|
||||
equatedLengthM: 14,
|
||||
|
||||
@@ -211,7 +211,6 @@ export class DemoBookingsSeeder {
|
||||
name: "Flat Wagon",
|
||||
capacityTons: 70,
|
||||
lengthMeters: 14,
|
||||
maxWagonsPerTrain: 53,
|
||||
supportedLoadTypes: ["CONTAINER"],
|
||||
isActive: true,
|
||||
equatedLengthM: 14,
|
||||
@@ -224,7 +223,6 @@ export class DemoBookingsSeeder {
|
||||
name: "Covered Hopper",
|
||||
capacityTons: 60,
|
||||
lengthMeters: 12,
|
||||
maxWagonsPerTrain: 55,
|
||||
supportedLoadTypes: ["BULK"],
|
||||
isActive: true,
|
||||
equatedLengthM: 12,
|
||||
@@ -236,7 +234,6 @@ export class DemoBookingsSeeder {
|
||||
name: "Powder Wagon",
|
||||
capacityTons: 55,
|
||||
lengthMeters: 12,
|
||||
maxWagonsPerTrain: 55,
|
||||
supportedLoadTypes: ["BULK"],
|
||||
isActive: true,
|
||||
equatedLengthM: 12,
|
||||
@@ -248,7 +245,6 @@ export class DemoBookingsSeeder {
|
||||
name: "Open Wagon",
|
||||
capacityTons: 65,
|
||||
lengthMeters: 13,
|
||||
maxWagonsPerTrain: 53,
|
||||
supportedLoadTypes: ["BULK"],
|
||||
isActive: true,
|
||||
equatedLengthM: 13,
|
||||
|
||||
@@ -204,6 +204,7 @@ export const FLEET_ROAD_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm('e2b00001-0001-4000-8000-000000000003', 'edr_freight_app:drivers:update', 'Update driver'),
|
||||
perm('e2b00001-0001-4000-8000-000000000004', 'edr_freight_app:drivers:delete', 'Delete driver'),
|
||||
perm('e2c00001-0001-4000-8000-000000000001', 'edr_freight_app:tracking:view', 'Track vehicles'),
|
||||
perm('e2c00001-0001-4000-8000-000000000002', 'edr_freight_app:tracking:manage', 'Manage GPS trackers'),
|
||||
perm('e2d00001-0001-4000-8000-000000000001', 'edr_freight_app:fuel:view', 'View fuel purchases'),
|
||||
perm('e2d00001-0001-4000-8000-000000000002', 'edr_freight_app:fuel:create', 'Create fuel purchase'),
|
||||
perm('e2d00001-0001-4000-8000-000000000003', 'edr_freight_app:fuel:update', 'Update fuel purchase'),
|
||||
@@ -477,6 +478,7 @@ export const FREIGHT_PERMS = {
|
||||
},
|
||||
tracking: {
|
||||
view: 'edr_freight_app:tracking:view',
|
||||
manage: 'edr_freight_app:tracking:manage',
|
||||
},
|
||||
fuel: {
|
||||
view: 'edr_freight_app:fuel:view',
|
||||
|
||||
@@ -50,6 +50,13 @@ import {
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
|
||||
/** Today as `yyyy-MM-dd` in the browser's local zone (a DateInput `minDate`). */
|
||||
function todayISODate(): string {
|
||||
const now = new Date();
|
||||
const tz = now.getTimezoneOffset() * 60000;
|
||||
return new Date(now.getTime() - tz).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export customs flow, ordered per the stakeholder process:
|
||||
* customer docs → RO (DJ) → declaration (ET, auto-releases) → create booking (ET)
|
||||
@@ -937,6 +944,7 @@ export function ReleaseOrderCard({
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [amendLoading, setAmendLoading] = useState(false);
|
||||
const minVesselDate = useMemo(todayISODate, []);
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
@@ -949,6 +957,7 @@ export function ReleaseOrderCard({
|
||||
label="Vessel departure date"
|
||||
value={vesselDate}
|
||||
onChange={(v) => setVesselDate(v ? new Date(v) : null)}
|
||||
minDate={minVesselDate}
|
||||
size="sm"
|
||||
/>
|
||||
<Group>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button, Group, Modal, Stack, Text } from "@mantine/core";
|
||||
import { DateInput } from "@mantine/dates";
|
||||
import { Ship, Upload } from "lucide-react";
|
||||
@@ -40,6 +40,13 @@ export function GlClearanceUploadModal({
|
||||
vesselDepartureDate ? new Date(vesselDepartureDate) : null,
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
// Earliest selectable vessel date (today, local) — refreshed on each open.
|
||||
const todayISODate = useMemo(() => {
|
||||
if (!opened) return undefined;
|
||||
const now = new Date();
|
||||
const tz = now.getTimezoneOffset() * 60000;
|
||||
return new Date(now.getTime() - tz).toISOString().slice(0, 10);
|
||||
}, [opened]);
|
||||
|
||||
const isDo = kind === "do";
|
||||
const isRo = kind === "ro";
|
||||
@@ -115,6 +122,7 @@ export function GlClearanceUploadModal({
|
||||
label="Vessel departure date"
|
||||
value={vesselDate}
|
||||
onChange={(v) => setVesselDate(v ? new Date(v) : null)}
|
||||
minDate={todayISODate}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
@@ -123,6 +131,7 @@ export function GlClearanceUploadModal({
|
||||
label="Vessel arrival date (optional)"
|
||||
value={vesselDate}
|
||||
onChange={(v) => setVesselDate(v ? new Date(v) : null)}
|
||||
minDate={todayISODate}
|
||||
size="sm"
|
||||
clearable
|
||||
/>
|
||||
|
||||
@@ -307,13 +307,23 @@ const RuleEngineFormDialog = ({
|
||||
);
|
||||
}
|
||||
|
||||
const isNumber = field.type === "number";
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
key={field.name}
|
||||
label={label}
|
||||
type={field.type === "number" ? "number" : field.type === "date" ? "date" : "text"}
|
||||
type={isNumber ? "number" : field.type === "date" ? "date" : "text"}
|
||||
// Every rule-engine number (sizes, capacities, counts, points, rates,
|
||||
// display order) is a non-negative magnitude — reject negatives outright
|
||||
// rather than letting a typed "-" reach the API.
|
||||
min={isNumber ? 0 : undefined}
|
||||
value={String(values[field.name] ?? "")}
|
||||
onChange={(e) => setField(field.name, e.currentTarget.value)}
|
||||
onChange={(e) => {
|
||||
const next = e.currentTarget.value;
|
||||
if (isNumber && next.trim().startsWith("-")) return;
|
||||
setField(field.name, next);
|
||||
}}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
size="md"
|
||||
|
||||
@@ -392,6 +392,7 @@ export default function BookingWindowSettingsModal({
|
||||
}
|
||||
min={1}
|
||||
clampBehavior="none"
|
||||
allowNegative={false}
|
||||
allowDecimal={false}
|
||||
/>
|
||||
) : (
|
||||
@@ -410,6 +411,7 @@ export default function BookingWindowSettingsModal({
|
||||
}
|
||||
min={0}
|
||||
clampBehavior="none"
|
||||
allowNegative={false}
|
||||
allowDecimal={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -98,6 +98,7 @@ export default function DurationField({
|
||||
emitNative(v === "" ? "" : Number(v), unit)
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowNegative={false}
|
||||
allowDecimal
|
||||
min={min != null ? convert(min, nativeUnit, unit) : 0}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -67,9 +67,13 @@ export default function EditScheduleDateModal({
|
||||
);
|
||||
|
||||
const [value, setValue] = useState("");
|
||||
// Earliest selectable departure, refreshed each time the modal opens.
|
||||
const [minValue, setMinValue] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) setValue(toLocalInputValue(currentDate));
|
||||
if (!opened) return;
|
||||
setValue(toLocalInputValue(currentDate));
|
||||
setMinValue(toLocalInputValue(new Date().toISOString()));
|
||||
}, [opened, currentDate]);
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -77,6 +81,13 @@ export default function EditScheduleDateModal({
|
||||
toast({ title: "Pick a departure date", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
if (new Date(value).getTime() < Date.now()) {
|
||||
toast({
|
||||
title: "Departure date must be in the future",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await save.mutateAsync({
|
||||
id: scheduleId,
|
||||
@@ -124,6 +135,7 @@ export default function EditScheduleDateModal({
|
||||
<TextInput
|
||||
label="Departure date"
|
||||
type="datetime-local"
|
||||
min={minValue}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button, Group, Modal, Stack, Text, TextInput, Textarea } from "@mantine/core";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { trainSchedulingService } from "@/services/trainScheduling.service";
|
||||
|
||||
/** `min` for a `datetime-local` input: now, in the browser's local zone. */
|
||||
function nowLocalDateTime(): string {
|
||||
const now = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return (
|
||||
`${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` +
|
||||
`T${pad(now.getHours())}:${pad(now.getMinutes())}`
|
||||
);
|
||||
}
|
||||
|
||||
export function RescheduleTrainDialog({
|
||||
scheduleId,
|
||||
currentBookingIds,
|
||||
@@ -20,12 +30,21 @@ export function RescheduleTrainDialog({
|
||||
const [newDepartureDate, setNewDepartureDate] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
// Earliest selectable departure, refreshed each time the dialog opens.
|
||||
const minDepartureDate = useMemo(
|
||||
() => (opened ? nowLocalDateTime() : ""),
|
||||
[opened],
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!newDepartureDate) {
|
||||
toast.error("Select a new departure date");
|
||||
return;
|
||||
}
|
||||
if (new Date(newDepartureDate).getTime() < Date.now()) {
|
||||
toast.error("New departure must be in the future");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await trainSchedulingService.maintenanceReschedule(scheduleId, {
|
||||
@@ -53,6 +72,7 @@ export function RescheduleTrainDialog({
|
||||
<TextInput
|
||||
label="New departure"
|
||||
type="datetime-local"
|
||||
min={minDepartureDate}
|
||||
value={newDepartureDate}
|
||||
onChange={(e) => setNewDepartureDate(e.target.value)}
|
||||
/>
|
||||
|
||||
@@ -37,6 +37,7 @@ const STAGE_TABS: Array<{ value: string; label: string }> = [
|
||||
{ value: 'ALL', label: 'All' },
|
||||
{ value: 'RECEIVED', label: 'Received' },
|
||||
{ value: 'GRN', label: "GRN'd" },
|
||||
{ value: 'ASSIGNED', label: 'Assigned' },
|
||||
{ value: 'LOADED', label: 'Loaded' },
|
||||
{ value: 'LEFT', label: 'Left' },
|
||||
{ value: 'DELIVERED', label: 'Delivered' },
|
||||
@@ -46,13 +47,15 @@ const STAGE_COLOR: Record<ContainerItemStage, string> = {
|
||||
PENDING: 'gray',
|
||||
RECEIVED: 'blue',
|
||||
GRN: 'teal',
|
||||
ASSIGNED: 'indigo',
|
||||
LOADED: 'grape',
|
||||
LEFT: 'orange',
|
||||
DELIVERED: 'green',
|
||||
};
|
||||
|
||||
/** Loadable = not yet on a truck (before LOADED). */
|
||||
const isLoadable = (i: ContainerItem) => i.stage === 'PENDING' || i.stage === 'RECEIVED' || i.stage === 'GRN';
|
||||
/** Loadable = not yet loaded (PENDING/RECEIVED/GRN, or customer-ASSIGNED awaiting load). */
|
||||
const isLoadable = (i: ContainerItem) =>
|
||||
i.stage === 'PENDING' || i.stage === 'RECEIVED' || i.stage === 'GRN' || i.stage === 'ASSIGNED';
|
||||
|
||||
export function ContainerItemsModal({ opened, onClose, bookingId, bookingReference }: ContainerItemsModalProps) {
|
||||
const { toast } = useToast();
|
||||
@@ -77,8 +80,13 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
|
||||
() => (tab === 'ALL' ? items : items.filter((i) => i.stage === tab)),
|
||||
[items, tab],
|
||||
);
|
||||
// Only arrived, not-yet-departed trucks can be loaded.
|
||||
const truckOptions = trucks
|
||||
.filter((t) => !(t as { departedAt?: string }).departedAt)
|
||||
.filter(
|
||||
(t) =>
|
||||
Boolean((t as { arrivedAt?: string }).arrivedAt) &&
|
||||
!(t as { departedAt?: string }).departedAt,
|
||||
)
|
||||
.map((t) => ({ value: t.id, label: `${t.plateNumber} · ${t.driverName}` }));
|
||||
|
||||
const loadMutation = useMutation({
|
||||
@@ -181,7 +189,7 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
|
||||
<Table.Td>{i.contractId ? <Badge variant="outline" color="indigo">Contract</Badge> : '—'}</Table.Td>
|
||||
<Table.Td>{i.hasLastMile ? <Badge variant="light" color="cyan">EDR</Badge> : <Badge variant="light" color="gray">Self-haul</Badge>}</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
{i.truckAssignmentId && (
|
||||
{i.loaded && i.truckAssignmentId && (
|
||||
<Tooltip
|
||||
label="Sign the handover first — a truck can't get its exit paper until the handover is signed."
|
||||
disabled={i.handoverSigned}
|
||||
|
||||
@@ -347,8 +347,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
placeholder="Select the assigned truck"
|
||||
searchable
|
||||
clearable
|
||||
// Enabled at arrival so the operator picks which assigned truck came;
|
||||
// only locked on the exit (leaving) step once identity is captured.
|
||||
disabled={isEntranceLocked}
|
||||
data={truckSelectOptions}
|
||||
disabled={isTruckIdentityLocked}
|
||||
value={truckSelectOptions.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
|
||||
onChange={(value) => {
|
||||
const truck = truckSelectOptions.find((row) => row.value === value);
|
||||
|
||||
@@ -145,6 +145,7 @@ export const FREIGHT_PERMS = {
|
||||
},
|
||||
tracking: {
|
||||
view: "edr_freight_app:tracking:view",
|
||||
manage: "edr_freight_app:tracking:manage",
|
||||
},
|
||||
fuel: {
|
||||
view: "edr_freight_app:fuel:view",
|
||||
|
||||
@@ -536,7 +536,7 @@ export function WagonTypesCrudPage() {
|
||||
name: '',
|
||||
capacityTons: 0,
|
||||
lengthMeters: 0,
|
||||
maxWagonsPerTrain: '',
|
||||
tareWeightTons: '',
|
||||
supportedLoadTypes: '',
|
||||
isActive: true,
|
||||
});
|
||||
@@ -587,7 +587,7 @@ export function WagonTypesCrudPage() {
|
||||
name: '',
|
||||
capacityTons: 0,
|
||||
lengthMeters: 0,
|
||||
maxWagonsPerTrain: '',
|
||||
tareWeightTons: '',
|
||||
supportedLoadTypes: '',
|
||||
isActive: true,
|
||||
});
|
||||
@@ -601,7 +601,7 @@ export function WagonTypesCrudPage() {
|
||||
name: type.name ?? '',
|
||||
capacityTons: type.capacityTons ?? 0,
|
||||
lengthMeters: type.lengthMeters ?? 0,
|
||||
maxWagonsPerTrain: type.maxWagonsPerTrain ?? '',
|
||||
tareWeightTons: type.tareWeightTons ?? '',
|
||||
supportedLoadTypes: type.supportedLoadTypes?.join(', ') ?? '',
|
||||
isActive: type.isActive,
|
||||
});
|
||||
@@ -610,10 +610,17 @@ export function WagonTypesCrudPage() {
|
||||
|
||||
const validateWagonType = () => {
|
||||
const errors: Record<string, string> = {};
|
||||
// normalizePayload strips empty strings, so a blank numeric field would be
|
||||
// dropped from the payload rather than rejected. Each must be a positive
|
||||
// number here — the API's @Min(0.001) agrees.
|
||||
const positive = (value: FormValue) => Number.isFinite(Number(value)) && Number(value) > 0;
|
||||
|
||||
if (!String(form.code ?? '').trim()) errors.code = 'Code is required';
|
||||
if (!String(form.name ?? '').trim()) errors.name = 'Name is required';
|
||||
if (!Number.isFinite(Number(form.capacityTons))) errors.capacityTons = 'Capacity must be a valid number';
|
||||
if (!Number.isFinite(Number(form.lengthMeters))) errors.lengthMeters = 'Length must be a valid number';
|
||||
if (!positive(form.capacityTons)) errors.capacityTons = 'Capacity must be greater than 0';
|
||||
if (!positive(form.lengthMeters)) errors.lengthMeters = 'Length must be greater than 0';
|
||||
if (!positive(form.tareWeightTons))
|
||||
errors.tareWeightTons = 'Tare weight is required and must be greater than 0';
|
||||
return errors;
|
||||
};
|
||||
|
||||
@@ -710,6 +717,7 @@ export function WagonTypesCrudPage() {
|
||||
</MantineButton>
|
||||
</MantineTable.Th>
|
||||
<MantineTable.Th>Length (m)</MantineTable.Th>
|
||||
<MantineTable.Th>Tare weight (tons)</MantineTable.Th>
|
||||
<MantineTable.Th>Load types</MantineTable.Th>
|
||||
<MantineTable.Th>Status</MantineTable.Th>
|
||||
<MantineTable.Th ta="right">Actions</MantineTable.Th>
|
||||
@@ -722,6 +730,7 @@ export function WagonTypesCrudPage() {
|
||||
<MantineTable.Td>{type.name}</MantineTable.Td>
|
||||
<MantineTable.Td>{type.capacityTons}</MantineTable.Td>
|
||||
<MantineTable.Td>{type.lengthMeters}</MantineTable.Td>
|
||||
<MantineTable.Td>{type.tareWeightTons ?? '-'}</MantineTable.Td>
|
||||
<MantineTable.Td>{type.supportedLoadTypes?.join(', ') || '-'}</MantineTable.Td>
|
||||
<MantineTable.Td>
|
||||
<MantineBadge color={type.isActive === false ? 'gray' : 'edr-green'} variant="light">
|
||||
@@ -750,7 +759,7 @@ export function WagonTypesCrudPage() {
|
||||
))}
|
||||
{!query.isLoading && filtered.length === 0 ? (
|
||||
<MantineTable.Tr>
|
||||
<MantineTable.Td colSpan={7}>
|
||||
<MantineTable.Td colSpan={8}>
|
||||
<Text ta="center" c="dimmed" py="xl">
|
||||
No wagon types found.
|
||||
</Text>
|
||||
@@ -759,7 +768,7 @@ export function WagonTypesCrudPage() {
|
||||
) : null}
|
||||
{query.isLoading ? (
|
||||
<MantineTable.Tr>
|
||||
<MantineTable.Td colSpan={7}>
|
||||
<MantineTable.Td colSpan={8}>
|
||||
<Text ta="center" c="dimmed" py="xl">
|
||||
Loading...
|
||||
</Text>
|
||||
@@ -815,10 +824,13 @@ export function WagonTypesCrudPage() {
|
||||
onChange={(value) => setForm((current) => ({ ...current, lengthMeters: value }))}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Max wagons per train"
|
||||
label="Tare weight (tons)"
|
||||
description="Empty wagon weight — counts against the locomotive's pull limit alongside the cargo"
|
||||
required
|
||||
min={0}
|
||||
value={form.maxWagonsPerTrain === '' ? '' : Number(form.maxWagonsPerTrain)}
|
||||
onChange={(value) => setForm((current) => ({ ...current, maxWagonsPerTrain: value }))}
|
||||
value={form.tareWeightTons === '' || form.tareWeightTons == null ? '' : Number(form.tareWeightTons)}
|
||||
error={fieldErrors.tareWeightTons}
|
||||
onChange={(value) => setForm((current) => ({ ...current, tareWeightTons: value }))}
|
||||
/>
|
||||
<MantineSelect
|
||||
label="Status"
|
||||
@@ -1118,6 +1130,16 @@ export function LocomotivesCrudPage() {
|
||||
{ key: 'status', label: 'Status', render: (locomotive) => statusBadge(locomotive.status) },
|
||||
{ key: 'maxPullWeightTons', label: 'Max pull (tons)' },
|
||||
{ key: 'maxTrainLengthMeters', label: 'Max length (m)' },
|
||||
{
|
||||
key: 'overageToleranceTons',
|
||||
label: 'Weight tolerance (t)',
|
||||
render: (locomotive) => locomotive.overageToleranceTons ?? '-',
|
||||
},
|
||||
{
|
||||
key: 'overageToleranceMeters',
|
||||
label: 'Length tolerance (m)',
|
||||
render: (locomotive) => locomotive.overageToleranceMeters ?? '-',
|
||||
},
|
||||
]}
|
||||
fields={[
|
||||
{ key: 'code', label: 'Code', required: true },
|
||||
@@ -1146,6 +1168,11 @@ export function LocomotivesCrudPage() {
|
||||
},
|
||||
{ key: 'maxPullWeightTons', label: 'Max pulling weight (tons)', type: 'number', required: true },
|
||||
{ key: 'maxTrainLengthMeters', label: 'Max train length (meters)', type: 'number', required: true },
|
||||
// Scheduling accepts a consist up to (max + tolerance) on each axis: 37 PW2
|
||||
// wagons gross 3,522.4T against a 3,500T pull limit and only board because
|
||||
// of the weight tolerance.
|
||||
{ key: 'overageToleranceTons', label: 'Weight tolerance (tons over max pull)', type: 'number' },
|
||||
{ key: 'overageToleranceMeters', label: 'Length tolerance (meters over max length)', type: 'number' },
|
||||
{ key: 'powerKw', label: 'Power (kW)', type: 'number' },
|
||||
{ key: 'tractionForceKn', label: 'Traction force (kN)', type: 'number' },
|
||||
{ key: 'maxSpeedKmh', label: 'Max speed (km/h)', type: 'number' },
|
||||
@@ -1157,6 +1184,8 @@ export function LocomotivesCrudPage() {
|
||||
status: 'AVAILABLE',
|
||||
maxPullWeightTons: 0,
|
||||
maxTrainLengthMeters: 760,
|
||||
overageToleranceTons: '',
|
||||
overageToleranceMeters: '',
|
||||
powerKw: '',
|
||||
tractionForceKn: '',
|
||||
maxSpeedKmh: '',
|
||||
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
import { Activity, Pencil, Plus, Radio, Trash2 } from "lucide-react";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { vehiclesService } from "@/services/vehicles.service";
|
||||
import { gpsTrackingService, type GpsDevice } from "@/services/gps-tracking.service";
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
@@ -151,6 +153,8 @@ function RouteTrail({ path }: { path: LatLng[] }) {
|
||||
export function TrackingPage() {
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
const canManage = hasPermission(user, FREIGHT_PERMS.tracking.manage);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [hoverId, setHoverId] = useState<string | null>(null);
|
||||
const [mapsReady, setMapsReady] = useState(false);
|
||||
@@ -288,9 +292,11 @@ export function TrackingPage() {
|
||||
<Text fw={700} size="xl">Real-Time Vehicle Tracking</Text>
|
||||
<Text c="dimmed" size="sm">Live GPS positions from GT06 trackers</Text>
|
||||
</div>
|
||||
<Button leftSection={<Plus size={16} />} color="edr-green" onClick={openRegister}>
|
||||
Register tracker
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Button leftSection={<Plus size={16} />} color="edr-green" onClick={openRegister}>
|
||||
Register tracker
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Grid>
|
||||
@@ -358,9 +364,11 @@ export function TrackingPage() {
|
||||
<Badge color={selected.online ? "edr-green" : "gray"} leftSection={<Activity size={12} />}>
|
||||
{selected.online ? "Live" : "Offline"}
|
||||
</Badge>
|
||||
<ActionIcon variant="subtle" color="red" aria-label="Remove tracker" onClick={() => deleteMutation.mutate(selected.id)}>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
{canManage && (
|
||||
<ActionIcon variant="subtle" color="red" aria-label="Remove tracker" onClick={() => deleteMutation.mutate(selected.id)}>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
@@ -393,6 +401,7 @@ export function TrackingPage() {
|
||||
data={vehicleOptions}
|
||||
value={selected.vehicleId ?? null}
|
||||
onChange={(v) => assignMutation.mutate({ id: selected.id, vehicleId: v })}
|
||||
disabled={!canManage}
|
||||
searchable
|
||||
clearable
|
||||
/>
|
||||
@@ -421,14 +430,16 @@ export function TrackingPage() {
|
||||
<Table.Td align="right">
|
||||
<Group gap={6} justify="flex-end" wrap="nowrap">
|
||||
<Badge color={d.online ? "edr-green" : "gray"} size="sm">{d.online ? "Live" : "Offline"}</Badge>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
aria-label="Edit tracker"
|
||||
onClick={(e) => { e.stopPropagation(); openEdit(d); }}
|
||||
>
|
||||
<Pencil size={15} />
|
||||
</ActionIcon>
|
||||
{canManage && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
aria-label="Edit tracker"
|
||||
onClick={(e) => { e.stopPropagation(); openEdit(d); }}
|
||||
>
|
||||
<Pencil size={15} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
|
||||
@@ -158,6 +158,8 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
||||
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
|
||||
{ id: "maxPullWeightTons", header: "Max pull (tons)", accessorKey: "maxPullWeightTons", format: "number" },
|
||||
{ id: "maxTrainLengthMeters", header: "Max length (m)", accessorKey: "maxTrainLengthMeters", format: "number" },
|
||||
{ id: "overageToleranceTons", header: "Weight tolerance (t)", accessorKey: "overageToleranceTons", format: "number" },
|
||||
{ id: "overageToleranceMeters", header: "Length tolerance (m)", accessorKey: "overageToleranceMeters", format: "number" },
|
||||
],
|
||||
// Code is auto-generated server-side (LOCO-NNN) — omitted from the form.
|
||||
formFields: [
|
||||
@@ -167,6 +169,11 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
||||
{ name: "currentYardId", label: "Current Yard", type: "select", dynamicOptions: "yards" },
|
||||
{ name: "maxPullWeightTons", label: "Max pulling weight (tons)", type: "number", required: true },
|
||||
{ name: "maxTrainLengthMeters", label: "Max train length (meters)", type: "number", required: true },
|
||||
// Scheduling accepts a consist up to (max + tolerance) on each axis: 37 PW2
|
||||
// wagons gross 3,522.4T against a 3,500T pull limit and only board because
|
||||
// of the weight tolerance.
|
||||
{ name: "overageToleranceTons", label: "Weight tolerance (tons over max pull)", type: "number" },
|
||||
{ name: "overageToleranceMeters", label: "Length tolerance (meters over max length)", type: "number" },
|
||||
{ name: "powerKw", label: "Power (kW)", type: "number" },
|
||||
{ name: "tractionForceKn", label: "Traction force (kN)", type: "number" },
|
||||
{ name: "maxSpeedKmh", label: "Max speed (km/h)", type: "number" },
|
||||
@@ -178,6 +185,8 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
||||
currentYardId: "",
|
||||
maxPullWeightTons: 2500,
|
||||
maxTrainLengthMeters: 760,
|
||||
overageToleranceTons: "",
|
||||
overageToleranceMeters: "",
|
||||
powerKw: "",
|
||||
tractionForceKn: "",
|
||||
maxSpeedKmh: "",
|
||||
|
||||
@@ -279,7 +279,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
{ id: "name", header: "Name", accessorKey: "name" },
|
||||
{ id: "capacityTons", header: "Capacity (t)", accessorKey: "capacityTons", format: "number" },
|
||||
{ id: "lengthMeters", header: "Length (m)", accessorKey: "lengthMeters", format: "number" },
|
||||
{ id: "maxWagonsPerTrain", header: "Max / train", accessorKey: "maxWagonsPerTrain", format: "number" },
|
||||
{
|
||||
id: "supportedLoadTypes",
|
||||
header: "Load types",
|
||||
@@ -291,12 +290,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
{ name: "name", label: "Name", type: "text", required: true },
|
||||
{ name: "capacityTons", label: "Capacity (tons)", type: "number", required: true },
|
||||
{ name: "lengthMeters", label: "Length (meters)", type: "number", required: true },
|
||||
{
|
||||
name: "maxWagonsPerTrain",
|
||||
label: "Max wagons per train",
|
||||
type: "number",
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
name: "supportedLoadTypes",
|
||||
label: "Supported load types",
|
||||
|
||||
@@ -175,6 +175,7 @@ function CapacityChip({
|
||||
);
|
||||
}
|
||||
|
||||
/** Gross weight (wagon tare + cargo) against the locomotive's pull limit. */
|
||||
function weightPctOf(s: BatchBoardSchedule) {
|
||||
return s.capacity.maxWeightTons && s.capacity.maxWeightTons > 0
|
||||
? (s.capacity.usedWeightTons / s.capacity.maxWeightTons) * 100
|
||||
@@ -185,6 +186,11 @@ function lengthPctOf(s: BatchBoardSchedule) {
|
||||
? (s.capacity.allocatedLengthMeters / s.capacity.maxLengthMeters) * 100
|
||||
: null;
|
||||
}
|
||||
function wagonPctOf(s: BatchBoardSchedule) {
|
||||
return s.capacity.maxWagons && s.capacity.maxWagons > 0
|
||||
? (s.capacity.allocatedWagons / s.capacity.maxWagons) * 100
|
||||
: null;
|
||||
}
|
||||
|
||||
function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
|
||||
const navigate = useNavigate();
|
||||
@@ -192,6 +198,7 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
|
||||
|
||||
const lengthPct = lengthPctOf(schedule);
|
||||
const weightPct = weightPctOf(schedule);
|
||||
const wagonPct = wagonPctOf(schedule);
|
||||
|
||||
const totalBookings = totalBookingCount(counts);
|
||||
|
||||
@@ -275,7 +282,7 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{/* capacity: weight + length rings + wagons (numbers preserved) */}
|
||||
{/* capacity: the three axes a train is limited by — gross weight, wagon slots, length */}
|
||||
<Box
|
||||
py="sm"
|
||||
px="xs"
|
||||
@@ -289,26 +296,35 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
|
||||
{weightPct != null ? (
|
||||
<CapacityRing
|
||||
pct={weightPct}
|
||||
label="WEIGHT"
|
||||
label="GROSS WT"
|
||||
current={fmtTons(capacity.usedWeightTons)}
|
||||
max={fmtTons(capacity.maxWeightTons ?? 0)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Stack gap={0} align="center" style={{ flex: 1 }}>
|
||||
<ThemeIcon size={34} radius="md" variant="light" color="gray">
|
||||
<Package size={17} />
|
||||
</ThemeIcon>
|
||||
<Text fw={800} size="26px" c="dark.5" lh={1.1} mt={6}>
|
||||
{capacity.allocatedWagons}
|
||||
</Text>
|
||||
<Text size="9px" fw={700} c="gray.6" tt="uppercase" style={{ letterSpacing: 0.5 }}>
|
||||
Wagons
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
allocated
|
||||
</Text>
|
||||
</Stack>
|
||||
{wagonPct != null ? (
|
||||
<CapacityRing
|
||||
pct={wagonPct}
|
||||
label="WAGONS"
|
||||
current={String(capacity.allocatedWagons)}
|
||||
max={String(capacity.maxWagons ?? 0)}
|
||||
/>
|
||||
) : (
|
||||
<Stack gap={0} align="center" style={{ flex: 1 }}>
|
||||
<ThemeIcon size={34} radius="md" variant="light" color="gray">
|
||||
<Package size={17} />
|
||||
</ThemeIcon>
|
||||
<Text fw={800} size="26px" c="dark.5" lh={1.1} mt={6}>
|
||||
{capacity.allocatedWagons}
|
||||
</Text>
|
||||
<Text size="9px" fw={700} c="gray.6" tt="uppercase" style={{ letterSpacing: 0.5 }}>
|
||||
Wagons
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
allocated
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{lengthPct != null ? (
|
||||
<CapacityRing
|
||||
@@ -519,30 +535,42 @@ export default function BatchBoardPage() {
|
||||
id: "capacity",
|
||||
header: "Capacity",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<CapacityChip icon={Weight} pct={weightPctOf(row.original)} text="wt" />
|
||||
<CapacityChip icon={Ruler} pct={lengthPctOf(row.original)} text="len" />
|
||||
<Group
|
||||
gap={4}
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
padding: "2px 8px",
|
||||
borderRadius: 8,
|
||||
background: "var(--mantine-color-edr-green-0)",
|
||||
border: "1px solid var(--mantine-color-edr-green-1)",
|
||||
}}
|
||||
>
|
||||
<Package size={12} color="var(--mantine-color-edr-green-7)" />
|
||||
<Text size="xs" fw={700} c="edr-green.8" lh={1.2}>
|
||||
{row.original.capacity.allocatedWagons}
|
||||
</Text>
|
||||
<Text size="10px" c="dimmed" lh={1.2}>
|
||||
wgn
|
||||
</Text>
|
||||
cell: ({ row }) => {
|
||||
const { allocatedWagons, maxWagons } = row.original.capacity;
|
||||
const wagonPct = wagonPctOf(row.original);
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<CapacityChip icon={Weight} pct={weightPctOf(row.original)} text="gross" />
|
||||
<CapacityChip icon={Ruler} pct={lengthPctOf(row.original)} text="len" />
|
||||
{wagonPct != null ? (
|
||||
<CapacityChip
|
||||
icon={Package}
|
||||
pct={wagonPct}
|
||||
text={`${allocatedWagons}/${maxWagons} wgn`}
|
||||
/>
|
||||
) : (
|
||||
<Group
|
||||
gap={4}
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
padding: "2px 8px",
|
||||
borderRadius: 8,
|
||||
background: "var(--mantine-color-edr-green-0)",
|
||||
border: "1px solid var(--mantine-color-edr-green-1)",
|
||||
}}
|
||||
>
|
||||
<Package size={12} color="var(--mantine-color-edr-green-7)" />
|
||||
<Text size="xs" fw={700} c="edr-green.8" lh={1.2}>
|
||||
{allocatedWagons}
|
||||
</Text>
|
||||
<Text size="10px" c="dimmed" lh={1.2}>
|
||||
wgn
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "bookings",
|
||||
|
||||
@@ -892,7 +892,9 @@ export default function BatchScheduleDetailPage() {
|
||||
items={[
|
||||
{
|
||||
label: "Allocated wagons",
|
||||
value: data.capacity.allocatedWagons,
|
||||
value: data.capacity.maxWagons
|
||||
? `${data.capacity.allocatedWagons} / ${data.capacity.maxWagons}`
|
||||
: data.capacity.allocatedWagons,
|
||||
hint: "on this train",
|
||||
icon: Boxes,
|
||||
},
|
||||
@@ -904,10 +906,11 @@ export default function BatchScheduleDetailPage() {
|
||||
icon: Ruler,
|
||||
},
|
||||
{
|
||||
label: "Weight",
|
||||
label: "Gross weight",
|
||||
value: data.capacity.maxWeightTons
|
||||
? `${fmtTons(data.capacity.usedWeightTons)} / ${fmtTons(data.capacity.maxWeightTons)}`
|
||||
: fmtTons(data.capacity.usedWeightTons),
|
||||
hint: "wagon tare + cargo",
|
||||
icon: Weight,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -55,6 +55,13 @@ import { useToast } from "@/hooks/use-toast";
|
||||
import type { TrainScheduleListItem } from "@/types/trainScheduling";
|
||||
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
|
||||
|
||||
/** `min` for a `datetime-local` input: now, in the browser's local zone. */
|
||||
const nowLocalDateTime = () => {
|
||||
const now = new Date();
|
||||
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
|
||||
return now.toISOString().slice(0, 16);
|
||||
};
|
||||
|
||||
const splitDate = (value?: string | null) => {
|
||||
if (!value) return { day: "—", time: "" };
|
||||
const date = new Date(value);
|
||||
@@ -103,6 +110,12 @@ export default function TrainScheduleV2ListPage() {
|
||||
const [routeId, setRouteId] = useState("");
|
||||
const [scheduleDate, setScheduleDate] = useState("");
|
||||
const [locomotiveIds, setLocomotiveIds] = useState<string[]>([]);
|
||||
// Recomputed each time the create modal opens so a long-lived tab can't keep
|
||||
// offering a stale "now" as the earliest selectable departure.
|
||||
const minScheduleDate = useMemo(
|
||||
() => (createOpen ? nowLocalDateTime() : ""),
|
||||
[createOpen],
|
||||
);
|
||||
|
||||
const schedulesQuery = useQuery(
|
||||
api.trainScheduling.scheduleList.queryOptions({ input: {} }),
|
||||
@@ -443,6 +456,13 @@ export default function TrainScheduleV2ListPage() {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (new Date(scheduleDate).getTime() < Date.now()) {
|
||||
toast({
|
||||
title: "Departure date must be in the future",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const created = await create.mutateAsync({
|
||||
payload: {
|
||||
@@ -691,6 +711,7 @@ export default function TrainScheduleV2ListPage() {
|
||||
<TextInput
|
||||
label="Departure date"
|
||||
type="datetime-local"
|
||||
min={minScheduleDate}
|
||||
value={scheduleDate}
|
||||
onChange={(e) => setScheduleDate(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
@@ -107,6 +107,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
setForm((current) => ({ ...current, maxTrainLengthMeters: value }))
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowNegative={false}
|
||||
allowDecimal
|
||||
min={1}
|
||||
disabled={loading}
|
||||
@@ -119,6 +120,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
setForm((current) => ({ ...current, maxTrainWeightTons: value }))
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowNegative={false}
|
||||
allowDecimal
|
||||
min={1}
|
||||
disabled={loading}
|
||||
@@ -130,6 +132,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
setForm((current) => ({ ...current, maxWagonsPerTrain: value }))
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowNegative={false}
|
||||
allowDecimal
|
||||
min={1}
|
||||
disabled={loading}
|
||||
@@ -145,6 +148,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
}))
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowNegative={false}
|
||||
allowDecimal
|
||||
min={0.001}
|
||||
disabled={loading}
|
||||
@@ -160,6 +164,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
}))
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowNegative={false}
|
||||
allowDecimal
|
||||
min={0}
|
||||
disabled={loading}
|
||||
@@ -203,6 +208,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
setForm((current) => ({ ...current, windowOpenHour: value }))
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowNegative={false}
|
||||
allowDecimal
|
||||
min={0}
|
||||
max={23}
|
||||
@@ -216,6 +222,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
setForm((current) => ({ ...current, windowCloseHour: value }))
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowNegative={false}
|
||||
allowDecimal
|
||||
min={0}
|
||||
max={23}
|
||||
|
||||
@@ -27,6 +27,10 @@ export interface Locomotive {
|
||||
currentYard?: { id: string; label?: string; code?: string } | null;
|
||||
maxPullWeightTons: number;
|
||||
maxTrainLengthMeters: number;
|
||||
/** Tons a train may exceed maxPullWeightTons by before scheduling blocks it. */
|
||||
overageToleranceTons?: number | null;
|
||||
/** Metres a train may exceed maxTrainLengthMeters by before scheduling blocks it. */
|
||||
overageToleranceMeters?: number | null;
|
||||
powerKw?: number | null;
|
||||
tractionForceKn?: number | null;
|
||||
maxSpeedKmh?: number | null;
|
||||
|
||||
@@ -8,7 +8,8 @@ export interface WagonType {
|
||||
name: string;
|
||||
capacityTons: number;
|
||||
lengthMeters: number;
|
||||
maxWagonsPerTrain?: number | null;
|
||||
/** Empty wagon weight; required, since pull limits apply to tare + cargo. */
|
||||
tareWeightTons: number;
|
||||
supportedLoadTypes: string[];
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ import type {
|
||||
WarehouseZone,
|
||||
} from '@/types/warehouse';
|
||||
|
||||
export type ContainerItemStage = 'PENDING' | 'RECEIVED' | 'GRN' | 'LOADED' | 'LEFT' | 'DELIVERED';
|
||||
export type ContainerItemStage = 'PENDING' | 'RECEIVED' | 'GRN' | 'ASSIGNED' | 'LOADED' | 'LEFT' | 'DELIVERED';
|
||||
|
||||
export interface ContainerItem {
|
||||
containerNumber: string;
|
||||
@@ -75,6 +75,8 @@ export interface ContainerItem {
|
||||
truckPlate: string | null;
|
||||
truckArrived: boolean;
|
||||
truckLeft: boolean;
|
||||
/** Operator has loaded this container onto the truck (customer assignment alone is not "loaded"). */
|
||||
loaded: boolean;
|
||||
bookingReference: string | null;
|
||||
contractId: string | null;
|
||||
hasLastMile: boolean;
|
||||
|
||||
@@ -280,10 +280,13 @@ export interface BatchBoardSchedule {
|
||||
capacity: {
|
||||
allocatedWagons: number;
|
||||
allocatedLengthMeters: number;
|
||||
/** Train-length cap: locomotive floored by global rules, plus overage tolerance. */
|
||||
maxLengthMeters: number | null;
|
||||
/** GROSS tons on the train — wagon tare + cargo, since the pull limit hauls both. */
|
||||
usedWeightTons: number;
|
||||
/** Pull-weight cap: locomotive floored by global rules, plus overage tolerance. */
|
||||
maxWeightTons: number | null;
|
||||
/** Wagon-slot cap for the train (locomotive/wagon-type derived). */
|
||||
/** Wagon-slot cap for the train, derived from train length and the shortest wagon type. */
|
||||
maxWagons: number | null;
|
||||
};
|
||||
counts: {
|
||||
|
||||
@@ -334,6 +334,19 @@ export const STATUS_CONFIG: Record<string, StageConfig> = {
|
||||
badgeDot: "edr-green.5",
|
||||
action: { label: "View", kind: "outline" },
|
||||
},
|
||||
TRUCK_ASSIGNED: {
|
||||
stage: 3,
|
||||
icon: Truck,
|
||||
iconColor: "edr-green.7",
|
||||
tile: "edr-soft",
|
||||
hint: "Truck assigned · preparing for pickup",
|
||||
step: "edr-green.5",
|
||||
badgeLabel: "Truck assigned",
|
||||
badgeBg: "edr-soft",
|
||||
badgeText: "edr-green.7",
|
||||
badgeDot: "edr-green.5",
|
||||
action: { label: "View", kind: "outline" },
|
||||
},
|
||||
IN_TRANSIT: {
|
||||
stage: 3,
|
||||
icon: Truck,
|
||||
|
||||
@@ -18,6 +18,7 @@ import { ContainersCard } from "./components/ContainersCard";
|
||||
import { ContractCard } from "./components/ContractCard";
|
||||
import { CustomerTruckAssignmentCard } from "./components/CustomerTruckAssignmentCard";
|
||||
import { KeyFactsStrip } from "./components/KeyFactsStrip";
|
||||
import { MileSummaryCard } from "./components/MileSummaryCard";
|
||||
import { BodyGrid, PageShell } from "./components/layout";
|
||||
import {
|
||||
CancelledBanner,
|
||||
@@ -220,6 +221,8 @@ export function ReadonlyBookingView({
|
||||
<WarehousePaymentsSection bookingId={booking.id} />
|
||||
|
||||
<ActivityCard booking={booking} />
|
||||
|
||||
<MileSummaryCard booking={booking} />
|
||||
</>
|
||||
}
|
||||
right={
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { Freight } from "@edr/types";
|
||||
|
||||
import { invoicesService, type PortalInvoice } from "@/services/invoices.service";
|
||||
import { InvoiceStatusBadge, titleCase } from "@/pages/billing/invoice-ui";
|
||||
import { paymentStatusLabel } from "@/pages/bookings/booking-display";
|
||||
import { saveBlob } from "@/utils/download";
|
||||
|
||||
import { fmtDate, priceLineItems, priceTotal, type Pricing } from "../utils";
|
||||
@@ -262,7 +263,7 @@ export function BookingPaymentPanel({
|
||||
? "Paid"
|
||||
: showCountdown
|
||||
? "Pay window open"
|
||||
: (booking.paymentStatus?.replace(/_/g, " ") ?? "Pending")}
|
||||
: paymentStatusLabel(booking.paymentStatus ?? "PENDING")}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { ReactNode } from "react";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { paymentStatusLabel } from "@/pages/bookings/booking-display";
|
||||
|
||||
import { fmtDate, yardLabel } from "../utils";
|
||||
import { SectionCard } from "./layout";
|
||||
|
||||
@@ -38,12 +40,7 @@ function Fact({ label, value }: { label: string; value: ReactNode }) {
|
||||
export function KeyFactsStrip({ booking }: { booking: BookingLike }) {
|
||||
const isContract = booking.bookingType === "GENERAL_CONTRACT";
|
||||
const freight = booking.freightType === "BULK" ? "Bulk" : "Container";
|
||||
const payment = booking.paymentStatus
|
||||
? booking.paymentStatus
|
||||
.replace(/_/g, " ")
|
||||
.toLowerCase()
|
||||
.replace(/^\w/, (c) => c.toUpperCase())
|
||||
: "—";
|
||||
const payment = paymentStatusLabel(booking.paymentStatus);
|
||||
|
||||
return (
|
||||
<SectionCard p="lg">
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { Box, Group, Stack, Text } from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import type {
|
||||
MileLegSummary,
|
||||
MileVehicleSummary,
|
||||
} from "@/services/bookings.service";
|
||||
|
||||
import { CardTitle, SectionCard } from "./layout";
|
||||
|
||||
function StatusPill({ status }: { status: string }) {
|
||||
const s = status.toUpperCase();
|
||||
const done = s.includes("DELIVER") || s.includes("COMPLET") || s.includes("PAID");
|
||||
const active = s.includes("TRANSIT") || s.includes("PROGRESS") || s.includes("ASSIGN");
|
||||
const dot = done ? "#0EA371" : active ? "#2563EB" : "#94A3B8";
|
||||
const color = done ? "#0A6F4D" : active ? "#1E40AF" : "#475569";
|
||||
const bg = done ? "#ECF6F1" : active ? "#EAF1FE" : "#F1F4F7";
|
||||
const border = done ? "#CDEBDD" : active ? "#CFDDFB" : "#E1E7EE";
|
||||
const label = status
|
||||
.replace(/_/g, " ")
|
||||
.toLowerCase()
|
||||
.replace(/\b\w/g, (m) => m.toUpperCase());
|
||||
|
||||
return (
|
||||
<Group
|
||||
component="span"
|
||||
gap={6}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
borderRadius: 999,
|
||||
backgroundColor: bg,
|
||||
border: `1px solid ${border}`,
|
||||
padding: "4px 10px",
|
||||
fontSize: 11.5,
|
||||
fontWeight: 700,
|
||||
color,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
style={{ width: 6, height: 6, borderRadius: 999, backgroundColor: dot }}
|
||||
/>
|
||||
{label}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function VehicleRow({ v }: { v: MileVehicleSummary }) {
|
||||
const parts: string[] = [];
|
||||
if (v.driverName) parts.push(v.driverName);
|
||||
if (v.containerNumber) parts.push(`Container ${v.containerNumber}`);
|
||||
if (v.distanceKm != null) parts.push(`${v.distanceKm} km`);
|
||||
|
||||
return (
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="flex-start"
|
||||
wrap="nowrap"
|
||||
py={8}
|
||||
style={{ borderTop: "1px solid #F2F5F8" }}
|
||||
>
|
||||
<Stack gap={2} style={{ minWidth: 0 }}>
|
||||
<Text fz="13px" fw={700} c="#10202F">
|
||||
{v.plate || v.code || "Vehicle"}
|
||||
</Text>
|
||||
{parts.length > 0 && (
|
||||
<Text fz="12px" c="#6B7C8E">
|
||||
{parts.join(" · ")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
{v.code && v.plate && (
|
||||
<Text fz="12px" c="#9AA8B5" style={{ whiteSpace: "nowrap" }}>
|
||||
{v.code}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function LegBlock({
|
||||
title,
|
||||
leg,
|
||||
address,
|
||||
}: {
|
||||
title: string;
|
||||
leg: MileLegSummary | null;
|
||||
address?: string | null;
|
||||
}) {
|
||||
const fmtMoney = (n: number | null, currency: string) =>
|
||||
n == null
|
||||
? null
|
||||
: `${currency} ${Number(n).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}`;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Group justify="space-between" align="center" pb={8}>
|
||||
<Text fz="13px" fw={700} c="#10202F">
|
||||
{title}
|
||||
</Text>
|
||||
{leg ? (
|
||||
<StatusPill status={leg.status} />
|
||||
) : (
|
||||
<StatusPill status="AWAITING_ASSIGNMENT" />
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{address && (
|
||||
<Text fz="12px" c="#6B7C8E" pb={4}>
|
||||
{title.startsWith("First") ? "Pickup" : "Delivery"}:{" "}
|
||||
<b style={{ color: "#10202F" }}>{address}</b>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!leg ? (
|
||||
<Text fz="12px" c="#9AA8B5" fs="italic" py={6}>
|
||||
Requested — a vehicle and driver will be assigned soon.
|
||||
</Text>
|
||||
) : leg.vehicles.length > 0 ? (
|
||||
<Box>
|
||||
{leg.vehicles.map((v, i) => (
|
||||
<VehicleRow key={`${v.plate ?? v.code ?? "v"}-${i}`} v={v} />
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<Text fz="12px" c="#9AA8B5" fs="italic" py={6}>
|
||||
No vehicle assigned yet.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{leg && (
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="center"
|
||||
pt={8}
|
||||
mt={4}
|
||||
style={{ borderTop: "1px solid #F2F5F8" }}
|
||||
>
|
||||
<Group gap={16}>
|
||||
{leg.exactKm != null && (
|
||||
<Text fz="12px" c="#6B7C8E">
|
||||
Total distance{" "}
|
||||
<b style={{ color: "#10202F" }}>{leg.exactKm} km</b>
|
||||
</Text>
|
||||
)}
|
||||
{leg.remainingPayment != null && leg.remainingPayment > 0 && (
|
||||
<Text fz="12px" c="#6B7C8E">
|
||||
Balance{" "}
|
||||
<b style={{ color: "#10202F" }}>
|
||||
{fmtMoney(leg.remainingPayment, leg.currency)}
|
||||
</b>
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
{leg.invoiced && (
|
||||
<Text fz="11px" fw={700} c="#0A6F4D">
|
||||
Invoiced
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function MileSummaryCard({ booking }: { booking: Freight.IBooking }) {
|
||||
const { data } = useQuery({
|
||||
queryKey: ["booking-mile-summary", booking.id],
|
||||
queryFn: () => bookingsService.mileSummary(booking.id),
|
||||
});
|
||||
|
||||
const firstLeg = data?.firstMile ?? null;
|
||||
const lastLeg = data?.lastMile ?? null;
|
||||
|
||||
// The backend doesn't persist an "enabled" flag — the presence of a
|
||||
// pickup/delivery address is the request signal. Also show a leg once its
|
||||
// record exists, regardless of address.
|
||||
const showFirst = !!booking.firstMilePickupAddress || !!firstLeg;
|
||||
const showLast = !!booking.lastMileDeliveryAddress || !!lastLeg;
|
||||
|
||||
if (!showFirst && !showLast) return null;
|
||||
|
||||
return (
|
||||
<SectionCard p={22}>
|
||||
<Box pb={12}>
|
||||
<CardTitle>First & Last Mile</CardTitle>
|
||||
</Box>
|
||||
<Stack gap={20}>
|
||||
{showFirst && (
|
||||
<LegBlock
|
||||
title="First mile"
|
||||
leg={firstLeg}
|
||||
address={booking.firstMilePickupAddress}
|
||||
/>
|
||||
)}
|
||||
{showLast && (
|
||||
<LegBlock
|
||||
title="Last mile"
|
||||
leg={lastLeg}
|
||||
address={booking.lastMileDeliveryAddress}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import type { ReactNode } from "react";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { bookingStatusLabel } from "@/pages/bookings/booking-display";
|
||||
|
||||
import { bookingSubtitle, isDraftLike, isNegative } from "../utils";
|
||||
|
||||
export interface PageHeaderMenuActions {
|
||||
@@ -60,7 +62,7 @@ export function PageHeader({
|
||||
className="shrink-0 rounded-full"
|
||||
style={{ width: 7, height: 7, backgroundColor: dotColor }}
|
||||
/>
|
||||
{status.replace(/_/g, " ").replace(/\b\w/g, (m) => m.toUpperCase())}
|
||||
{bookingStatusLabel(status)}
|
||||
</span>
|
||||
|
||||
<span className="inline-flex items-center gap-[6px] rounded-full bg-[#F1F4F7] px-[11px] py-1.5 text-xs font-bold text-[#475569]">
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { ReactNode } from "react";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { bookingStatusLabel } from "@/pages/bookings/booking-display";
|
||||
|
||||
import { fmtDate, isDraftLike, isNegative } from "../utils";
|
||||
import { CardTitle, SectionCard } from "./layout";
|
||||
|
||||
@@ -15,10 +17,7 @@ function StatusPill({ status }: { status: string }) {
|
||||
const color = negative ? "#A93226" : draft ? "#475569" : "#0A6F4D";
|
||||
const bg = negative ? "#FBEAE7" : draft ? "#F1F4F7" : "#ECF6F1";
|
||||
const border = negative ? "#F3C8C1" : draft ? "#E1E7EE" : "#CDEBDD";
|
||||
const label = status
|
||||
.replace(/_/g, " ")
|
||||
.toLowerCase()
|
||||
.replace(/\b\w/g, (m) => m.toUpperCase());
|
||||
const label = bookingStatusLabel(status);
|
||||
|
||||
return (
|
||||
<Group
|
||||
|
||||
@@ -1,12 +1,32 @@
|
||||
import { Badge, Group, Text } from "@mantine/core";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { STATUS_CONFIG } from "@/pages/MyPortalPage/constants";
|
||||
|
||||
/**
|
||||
* Shared presentation helpers for booking-like rows (one-time bookings AND
|
||||
* general contracts). Kept in one place so the bookings list, contracts list,
|
||||
* and detail page render type/freight/mode/payment consistently.
|
||||
*/
|
||||
|
||||
/** Title-case an unmapped enum as a readable fallback ("FOO_BAR" → "Foo Bar"). */
|
||||
export function titleCaseStatus(status: string): string {
|
||||
return status
|
||||
.replace(/_/g, " ")
|
||||
.toLowerCase()
|
||||
.replace(/\b\w/g, (m) => m.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Human label for a booking status. Single source of truth is the booking
|
||||
* list's `STATUS_CONFIG` badge labels; anything not mapped there falls back to
|
||||
* a readable title-cased form (never the raw SCREAMING_SNAKE enum).
|
||||
*/
|
||||
export function bookingStatusLabel(status?: string | null): string {
|
||||
if (!status) return "—";
|
||||
return STATUS_CONFIG[status]?.badgeLabel ?? titleCaseStatus(status);
|
||||
}
|
||||
|
||||
type BookingLike = Freight.IBooking & {
|
||||
bookingType?: string;
|
||||
freightType?: string;
|
||||
@@ -59,7 +79,12 @@ const PAYMENT_COLORS: Record<string, string> = {
|
||||
PAID: "green",
|
||||
PENDING: "gray",
|
||||
PNR_GENERATED: "blue",
|
||||
// Backend emits the long form on some flows; keep the short alias too.
|
||||
VERIFICATION_IN_PROGRESS: "yellow",
|
||||
PAYMENT_VERIFICATION_IN_PROGRESS: "yellow",
|
||||
OVERDUE: "red",
|
||||
REFUNDED: "blue",
|
||||
CANCELLED: "gray",
|
||||
FAILED: "red",
|
||||
};
|
||||
|
||||
@@ -68,9 +93,19 @@ const PAYMENT_LABELS: Record<string, string> = {
|
||||
PENDING: "Pending",
|
||||
PNR_GENERATED: "PNR generated",
|
||||
VERIFICATION_IN_PROGRESS: "Verifying",
|
||||
PAYMENT_VERIFICATION_IN_PROGRESS: "Verifying",
|
||||
OVERDUE: "Overdue",
|
||||
REFUNDED: "Refunded",
|
||||
CANCELLED: "Cancelled",
|
||||
FAILED: "Failed",
|
||||
};
|
||||
|
||||
/** Human label for a payment status (plain text, no badge). */
|
||||
export function paymentStatusLabel(status?: string | null): string {
|
||||
if (!status) return "—";
|
||||
return PAYMENT_LABELS[status] ?? titleCaseStatus(status);
|
||||
}
|
||||
|
||||
/** Payment status pill. */
|
||||
export function PaymentBadge({ status }: { status?: string | null }) {
|
||||
if (!status) return <Text fz={13} c="dimmed">—</Text>;
|
||||
@@ -81,7 +116,7 @@ export function PaymentBadge({ status }: { status?: string | null }) {
|
||||
color={PAYMENT_COLORS[status] ?? "gray"}
|
||||
styles={{ root: { textTransform: "none", fontWeight: 600, letterSpacing: 0 } }}
|
||||
>
|
||||
{PAYMENT_LABELS[status] ?? status.replace(/_/g, " ")}
|
||||
{PAYMENT_LABELS[status] ?? titleCaseStatus(status)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { Button, type ButtonProps } from "@mantine/core";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import type { MouseEvent } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { type MouseEvent, useState } from "react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { ApproveDeliveryModal } from "./ApproveDeliveryModal";
|
||||
|
||||
type ApproveDeliveryButtonProps = ButtonProps & {
|
||||
bookingId: string;
|
||||
@@ -13,25 +10,6 @@ type ApproveDeliveryButtonProps = ButtonProps & {
|
||||
onApproved?: () => void;
|
||||
};
|
||||
|
||||
const errorMessage = (error: unknown) => {
|
||||
const data = (error as { response?: { data?: { message?: string | string[] } } })
|
||||
?.response?.data;
|
||||
if (Array.isArray(data?.message)) return data.message.join(", ");
|
||||
if (data?.message) return data.message;
|
||||
return error instanceof Error ? error.message : "Could not approve delivery";
|
||||
};
|
||||
|
||||
const downloadBlob = (blob: Blob, filename: string) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
export function ApproveDeliveryButton({
|
||||
bookingId,
|
||||
stopPropagation,
|
||||
@@ -40,56 +18,31 @@ export function ApproveDeliveryButton({
|
||||
variant = "filled",
|
||||
...props
|
||||
}: ApproveDeliveryButtonProps) {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const handoverMutation = useMutation(api.bookings.downloadHandoverDocument.mutationOptions());
|
||||
|
||||
const mutation = useMutation({
|
||||
...api.bookings.approveDelivery.mutationOptions(),
|
||||
onSuccess: async (result) => {
|
||||
try {
|
||||
const blob = await handoverMutation.mutateAsync({ inventoryId: result.inventoryId });
|
||||
downloadBlob(blob, `handover-${bookingId}.pdf`);
|
||||
toast.success("Delivery approved and signed handover downloaded");
|
||||
} catch {
|
||||
toast.success("Delivery approved and handover signed");
|
||||
toast.error("Signed handover document could not be downloaded");
|
||||
}
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: api.bookings.get.queryKey({ id: bookingId }) }),
|
||||
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }),
|
||||
queryClient.invalidateQueries({ queryKey: ["companies", "getDashboard"] }),
|
||||
]);
|
||||
onApproved?.();
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = errorMessage(error);
|
||||
toast.error(message);
|
||||
if (message.toLowerCase().includes("save your signature")) {
|
||||
navigate("/signature");
|
||||
} else if (/demurrage|storage|warehouse invoice|fully paid/i.test(message)) {
|
||||
navigate("/billing");
|
||||
}
|
||||
},
|
||||
});
|
||||
const [opened, setOpened] = useState(false);
|
||||
|
||||
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
if (stopPropagation) event.stopPropagation();
|
||||
mutation.mutate({ id: bookingId });
|
||||
setOpened(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
{...props}
|
||||
size={size}
|
||||
variant={variant}
|
||||
color="edr-green"
|
||||
leftSection={<CheckCircle2 size={16} />}
|
||||
loading={mutation.isPending || handoverMutation.isPending}
|
||||
onClick={handleClick}
|
||||
>
|
||||
Approve delivery
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
{...props}
|
||||
size={size}
|
||||
variant={variant}
|
||||
color="edr-green"
|
||||
leftSection={<CheckCircle2 size={16} />}
|
||||
onClick={handleClick}
|
||||
>
|
||||
Approve delivery
|
||||
</Button>
|
||||
<ApproveDeliveryModal
|
||||
bookingId={bookingId}
|
||||
opened={opened}
|
||||
onClose={() => setOpened(false)}
|
||||
onApproved={onApproved}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { Alert, Button, Group, Loader, Modal, Stack, Text } from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { CheckCircle2, Info } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
|
||||
type ApproveDeliveryModalProps = {
|
||||
bookingId: string;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onApproved?: () => void;
|
||||
};
|
||||
|
||||
const errorMessage = (error: unknown) => {
|
||||
const data = (error as { response?: { data?: { message?: string | string[] } } })
|
||||
?.response?.data;
|
||||
if (Array.isArray(data?.message)) return data.message.join(", ");
|
||||
if (data?.message) return data.message;
|
||||
return error instanceof Error ? error.message : "Could not approve delivery";
|
||||
};
|
||||
|
||||
const downloadBlob = (blob: Blob, filename: string) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
/**
|
||||
* Approve-delivery flow: open the handover document for the customer to review,
|
||||
* then apply their saved signature (approve) and hand back the signed PDF.
|
||||
*/
|
||||
export function ApproveDeliveryModal({
|
||||
bookingId,
|
||||
opened,
|
||||
onClose,
|
||||
onApproved,
|
||||
}: ApproveDeliveryModalProps) {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
data: docBlob,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: ["booking-handover-doc", bookingId],
|
||||
queryFn: () => bookingsService.downloadBookingHandoverDocument(bookingId),
|
||||
enabled: opened && Boolean(bookingId),
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!docBlob) {
|
||||
setPdfUrl(null);
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(docBlob);
|
||||
setPdfUrl(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [docBlob]);
|
||||
|
||||
const handoverMutation = useMutation(
|
||||
api.bookings.downloadHandoverDocument.mutationOptions(),
|
||||
);
|
||||
|
||||
const approve = useMutation({
|
||||
...api.bookings.approveDelivery.mutationOptions(),
|
||||
onSuccess: async (result) => {
|
||||
try {
|
||||
const signed = await handoverMutation.mutateAsync({
|
||||
inventoryId: result.inventoryId,
|
||||
});
|
||||
downloadBlob(signed, `handover-${bookingId}.pdf`);
|
||||
toast.success("Delivery approved and signed handover downloaded");
|
||||
} catch {
|
||||
toast.success("Delivery approved and handover signed");
|
||||
toast.error("Signed handover document could not be downloaded");
|
||||
}
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.bookings.get.queryKey({ id: bookingId }),
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }),
|
||||
queryClient.invalidateQueries({ queryKey: ["companies", "getDashboard"] }),
|
||||
]);
|
||||
onApproved?.();
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = errorMessage(error);
|
||||
toast.error(message);
|
||||
if (message.toLowerCase().includes("save your signature")) {
|
||||
onClose();
|
||||
navigate("/signature");
|
||||
} else if (/demurrage|storage|warehouse invoice|fully paid/i.test(message)) {
|
||||
onClose();
|
||||
navigate("/billing");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const busy = approve.isPending || handoverMutation.isPending;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title="Approve delivery — review & sign the handover"
|
||||
size="xl"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Alert color="blue" variant="light" icon={<Info size={16} />}>
|
||||
<Text size="sm">
|
||||
Review the handover document below. Approving applies your saved signature
|
||||
and confirms you received the goods.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader size="sm" />
|
||||
<Text size="sm" c="dimmed">
|
||||
Loading handover document…
|
||||
</Text>
|
||||
</Group>
|
||||
) : isError || !pdfUrl ? (
|
||||
<Text size="sm" c="red">
|
||||
Could not load the handover document. It may not be generated yet.
|
||||
</Text>
|
||||
) : (
|
||||
<iframe
|
||||
title="Handover document"
|
||||
src={pdfUrl}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "60vh",
|
||||
border: "1px solid var(--mantine-color-gray-3)",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<CheckCircle2 size={16} />}
|
||||
loading={busy}
|
||||
disabled={isLoading || isError}
|
||||
onClick={() => approve.mutate({ id: bookingId })}
|
||||
>
|
||||
Approve & sign delivery
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useEffect, useMemo, useRef, type KeyboardEvent } from "react";
|
||||
import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form";
|
||||
import { Flame, Package, Plus, Snowflake, Trash2, Weight } from "lucide-react";
|
||||
import { ActionIcon, Button, Skeleton, Text, TextInput } from "@mantine/core";
|
||||
@@ -26,6 +26,15 @@ type BookingForm = UseFormReturn<
|
||||
BookingFormValues
|
||||
>;
|
||||
|
||||
/**
|
||||
* Every quantity on this step is a non-negative magnitude. A native number
|
||||
* input's `min` only constrains its stepper, so swallow the minus key before it
|
||||
* can put a negative into the field at all.
|
||||
*/
|
||||
const blockNegative = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "-") event.preventDefault();
|
||||
};
|
||||
|
||||
/**
|
||||
* One numbered toggle per container unit in the line — tap units to mark how
|
||||
* many are hazardous/refrigerated (2 hazardous → toggle 2 units on). Selection
|
||||
@@ -379,6 +388,7 @@ export function Step5CargoDetails({
|
||||
}}
|
||||
id="cargoWeight"
|
||||
type="number"
|
||||
onKeyDown={blockNegative}
|
||||
label={isPerItem ? "Quantity (Items) *" : "Quantity (Tons) *"}
|
||||
placeholder={isPerItem ? "e.g. 500" : "e.g. 1200"}
|
||||
leftSection={
|
||||
@@ -435,6 +445,7 @@ export function Step5CargoDetails({
|
||||
render={({ field: hq, fieldState }) => (
|
||||
<TextInput
|
||||
type="number"
|
||||
onKeyDown={blockNegative}
|
||||
size="sm"
|
||||
label={
|
||||
isPerItem
|
||||
@@ -483,6 +494,7 @@ export function Step5CargoDetails({
|
||||
render={({ field: rq, fieldState }) => (
|
||||
<TextInput
|
||||
type="number"
|
||||
onKeyDown={blockNegative}
|
||||
size="sm"
|
||||
label={
|
||||
isPerItem
|
||||
@@ -630,6 +642,7 @@ export function Step5CargoDetails({
|
||||
}}
|
||||
onBlur={qtyField.onBlur}
|
||||
type="number"
|
||||
onKeyDown={blockNegative}
|
||||
min={1}
|
||||
className="h-9 w-full rounded-lg border border-gray-200 bg-white px-3 text-center text-sm outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
|
||||
/>
|
||||
|
||||
@@ -32,6 +32,28 @@ const TZ = "Africa/Addis_Ababa";
|
||||
/** Cards visible per carousel page. */
|
||||
const PER_PAGE = 3;
|
||||
|
||||
/** Customer-facing labels for a booking-window phase / status. */
|
||||
const WINDOW_PHASE_LABELS: Record<string, string> = {
|
||||
PRE_WINDOW: "Opens soon",
|
||||
OPEN: "Open now",
|
||||
DOC_REVIEW: "Document review",
|
||||
PAYMENT: "Payment due",
|
||||
DONE: "Closed",
|
||||
CLOSED_FOR_DAY: "Closed for the day",
|
||||
};
|
||||
|
||||
/** Friendly label for a window phase/status, never the raw enum. */
|
||||
function windowPhaseLabel(phase?: string | null): string {
|
||||
if (!phase) return "—";
|
||||
return (
|
||||
WINDOW_PHASE_LABELS[phase] ??
|
||||
phase
|
||||
.replace(/_/g, " ")
|
||||
.toLowerCase()
|
||||
.replace(/\b\w/g, (m) => m.toUpperCase())
|
||||
);
|
||||
}
|
||||
|
||||
function fmtDay(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString("en-GB", {
|
||||
weekday: "short",
|
||||
@@ -60,7 +82,7 @@ function windowLabel(w: MyBookingWindow): string {
|
||||
if (w.windowOpensAt) {
|
||||
return `Opens ${fmtDay(w.windowOpensAt)} · ${fmtTime(w.windowOpensAt)} EAT`;
|
||||
}
|
||||
return (w.windowPhase ?? w.bookingWindowStatus).replace(/_/g, " ");
|
||||
return windowPhaseLabel(w.windowPhase ?? w.bookingWindowStatus);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -163,7 +185,7 @@ function WindowCard({ w }: { w: MyBookingWindow }) {
|
||||
>
|
||||
{open
|
||||
? "Open now"
|
||||
: (w.windowPhase ?? w.bookingWindowStatus).replace(/_/g, " ")}
|
||||
: windowPhaseLabel(w.windowPhase ?? w.bookingWindowStatus)}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import toast from "react-hot-toast";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { bookingStatusLabel } from "@/pages/bookings/booking-display";
|
||||
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
|
||||
import { PortalFileDropzone } from "@/components/contracts/PortalFileDropzone";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
@@ -99,7 +100,7 @@ export function ContractClearanceWorkflowBanner({
|
||||
<Text fz={12} c="dimmed">
|
||||
Global Logistics has created your shipment booking
|
||||
{clearance.linkedBookingStatus
|
||||
? ` (${clearance.linkedBookingStatus.replace(/_/g, " ").toLowerCase()})`
|
||||
? ` (${bookingStatusLabel(clearance.linkedBookingStatus).toLowerCase()})`
|
||||
: ""}
|
||||
. Track its progress from the booking.
|
||||
</Text>
|
||||
|
||||
@@ -85,6 +85,37 @@ const CLEARANCE_UPLOAD_STATUSES = [
|
||||
|
||||
type ContractFile = NonNullable<Freight.IContract["files"]>[number];
|
||||
|
||||
// Customer-facing labels for a shipment-request status (BOOKING_REQUEST_STATUSES).
|
||||
const BOOKING_REQUEST_STATUS_LABELS: Record<string, string> = {
|
||||
PENDING: "Pending review",
|
||||
ACCEPTED: "Accepted",
|
||||
REJECTED: "Rejected",
|
||||
CANCELLED: "Cancelled",
|
||||
};
|
||||
|
||||
// Customer-facing labels for an invoice status (Freight.InvoiceStatus).
|
||||
const INVOICE_STATUS_LABELS: Record<string, string> = {
|
||||
DRAFT: "Draft",
|
||||
ISSUED: "Issued",
|
||||
PENDING: "Due",
|
||||
PARTIALLY_PAID: "Partially paid",
|
||||
PAID: "Paid",
|
||||
OVERDUE: "Overdue",
|
||||
CANCELLED: "Cancelled",
|
||||
REFUNDED: "Refunded",
|
||||
EXPIRED: "Expired",
|
||||
};
|
||||
|
||||
function invoiceStatusLabel(status: string): string {
|
||||
return (
|
||||
INVOICE_STATUS_LABELS[status] ??
|
||||
status
|
||||
.replace(/_/g, " ")
|
||||
.toLowerCase()
|
||||
.replace(/\b\w/g, (m) => m.toUpperCase())
|
||||
);
|
||||
}
|
||||
|
||||
// Business-license document codes — surfaced as their own section so they stand
|
||||
// out from the rest of the onboarding/profile set.
|
||||
const BUSINESS_LICENSE_DOC_CODES = new Set([
|
||||
@@ -667,7 +698,8 @@ export default function ContractDetailPage() {
|
||||
variant="filled"
|
||||
radius="sm"
|
||||
>
|
||||
{clearanceView.riskLevel}
|
||||
{clearanceView.riskLevel.charAt(0) +
|
||||
clearanceView.riskLevel.slice(1).toLowerCase()}
|
||||
</Badge>
|
||||
{clearanceView.riskAssignedAt ? (
|
||||
<Text fz={12} c="dimmed">
|
||||
@@ -1226,12 +1258,15 @@ export default function ContractDetailPage() {
|
||||
? "teal"
|
||||
: req.status === "REJECTED"
|
||||
? "red"
|
||||
: "yellow"
|
||||
: req.status === "CANCELLED"
|
||||
? "gray"
|
||||
: "yellow"
|
||||
}
|
||||
>
|
||||
{req.status === "ACCEPTED" && req.createdBookingId
|
||||
? "Booking created"
|
||||
: req.status}
|
||||
: BOOKING_REQUEST_STATUS_LABELS[req.status] ??
|
||||
req.status}
|
||||
</Badge>
|
||||
{req.createdBookingId ? (
|
||||
<ChevronRight size={16} color={MUTED} />
|
||||
@@ -1722,7 +1757,7 @@ function FinalInvoiceDueCard({
|
||||
{invoice.invoiceNumber}
|
||||
</Text>
|
||||
<Badge color={paid ? "edr-green" : "yellow"} variant="light" radius="sm">
|
||||
{invoice.status}
|
||||
{invoiceStatusLabel(invoice.status)}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text fz={20} fw={800} mt={6} c={INK}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -58,6 +58,15 @@ type ShipmentForm = ReturnType<
|
||||
typeof useForm<ShipmentFormInputValues, any, ShipmentFormValues>
|
||||
>;
|
||||
|
||||
/**
|
||||
* Every quantity on this form is a non-negative magnitude. A native number
|
||||
* input's `min` only constrains its stepper, so swallow the minus key before it
|
||||
* can put a negative into the field at all.
|
||||
*/
|
||||
const blockNegative = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "-") event.preventDefault();
|
||||
};
|
||||
|
||||
export default function NewShipmentPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
@@ -962,6 +971,7 @@ function CargoStep({
|
||||
<TextInput
|
||||
{...field}
|
||||
type="number"
|
||||
onKeyDown={blockNegative}
|
||||
label="Quantity (tons)"
|
||||
placeholder="e.g. 1200"
|
||||
min={0}
|
||||
@@ -979,6 +989,7 @@ function CargoStep({
|
||||
<TextInput
|
||||
{...field}
|
||||
type="number"
|
||||
onKeyDown={blockNegative}
|
||||
label="Item count (if applicable)"
|
||||
placeholder="e.g. 500"
|
||||
min={0}
|
||||
@@ -997,6 +1008,7 @@ function CargoStep({
|
||||
<TextInput
|
||||
{...field}
|
||||
type="number"
|
||||
onKeyDown={blockNegative}
|
||||
label="Hazardous quantity"
|
||||
min={0}
|
||||
step={1}
|
||||
@@ -1015,6 +1027,7 @@ function CargoStep({
|
||||
<TextInput
|
||||
{...field}
|
||||
type="number"
|
||||
onKeyDown={blockNegative}
|
||||
label="Refrigerated quantity"
|
||||
min={0}
|
||||
step={1}
|
||||
@@ -1093,6 +1106,7 @@ function ContainerLineEditor({
|
||||
<TextInput
|
||||
{...field}
|
||||
type="number"
|
||||
onKeyDown={blockNegative}
|
||||
label="Quantity *"
|
||||
min={1}
|
||||
error={fieldState.error?.message}
|
||||
@@ -1113,6 +1127,7 @@ function ContainerLineEditor({
|
||||
<TextInput
|
||||
{...field}
|
||||
type="number"
|
||||
onKeyDown={blockNegative}
|
||||
label="Hazardous qty"
|
||||
min={0}
|
||||
error={fieldState.error?.message}
|
||||
@@ -1130,6 +1145,7 @@ function ContainerLineEditor({
|
||||
<TextInput
|
||||
{...field}
|
||||
type="number"
|
||||
onKeyDown={blockNegative}
|
||||
label="Reefer qty"
|
||||
min={0}
|
||||
error={fieldState.error?.message}
|
||||
@@ -1151,6 +1167,9 @@ function ContainerLineEditor({
|
||||
render={({ field, fieldState }) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(e.currentTarget.value.toUpperCase())
|
||||
}
|
||||
label={u === 0 ? "Container number *" : undefined}
|
||||
placeholder="e.g. MSCU1234567"
|
||||
error={fieldState.error?.message}
|
||||
@@ -1179,6 +1198,7 @@ function ContainerLineEditor({
|
||||
<TextInput
|
||||
{...field}
|
||||
type="number"
|
||||
onKeyDown={blockNegative}
|
||||
label={u === 0 ? "VGM (tons) *" : undefined}
|
||||
placeholder="e.g. 24.5"
|
||||
min={0}
|
||||
|
||||
@@ -149,21 +149,35 @@ export const CONTRACT_STATUS_CONFIG: Record<
|
||||
DOCUMENTS_UNDER_REVIEW: { label: "Documents Under Review", ...TONE.info },
|
||||
CLEARANCE_READY: { label: "Clearance Ready", ...TONE.success },
|
||||
OPERATION_REQUEST_PENDING: { label: "Operation Review", ...TONE.warning },
|
||||
OPERATION_REQUESTED: { label: "Operation Requested", ...TONE.info },
|
||||
OPERATION_CHANGES_REQUESTED: { label: "Changes Requested", ...TONE.warning },
|
||||
OPERATION_PRICE_PENDING_CONFIRM: { label: "Confirm New Price", ...TONE.warning },
|
||||
ROAD_DISPATCH_PENDING: { label: "Awaiting Dispatch", ...TONE.warning },
|
||||
READY_FOR_ASSIGNMENT: { label: "Assigning Wagon", ...TONE.info },
|
||||
WAGON_ASSIGNED: { label: "Wagon Assigned", ...TONE.success },
|
||||
SELECTED_FOR_BATCH: { label: "Awaiting Payment", ...TONE.warning },
|
||||
PNR_GENERATED: { label: "Payment Reference Ready", ...TONE.warning },
|
||||
PAYMENT_VERIFICATION_IN_PROGRESS: { label: "Verifying Payment", ...TONE.warning },
|
||||
INVOICED: { label: "Invoiced", ...TONE.info },
|
||||
PAID: { label: "Paid", ...TONE.success },
|
||||
PENDING_CONSOLIDATION: { label: "Consolidating", ...TONE.info },
|
||||
CONSOLIDATED: { label: "Consolidated", ...TONE.success },
|
||||
TRUCK_ASSIGNED: { label: "Truck Assigned", ...TONE.success },
|
||||
IN_TRANSIT: { label: "In Transit", ...TONE.info },
|
||||
ARRIVED: { label: "Arrived", ...TONE.success },
|
||||
DELIVERED: { label: "Delivered", ...TONE.success },
|
||||
COMPLETED: { label: "Completed", ...TONE.success },
|
||||
};
|
||||
|
||||
export function ContractStatusBadge({ status }: { status: string }) {
|
||||
const cfg =
|
||||
CONTRACT_STATUS_CONFIG[status] ?? {
|
||||
label: status,
|
||||
// Readable title-case fallback so an unmapped status never leaks the raw
|
||||
// SCREAMING_SNAKE enum to the customer.
|
||||
label: status
|
||||
.replace(/_/g, " ")
|
||||
.toLowerCase()
|
||||
.replace(/\b\w/g, (m) => m.toUpperCase()),
|
||||
color: MUTED,
|
||||
bg: "#EEF2F6",
|
||||
};
|
||||
|
||||
@@ -286,7 +286,7 @@ export const initialContractFormValues: DeepPartial<ContractFormValues> = {
|
||||
customsClearingAgent: "",
|
||||
|
||||
cargoType: "container",
|
||||
enabledContainerSizes: ["20ft"],
|
||||
enabledContainerSizes: [],
|
||||
containerSizeCaps: {},
|
||||
cargoTypePath: [],
|
||||
cargoFreeText: "",
|
||||
|
||||
@@ -26,8 +26,17 @@ export interface ShipmentValidationContext {
|
||||
requiresDate?: boolean;
|
||||
}
|
||||
|
||||
// ISO 6346: 3-letter owner code + category id (U/J/Z) + 6-digit serial + check digit.
|
||||
const ISO_CONTAINER_NUMBER_REGEX = /^[A-Z]{4}\d{7}$/;
|
||||
|
||||
const containerUnitSchema = z.object({
|
||||
containerNumber: z.string().min(1, "Container number is required."),
|
||||
containerNumber: z
|
||||
.string()
|
||||
.min(1, "Container number is required.")
|
||||
.refine(
|
||||
(v) => ISO_CONTAINER_NUMBER_REGEX.test(v.trim().toUpperCase()),
|
||||
"Enter a valid ISO container number (e.g. ABCD1234567).",
|
||||
),
|
||||
sealNumber: z.string().default(""),
|
||||
vgmTons: z
|
||||
.string()
|
||||
@@ -68,6 +77,18 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) {
|
||||
}
|
||||
|
||||
if (ctx.isContainer) {
|
||||
// Container numbers must be unique within this shipment (front-end only —
|
||||
// the DB column is intentionally not unique). Duplicates block submit and
|
||||
// price generation since both run through this same schema validation.
|
||||
const numberCounts = new Map<string, number>();
|
||||
data.containers.forEach((line) => {
|
||||
line.units.forEach((u) => {
|
||||
const key = u.containerNumber.trim().toUpperCase();
|
||||
if (!key) return;
|
||||
numberCounts.set(key, (numberCounts.get(key) ?? 0) + 1);
|
||||
});
|
||||
});
|
||||
|
||||
data.containers.forEach((line, i) => {
|
||||
const qty = Number(line.quantity || 0);
|
||||
if (qty >= 1 && line.units.length < qty) {
|
||||
@@ -77,9 +98,25 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) {
|
||||
message: `Enter details for all ${qty} container(s).`,
|
||||
});
|
||||
}
|
||||
line.units.forEach((u, j) => {
|
||||
const key = u.containerNumber.trim().toUpperCase();
|
||||
if (key && (numberCounts.get(key) ?? 0) > 1) {
|
||||
refineCtx.addIssue({
|
||||
code: "custom",
|
||||
path: ["containers", i, "units", j, "containerNumber"],
|
||||
message: "Duplicate container number in this shipment.",
|
||||
});
|
||||
}
|
||||
});
|
||||
if (ctx.isHazardous) {
|
||||
const h = Number(line.hazardousQuantity || 0);
|
||||
if (h > qty) {
|
||||
if (h < 0) {
|
||||
refineCtx.addIssue({
|
||||
code: "custom",
|
||||
path: ["containers", i, "hazardousQuantity"],
|
||||
message: "Enter a valid hazardous quantity.",
|
||||
});
|
||||
} else if (h > qty) {
|
||||
refineCtx.addIssue({
|
||||
code: "custom",
|
||||
path: ["containers", i, "hazardousQuantity"],
|
||||
@@ -89,7 +126,13 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) {
|
||||
}
|
||||
if (ctx.isReefer) {
|
||||
const r = Number(line.reeferQuantity || 0);
|
||||
if (r > qty) {
|
||||
if (r < 0) {
|
||||
refineCtx.addIssue({
|
||||
code: "custom",
|
||||
path: ["containers", i, "reeferQuantity"],
|
||||
message: "Enter a valid refrigerated quantity.",
|
||||
});
|
||||
} else if (r > qty) {
|
||||
refineCtx.addIssue({
|
||||
code: "custom",
|
||||
path: ["containers", i, "reeferQuantity"],
|
||||
@@ -99,10 +142,21 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) {
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const bulkCap =
|
||||
ctx.unitOfMeasure === "PER_ITEM"
|
||||
? Number(data.itemCount || 0)
|
||||
: Number(data.cargoWeightTons || 0);
|
||||
const isPerItem = ctx.unitOfMeasure === "PER_ITEM";
|
||||
const bulkCap = isPerItem
|
||||
? Number(data.itemCount || 0)
|
||||
: Number(data.cargoWeightTons || 0);
|
||||
|
||||
// The bulk cargo amount itself: a positive magnitude. Without this a
|
||||
// negative (typed past the input's `min`) reaches the API unchecked.
|
||||
const bulkPath = isPerItem ? "itemCount" : "cargoWeightTons";
|
||||
if (Number.isNaN(bulkCap) || bulkCap <= 0) {
|
||||
refineCtx.addIssue({
|
||||
code: "custom",
|
||||
path: [bulkPath],
|
||||
message: "Enter a quantity greater than 0.",
|
||||
});
|
||||
}
|
||||
|
||||
const boundBulkPortion = (
|
||||
on: boolean,
|
||||
|
||||
@@ -264,6 +264,13 @@ export const api = {
|
||||
bookingsService.downloadHandoverDocument(inventoryId),
|
||||
),
|
||||
|
||||
downloadBookingHandoverDocument: endpoint<{ bookingId: string }, Blob>(
|
||||
"bookings",
|
||||
"downloadBookingHandoverDocument",
|
||||
({ bookingId }) =>
|
||||
bookingsService.downloadBookingHandoverDocument(bookingId),
|
||||
),
|
||||
|
||||
create: endpoint<
|
||||
{ payload: CreateBookingPayload; documents?: BookingDocuments },
|
||||
Freight.IBooking
|
||||
|
||||
@@ -7,6 +7,26 @@ import { client } from "../utils/api";
|
||||
|
||||
const B = URL_CONSTANTS.BOOKINGS;
|
||||
|
||||
export interface MileVehicleSummary {
|
||||
plate: string | null;
|
||||
code: string | null;
|
||||
driverName: string | null;
|
||||
containerNumber: string | null;
|
||||
distanceKm: number | null;
|
||||
}
|
||||
export interface MileLegSummary {
|
||||
status: string;
|
||||
exactKm: number | null;
|
||||
remainingPayment: number | null;
|
||||
currency: string;
|
||||
invoiced: boolean;
|
||||
vehicles: MileVehicleSummary[];
|
||||
}
|
||||
export interface MileSummaryResponse {
|
||||
firstMile: MileLegSummary | null;
|
||||
lastMile: MileLegSummary | null;
|
||||
}
|
||||
|
||||
export type CreateBookingPayload = Freight.CreateBookingDto;
|
||||
|
||||
export interface ContractView {
|
||||
@@ -150,6 +170,10 @@ export const bookingsService = {
|
||||
const { data } = await client.get(`/api/bookings/${id}`);
|
||||
return data.data;
|
||||
},
|
||||
mileSummary: async (id: string): Promise<MileSummaryResponse> => {
|
||||
const { data } = await client.get(`/api/bookings/${id}/mile-summary`);
|
||||
return data.data;
|
||||
},
|
||||
assignCustomerTruck: async (
|
||||
id: string,
|
||||
payload: CustomerTruckAssignmentPayload,
|
||||
@@ -174,6 +198,13 @@ export const bookingsService = {
|
||||
);
|
||||
return data;
|
||||
},
|
||||
downloadBookingHandoverDocument: async (bookingId: string): Promise<Blob> => {
|
||||
const { data } = await client.get(
|
||||
`/api/warehouse-inventory/bookings/${bookingId}/handover-document`,
|
||||
{ responseType: "blob" },
|
||||
);
|
||||
return data;
|
||||
},
|
||||
tracking: async (id: string): Promise<Freight.IBookingTracking> => {
|
||||
const { data } = await client.get(`/api/bookings/${id}/tracking`);
|
||||
return data.data;
|
||||
|
||||
@@ -338,7 +338,14 @@ export class PaymentsService {
|
||||
};
|
||||
return this.prisma.paymentIntent.upsert({
|
||||
where: { bookingId },
|
||||
update: data,
|
||||
// amountMinor/currency are refreshed on update too: a cross-currency method switch
|
||||
// (e.g. Waafi/USD → Telebirr/ETB) re-initiates over the same row, and the projection
|
||||
// must reflect the currency the new provider actually charges — not the first one's.
|
||||
update: {
|
||||
...data,
|
||||
amountMinor: snapshot.amountMinor,
|
||||
currency: snapshot.currency,
|
||||
},
|
||||
create: {
|
||||
bookingId,
|
||||
amountMinor: snapshot.amountMinor,
|
||||
|
||||
@@ -29,12 +29,12 @@ export default function LoginPage() {
|
||||
const [emailFocused, setEmailFocused] = useState(false);
|
||||
const [passwordFocused, setPasswordFocused] = useState(false);
|
||||
|
||||
const [view, setView] = useState<'login' | 'forgot'>('login');
|
||||
const [forgotEmail, setForgotEmail] = useState('');
|
||||
const [forgotLoading, setForgotLoading] = useState(false);
|
||||
const [forgotError, setForgotError] = useState('');
|
||||
const [forgotSent, setForgotSent] = useState(false);
|
||||
const [forgotFocused, setForgotFocused] = useState(false);
|
||||
const [view, setView] = useState<'login' | 'forgot'>('login');
|
||||
const [forgotIdentifier, setForgotIdentifier] = useState('');
|
||||
const [forgotLoading, setForgotLoading] = useState(false);
|
||||
const [forgotError, setForgotError] = useState('');
|
||||
const [forgotSent, setForgotSent] = useState(false);
|
||||
const [forgotFocused, setForgotFocused] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { login } = useAuthStore();
|
||||
@@ -66,13 +66,13 @@ export default function LoginPage() {
|
||||
setForgotLoading(true);
|
||||
setForgotError('');
|
||||
try {
|
||||
await iamAuthApi.forgotPassword(forgotEmail);
|
||||
await iamAuthApi.forgotPassword(forgotIdentifier.trim());
|
||||
setForgotSent(true);
|
||||
} catch (err: any) {
|
||||
const msg = err.response?.data?.message || err.message || '';
|
||||
setForgotError(
|
||||
msg === 'user_not_found'
|
||||
? 'No account found with that email address.'
|
||||
? 'No account found with that email or phone number.'
|
||||
: msg || 'Failed to send the reset link. Please try again.'
|
||||
);
|
||||
} finally {
|
||||
@@ -209,7 +209,7 @@ export default function LoginPage() {
|
||||
<div className="flex justify-end mt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setView('forgot'); setForgotEmail(email); setError(''); }}
|
||||
onClick={() => { setView('forgot'); setForgotIdentifier(email); setError(''); }}
|
||||
className="text-xs font-medium text-[rgb(20,113,76)] hover:underline"
|
||||
>
|
||||
Forgot password?
|
||||
@@ -260,7 +260,7 @@ export default function LoginPage() {
|
||||
Reset your password
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Enter your email address and we'll send a reset link to the phone number on your account.
|
||||
Enter your email or phone number and we'll send a reset link to the phone number on your account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -295,10 +295,10 @@ export default function LoginPage() {
|
||||
)}
|
||||
|
||||
<form onSubmit={handleForgotSubmit} className="space-y-4 animate-fade-up" style={{ animationDelay: '80ms' }}>
|
||||
{/* Email field */}
|
||||
{/* Email or phone field */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider mb-2">
|
||||
Email address
|
||||
Email or phone number
|
||||
</label>
|
||||
<div className={`relative rounded-xl transition-all duration-200 ${
|
||||
forgotFocused
|
||||
@@ -306,15 +306,15 @@ export default function LoginPage() {
|
||||
: 'ring-1 ring-gray-200 dark:ring-gray-800'
|
||||
}`}>
|
||||
<input
|
||||
type="email"
|
||||
value={forgotEmail}
|
||||
onChange={(e) => { setForgotEmail(e.target.value); setForgotError(''); }}
|
||||
type="text"
|
||||
value={forgotIdentifier}
|
||||
onChange={(e) => { setForgotIdentifier(e.target.value); setForgotError(''); }}
|
||||
onFocus={() => setForgotFocused(true)}
|
||||
onBlur={() => setForgotFocused(false)}
|
||||
className="w-full px-4 py-3 rounded-xl bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-600 text-sm focus:outline-none"
|
||||
placeholder="name@edr.com"
|
||||
placeholder="name@edr.com or +251..."
|
||||
required
|
||||
autoComplete="email"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -322,9 +322,9 @@ export default function LoginPage() {
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={forgotLoading || !forgotEmail}
|
||||
disabled={forgotLoading || !forgotIdentifier.trim()}
|
||||
className="group w-full mt-2 flex items-center justify-center gap-2 py-3 px-4 rounded-xl font-semibold text-sm text-white transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
style={{ background: forgotLoading || !forgotEmail
|
||||
style={{ background: forgotLoading || !forgotIdentifier.trim()
|
||||
? 'rgb(20,113,76)'
|
||||
: `linear-gradient(135deg, rgb(20,113,76) 0%, rgb(16,143,96) 100%)`
|
||||
}}
|
||||
|
||||
@@ -4,8 +4,10 @@ const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
||||
|
||||
|
||||
export const iamAuthApi = {
|
||||
forgotPassword: (email: string) =>
|
||||
axios.post(`${API_URL}/v1/auth/forgot-password`, { email }),
|
||||
// `identifier` can be an email address or a phone number — the IAM accepts
|
||||
// either in the `email` field of the forgot-password body.
|
||||
forgotPassword: (identifier: string) =>
|
||||
axios.post(`${API_URL}/v1/auth/forgot-password`, { email: identifier }),
|
||||
|
||||
resetPassword: (data: {
|
||||
userId: string;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Train, MailCheck, ArrowLeft } from 'lucide-react';
|
||||
import { iamAuthApi } from '@/lib/api/auth';
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [identifier, setIdentifier] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [sent, setSent] = useState(false);
|
||||
@@ -16,13 +16,13 @@ export default function ForgotPasswordPage() {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await iamAuthApi.forgotPassword(email);
|
||||
await iamAuthApi.forgotPassword(identifier.trim());
|
||||
setSent(true);
|
||||
} catch (err: any) {
|
||||
const msg = err.response?.data?.message || err.message || '';
|
||||
setError(
|
||||
msg === 'user_not_found'
|
||||
? 'No account found with that email address.'
|
||||
? 'No account found with that email or phone number.'
|
||||
: msg || 'Failed to send the reset link. Please try again.'
|
||||
);
|
||||
} finally {
|
||||
@@ -41,7 +41,7 @@ export default function ForgotPasswordPage() {
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Reset your password</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-2">
|
||||
Enter your email and we'll send a reset link to the phone number on your account.
|
||||
Enter your email or phone number and we'll send a reset link to the phone number on your account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -69,19 +69,19 @@ export default function ForgotPasswordPage() {
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email</label>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email or phone number</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => { setEmail(e.target.value); setError(''); }}
|
||||
type="text"
|
||||
value={identifier}
|
||||
onChange={(e) => { setIdentifier(e.target.value); setError(''); }}
|
||||
className="input-field"
|
||||
placeholder="your@email.com"
|
||||
autoComplete="email"
|
||||
placeholder="your@email.com or +251..."
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn-primary w-full" disabled={loading || !email}>
|
||||
<button type="submit" className="btn-primary w-full" disabled={loading || !identifier.trim()}>
|
||||
{loading ? 'Sending...' : 'Send reset link'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -8,8 +8,10 @@ const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
||||
// current password is wrong on change-password, and OTP failures must surface
|
||||
// as inline errors, not a logout.
|
||||
export const iamAuthApi = {
|
||||
forgotPassword: (email: string) =>
|
||||
axios.post(`${API_URL}/v1/auth/forgot-password`, { email }),
|
||||
// `identifier` can be an email address or a phone number — the IAM accepts
|
||||
// either in the `email` field of the forgot-password body.
|
||||
forgotPassword: (identifier: string) =>
|
||||
axios.post(`${API_URL}/v1/auth/forgot-password`, { email: identifier }),
|
||||
|
||||
// Re-sends the registration verification code for a still-pending account.
|
||||
resendRegistrationCode: (data: { email: string; phoneNumber: string }) =>
|
||||
|
||||
@@ -77,8 +77,27 @@ export class IntentsService {
|
||||
request.referenceId,
|
||||
);
|
||||
if (existing) {
|
||||
const reusable = await this.reuseOrRetire(existing);
|
||||
if (reusable) return this.toSnapshot(reusable);
|
||||
// Payer switched method (e.g. Waafi → Telebirr) on an uncharged session: retire the
|
||||
// open intent and fall through to open a fresh one for the new provider. Only safe
|
||||
// while REQUIRES_ACTION — PROCESSING/SUCCEEDED intents may have money in flight, so
|
||||
// they keep the reuse path (the switch is silently refused until they resolve).
|
||||
const switchingProvider =
|
||||
existing.provider !== request.provider &&
|
||||
existing.status === ProviderPaymentStatus.REQUIRES_ACTION;
|
||||
if (switchingProvider) {
|
||||
await this.intentsRepository.update(existing.id, {
|
||||
status: ProviderPaymentStatus.CANCELLED,
|
||||
failureCode: "METHOD_CHANGED",
|
||||
failureMessage: `Payer switched from ${existing.provider} to ${request.provider}`,
|
||||
});
|
||||
this.logger.log(
|
||||
`intent ${existing.id} retired (METHOD_CHANGED ${existing.provider} → ${request.provider}) for ` +
|
||||
`${request.service}/${request.referenceType}/${request.referenceId}`,
|
||||
);
|
||||
} else {
|
||||
const reusable = await this.reuseOrRetire(existing);
|
||||
if (reusable) return this.toSnapshot(reusable);
|
||||
}
|
||||
}
|
||||
|
||||
const provider = this.providers.get(request.provider);
|
||||
|
||||
@@ -45,22 +45,16 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
|
||||
return {
|
||||
CAC_BASE_URL: this.baseUrl || "(empty)",
|
||||
CAC_USERNAME: this.username || "(empty)",
|
||||
CAC_PASSWORD: this.mask(this.password),
|
||||
CAC_APP_KEY: this.mask(this.appKey),
|
||||
CAC_API_KEY: this.mask(this.apiKey),
|
||||
CAC_PASSWORD: this.password,
|
||||
CAC_APP_KEY: this.appKey,
|
||||
CAC_API_KEY: this.apiKey,
|
||||
CAC_COMPANY_SERVICES_ID: this.companyServicesId,
|
||||
CAC_CURRENCY: this.defaultCurrency,
|
||||
CAC_TOKEN_TTL_MS: this.tokenTtlMs,
|
||||
CAC_OTP_EXPIRY_MS: this.otpExpiryMs,
|
||||
};
|
||||
}
|
||||
|
||||
/** Mask a secret to `set(len=N,…abcd)` / `(empty)` so presence & length are visible but not the value. */
|
||||
private mask(value: string): string {
|
||||
if (!value) return "(empty)";
|
||||
const tail = value.length > 4 ? value.slice(-4) : "";
|
||||
return `set(len=${value.length},…${tail})`;
|
||||
}
|
||||
|
||||
|
||||
async initiate(
|
||||
input: ProviderInitiationInput,
|
||||
|
||||
Reference in New Issue
Block a user