mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Refactor wagon type handling and container wagon calculations
- Removed maxWagonsPerTrain from WagonType entity and related DTOs. - Updated containerWagonsForLines function to calculate required wagons based on container lines more accurately. - Added unit tests for containerWagonsForLines to ensure correct calculations. - Adjusted related services and scripts to reflect the removal of maxWagonsPerTrain. - Enhanced booking and contract components to use new status labels for better user experience. - Implemented validation for unique container numbers in shipment forms.
This commit is contained in:
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,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)],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -163,7 +163,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 +181,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';
|
||||
|
||||
@@ -42,7 +42,10 @@ 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,
|
||||
@@ -1014,17 +1017,22 @@ 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();
|
||||
@@ -1034,13 +1042,14 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonLengths);
|
||||
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
|
||||
@@ -1106,6 +1115,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 +1135,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1499,7 +1510,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.fillSchedule(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 +1526,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.fillSchedule(scheduleId);
|
||||
if (topUpReserved > 0) {
|
||||
await this.extendPaymentPhaseForTopUp(scheduleId);
|
||||
}
|
||||
void this.triggerWagonAllocation(scheduleId);
|
||||
}
|
||||
|
||||
@@ -1613,8 +1633,12 @@ export class BookingBatchService implements OnModuleInit {
|
||||
.findOne({ where: { id: bookingId } });
|
||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
await this.expire(booking);
|
||||
if (booking.trainScheduleId)
|
||||
await this.fillSchedule(booking.trainScheduleId);
|
||||
if (booking.trainScheduleId) {
|
||||
const topUpReserved = await this.fillSchedule(booking.trainScheduleId);
|
||||
if (topUpReserved > 0) {
|
||||
await this.extendPaymentPhaseForTopUp(booking.trainScheduleId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- intercity ride-along API ---------------------------------------------
|
||||
@@ -1671,6 +1695,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, {
|
||||
@@ -2020,14 +2066,15 @@ 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. */
|
||||
@@ -2061,6 +2108,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,
|
||||
{
|
||||
@@ -2256,6 +2305,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 {
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
import {
|
||||
bookingTrainLengthMeters,
|
||||
deriveTrainCapacityFromLocomotive,
|
||||
minLocomotiveLimits,
|
||||
} from './train-capacity.util';
|
||||
|
||||
describe('train-capacity.util', () => {
|
||||
@@ -38,4 +39,31 @@ describe('train-capacity.util', () => {
|
||||
).toBe(28);
|
||||
expect(bookingTrainLengthMeters('BULK', 3, { container: 14, bulk: 18 })).toBe(54);
|
||||
});
|
||||
|
||||
it('extends weight/length caps by the locomotive overage tolerance (fertilizer +90T example)', () => {
|
||||
const pw2 = { lengthMeters: 17.066, capacityTons: 70 };
|
||||
const derived = deriveTrainCapacityFromLocomotive(
|
||||
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
|
||||
[pw2],
|
||||
);
|
||||
expect(derived.maxWeightTons).toBe(3590);
|
||||
});
|
||||
|
||||
it('ignores overage tolerance when unset (strict cap, no behavior change)', () => {
|
||||
const derived = deriveTrainCapacityFromLocomotive(
|
||||
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760 },
|
||||
[{ lengthMeters: 14, capacityTons: 70 }],
|
||||
);
|
||||
expect(derived.maxWeightTons).toBe(3500);
|
||||
expect(derived.maxLengthMeters).toBe(760);
|
||||
});
|
||||
|
||||
it('takes the weakest locomotive tolerance 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,10 @@ export type WagonTypeDimensions = {
|
||||
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 = {
|
||||
@@ -29,14 +33,19 @@ export function deriveTrainCapacityFromLocomotive(
|
||||
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 overageTons = Number(locomotive.overageToleranceTons) || 0;
|
||||
const overageMeters = Number(locomotive.overageToleranceMeters) || 0;
|
||||
|
||||
const maxWeightTons =
|
||||
Math.min(
|
||||
Number(locomotive.maxPullWeightTons) || Infinity,
|
||||
ruleCaps?.maxTrainWeightTons ?? Infinity,
|
||||
) + overageTons;
|
||||
const maxLengthMeters =
|
||||
Math.min(
|
||||
Number(locomotive.maxTrainLengthMeters) || Infinity,
|
||||
ruleCaps?.maxTrainLengthMeters ?? Infinity,
|
||||
) + overageMeters;
|
||||
|
||||
const types =
|
||||
wagonTypes.length > 0
|
||||
@@ -75,7 +84,10 @@ 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 {
|
||||
@@ -85,6 +97,13 @@ export function minLocomotiveLimits(
|
||||
maxTrainLengthMeters: Math.min(
|
||||
...locomotives.map((l) => Number(l.maxTrainLengthMeters) || Infinity),
|
||||
),
|
||||
// Weakest locomotive's tolerance governs the set, same as its caps.
|
||||
overageToleranceTons: Math.min(
|
||||
...locomotives.map((l) => Number(l.overageToleranceTons) || 0),
|
||||
),
|
||||
overageToleranceMeters: Math.min(
|
||||
...locomotives.map((l) => Number(l.overageToleranceMeters) || 0),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -975,13 +975,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`,
|
||||
);
|
||||
@@ -2916,8 +2922,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',
|
||||
@@ -2935,8 +2943,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']);
|
||||
@@ -2991,7 +3001,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<{
|
||||
@@ -3018,6 +3031,8 @@ export class TrainSchedulingService {
|
||||
{
|
||||
maxPullWeightTons: Number(locomotive.maxPullWeightTons),
|
||||
maxTrainLengthMeters: Number(locomotive.maxTrainLengthMeters),
|
||||
overageToleranceTons: Number(locomotive.overageToleranceTons) || 0,
|
||||
overageToleranceMeters: Number(locomotive.overageToleranceMeters) || 0,
|
||||
},
|
||||
wagonTypes,
|
||||
{
|
||||
@@ -3688,10 +3703,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`,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,18 +89,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,15 +130,16 @@ 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,
|
||||
@@ -424,7 +445,7 @@ export function validateBulkWagonSlotWeights(wagonPlan: WagonPlanSlot[]): string
|
||||
|
||||
export function validateTrainLimits(
|
||||
wagonPlan: WagonPlanSlot[],
|
||||
wagonType: WagonType,
|
||||
wagonType: Pick<WagonType, 'lengthMeters'>,
|
||||
limits?: TrainLimitConfig,
|
||||
): string[] {
|
||||
const violations: string[] = [];
|
||||
@@ -478,7 +499,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,13 +56,6 @@ 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;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Supported load types, e.g. CONTAINER,BULK',
|
||||
type: [String],
|
||||
|
||||
@@ -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[];
|
||||
|
||||
|
||||
@@ -80,7 +80,6 @@ export class WagonTypesService {
|
||||
name: dto.name.trim(),
|
||||
capacityTons: dto.capacityTons,
|
||||
lengthMeters: dto.lengthMeters,
|
||||
maxWagonsPerTrain: dto.maxWagonsPerTrain ?? null,
|
||||
supportedLoadTypes: dto.supportedLoadTypes ?? [],
|
||||
isActive: dto.isActive ?? true,
|
||||
});
|
||||
@@ -101,8 +100,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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -536,7 +536,6 @@ export function WagonTypesCrudPage() {
|
||||
name: '',
|
||||
capacityTons: 0,
|
||||
lengthMeters: 0,
|
||||
maxWagonsPerTrain: '',
|
||||
supportedLoadTypes: '',
|
||||
isActive: true,
|
||||
});
|
||||
@@ -587,7 +586,6 @@ export function WagonTypesCrudPage() {
|
||||
name: '',
|
||||
capacityTons: 0,
|
||||
lengthMeters: 0,
|
||||
maxWagonsPerTrain: '',
|
||||
supportedLoadTypes: '',
|
||||
isActive: true,
|
||||
});
|
||||
@@ -601,7 +599,6 @@ export function WagonTypesCrudPage() {
|
||||
name: type.name ?? '',
|
||||
capacityTons: type.capacityTons ?? 0,
|
||||
lengthMeters: type.lengthMeters ?? 0,
|
||||
maxWagonsPerTrain: type.maxWagonsPerTrain ?? '',
|
||||
supportedLoadTypes: type.supportedLoadTypes?.join(', ') ?? '',
|
||||
isActive: type.isActive,
|
||||
});
|
||||
@@ -814,12 +811,6 @@ export function WagonTypesCrudPage() {
|
||||
error={fieldErrors.lengthMeters}
|
||||
onChange={(value) => setForm((current) => ({ ...current, lengthMeters: value }))}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Max wagons per train"
|
||||
min={0}
|
||||
value={form.maxWagonsPerTrain === '' ? '' : Number(form.maxWagonsPerTrain)}
|
||||
onChange={(value) => setForm((current) => ({ ...current, maxWagonsPerTrain: value }))}
|
||||
/>
|
||||
<MantineSelect
|
||||
label="Status"
|
||||
value={form.isActive ? 'true' : 'false'}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -8,7 +8,6 @@ export interface WagonType {
|
||||
name: string;
|
||||
capacityTons: number;
|
||||
lengthMeters: number;
|
||||
maxWagonsPerTrain?: number | null;
|
||||
supportedLoadTypes: string[];
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -1151,6 +1151,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}
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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,6 +98,16 @@ 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) {
|
||||
|
||||
Reference in New Issue
Block a user