mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 12:41:04 +00:00
Merge pull request #923 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -289,6 +289,7 @@ export class ContractBookingService {
|
||||
tradeDirection: contract.tradeDirection,
|
||||
freightType,
|
||||
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
||||
cargoFreeText: dto.cargoFreeText?.trim() || null,
|
||||
isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'),
|
||||
isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'),
|
||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||
@@ -738,6 +739,7 @@ export class ContractBookingService {
|
||||
}
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
||||
cargoFreeText: dto.cargoFreeText?.trim() || null,
|
||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
|
||||
} as never);
|
||||
|
||||
@@ -182,6 +182,13 @@ export class CreateBookingUnderContractDto {
|
||||
@Type(() => CreateBulkLineDto)
|
||||
bulkLines?: CreateBulkLineDto[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'What the containers carry — captured per booking (container freight).',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cargoFreeText?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -50,8 +50,11 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
|
||||
company: true,
|
||||
originYard: true,
|
||||
destinationYard: true,
|
||||
bookingContainers: { containerType: true },
|
||||
cargoType: true,
|
||||
// wagonTypes feed grossBookingWeightTons the REAL tare of the
|
||||
// wagon type the booking rides — without them it falls back to
|
||||
// default tares and the workspace gross drifts from the validator.
|
||||
bookingContainers: { containerType: { wagonTypes: true } },
|
||||
cargoType: { wagonTypes: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1187,6 +1187,7 @@ describe('BookingBatchService — built-train wagon capacity', () => {
|
||||
reserved: Booking[];
|
||||
maxWagons?: number;
|
||||
routeStops?: string[];
|
||||
yardCountries?: Record<string, string>;
|
||||
}) => {
|
||||
const schedule = {
|
||||
id: scheduleId,
|
||||
@@ -1218,10 +1219,21 @@ describe('BookingBatchService — built-train wagon capacity', () => {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const yardRepo = {
|
||||
find: jest
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
Object.entries(opts.yardCountries ?? {}).map(([id, country]) => ({
|
||||
id,
|
||||
country,
|
||||
})),
|
||||
),
|
||||
};
|
||||
const dataSource = {
|
||||
getRepository: jest.fn((entity: { name?: string }) => {
|
||||
if (entity?.name === 'Wagon') return wagonRepo;
|
||||
if (entity?.name === 'RouteMilestone') return milestoneRepo;
|
||||
if (entity?.name === 'Yard') return yardRepo;
|
||||
return genericRepo;
|
||||
}),
|
||||
transaction: jest.fn(),
|
||||
@@ -1264,11 +1276,11 @@ describe('BookingBatchService — built-train wagon capacity', () => {
|
||||
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('is FULL when sub-leg bookings hold every physical wagon of a milestone route', async () => {
|
||||
// Regression: 50 wagons sold Negad→Mojo on a Doraleh→…→Dire Dawa corridor
|
||||
// left the pass-through edges reading "free" in the per-edge budget, so the
|
||||
// full train's window cycled OPEN forever and the day pool never expired.
|
||||
// A wagon is committed for the whole trip — leg-free edges are not capacity.
|
||||
it('is NOT full when only a middle leg is sold and other edges run free (domestic route)', async () => {
|
||||
// Leg-aware allocation (planWagonsWithStock legs) made mid-leg wagons real
|
||||
// capacity on the edges they don't ride: a domestic corridor with cargo
|
||||
// only on m1→m2 still boards bookings on the free first/last edges, so the
|
||||
// window must stay open for them.
|
||||
const { service } = buildService({
|
||||
physicalWagons: 2,
|
||||
routeStops: ['yard-a', 'yard-m1', 'yard-m2', 'yard-b'],
|
||||
@@ -1277,9 +1289,48 @@ describe('BookingBatchService — built-train wagon capacity', () => {
|
||||
reservedBooking('b2', { origin: 'yard-m1', dest: 'yard-m2' }),
|
||||
],
|
||||
});
|
||||
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('is FULL for the trade direction once the border edge is sold out, even with home legs free', async () => {
|
||||
// Export b→c holds every wagon of the border crossing: no further export
|
||||
// can board anywhere (they all must ride that edge), so the window closes —
|
||||
// while intercity keeps booking the free a→b leg through the per-leg budget.
|
||||
const { service } = buildService({
|
||||
physicalWagons: 2,
|
||||
routeStops: ['yard-a', 'yard-b', 'yard-dj'],
|
||||
yardCountries: {
|
||||
'yard-a': 'ETHIOPIA',
|
||||
'yard-b': 'ETHIOPIA',
|
||||
'yard-dj': 'DJIBOUTI',
|
||||
},
|
||||
reserved: [
|
||||
reservedBooking('b1', { origin: 'yard-b', dest: 'yard-dj' }),
|
||||
reservedBooking('b2', { origin: 'yard-b', dest: 'yard-dj' }),
|
||||
],
|
||||
});
|
||||
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('is NOT full while the border edge still has room, even with a home leg sold out', async () => {
|
||||
// Intercity rode a→b on both wagons; the border edge b→dj is still free,
|
||||
// so exports can still board — the window stays open.
|
||||
const { service } = buildService({
|
||||
physicalWagons: 2,
|
||||
routeStops: ['yard-a', 'yard-b', 'yard-dj'],
|
||||
yardCountries: {
|
||||
'yard-a': 'ETHIOPIA',
|
||||
'yard-b': 'ETHIOPIA',
|
||||
'yard-dj': 'DJIBOUTI',
|
||||
},
|
||||
reserved: [
|
||||
reservedBooking('b1', { origin: 'yard-a', dest: 'yard-b' }),
|
||||
reservedBooking('b2', { origin: 'yard-a', dest: 'yard-b' }),
|
||||
],
|
||||
});
|
||||
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('reports over-allocation when the consist is trimmed below committed bookings', async () => {
|
||||
const { service } = buildService({
|
||||
physicalWagons: 1,
|
||||
|
||||
@@ -24,9 +24,9 @@ import {
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||
import { formatRouteLabel } from '../routes/entities/route.entity';
|
||||
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||
@@ -60,11 +60,14 @@ import {
|
||||
DEFAULT_WAGONS_PER_BOOKING,
|
||||
} from "./booking-batch.constants";
|
||||
import {
|
||||
LocomotiveLimits,
|
||||
WagonTypeDimensions,
|
||||
bookingCargoTons,
|
||||
bookingGrossWeightTons,
|
||||
deriveTrainCapacityFromLocomotive,
|
||||
sizePartialOfferWagons,
|
||||
trainHardCaps,
|
||||
trainSetLocomotiveLimits,
|
||||
wagonTypeDimensionsFromEntity,
|
||||
} from './train-capacity.util';
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
@@ -677,7 +680,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||
candidate.id,
|
||||
);
|
||||
const locomotive = schedule?.trainSet?.locomotive;
|
||||
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
|
||||
if (!schedule || !locomotive) continue;
|
||||
const limits = await this.capacityLimits(locomotive);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||
@@ -825,7 +828,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||
candidate.id,
|
||||
);
|
||||
const locomotive = schedule?.trainSet?.locomotive;
|
||||
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
|
||||
if (!schedule || !locomotive) continue;
|
||||
const limits = await this.capacityLimits(locomotive);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||
@@ -895,7 +898,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||
candidate.id,
|
||||
);
|
||||
const locomotive = schedule?.trainSet?.locomotive;
|
||||
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
|
||||
if (!schedule || !locomotive) continue;
|
||||
const limits = await this.capacityLimits(locomotive);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||
@@ -937,7 +940,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||
target.scheduleId,
|
||||
);
|
||||
const locomotive = schedule?.trainSet?.locomotive;
|
||||
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
|
||||
if (!schedule || !locomotive) return false;
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const limits = await this.capacityLimits(locomotive);
|
||||
@@ -1034,7 +1037,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
const schedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
const locomotive = schedule?.trainSet?.locomotive;
|
||||
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
|
||||
if (!schedule || !locomotive) {
|
||||
throw new ConflictException(
|
||||
"Export train is no longer available for reservation",
|
||||
@@ -1351,7 +1354,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
};
|
||||
});
|
||||
|
||||
const loco = s.trainSet?.locomotive ?? null;
|
||||
const loco = trainSetLocomotiveLimits(s.trainSet);
|
||||
|
||||
// The board renders ONE booking window — the schedule's own frozen window
|
||||
// (windowOpensAt/windowClosesAt + phase deadlines returned below). Bookings
|
||||
@@ -1411,10 +1414,12 @@ export class BookingBatchService implements OnModuleInit {
|
||||
trainName: s.trainSet.train.trainName ?? null,
|
||||
}
|
||||
: null,
|
||||
// Identity from the primary (legacy) locomotive; limit figures from the
|
||||
// whole set's effective minimum — what the fill engine actually spends.
|
||||
locomotive: loco
|
||||
? {
|
||||
code: loco.code,
|
||||
name: loco.name ?? null,
|
||||
code: s.trainSet?.locomotive?.code ?? '',
|
||||
name: s.trainSet?.locomotive?.name ?? null,
|
||||
maxPullWeightTons: Number(loco.maxPullWeightTons),
|
||||
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
||||
}
|
||||
@@ -1464,7 +1469,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
weightTons: number;
|
||||
lengthMeters: number;
|
||||
}>,
|
||||
loco: Locomotive | null,
|
||||
loco: LocomotiveLimits | null,
|
||||
maxWagons: number | null,
|
||||
): BatchBoardSchedule["capacity"] {
|
||||
const allocated = items.filter((i) => i.state === "ALLOCATED");
|
||||
@@ -1499,7 +1504,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
s: TrainSchedule,
|
||||
items: BatchBoardBooking[],
|
||||
): BatchBoardSchedule {
|
||||
const loco = s.trainSet?.locomotive ?? null;
|
||||
const loco = trainSetLocomotiveLimits(s.trainSet);
|
||||
|
||||
return {
|
||||
scheduleId: s.id,
|
||||
@@ -1531,10 +1536,12 @@ export class BookingBatchService implements OnModuleInit {
|
||||
trainName: s.trainSet.train.trainName ?? null,
|
||||
}
|
||||
: null,
|
||||
// Identity from the primary (legacy) locomotive; limit figures from the
|
||||
// whole set's effective minimum — what the fill engine actually spends.
|
||||
locomotive: loco
|
||||
? {
|
||||
code: loco.code,
|
||||
name: loco.name ?? null,
|
||||
code: s.trainSet?.locomotive?.code ?? '',
|
||||
name: s.trainSet?.locomotive?.name ?? null,
|
||||
maxPullWeightTons: Number(loco.maxPullWeightTons),
|
||||
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
||||
}
|
||||
@@ -1600,7 +1607,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const schedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule || !this.isFillable(schedule)) return 0;
|
||||
const locomotive = schedule.trainSet?.locomotive;
|
||||
const locomotive = trainSetLocomotiveLimits(schedule.trainSet);
|
||||
if (!schedule.trainSetId || !locomotive) {
|
||||
this.logger.warn(
|
||||
`Schedule ${scheduleId} has no locomotive/train set — skipped.`,
|
||||
@@ -1827,7 +1834,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
for (const id of scheduleIds) {
|
||||
const schedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(id);
|
||||
const locomotive = schedule?.trainSet?.locomotive;
|
||||
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
|
||||
if (!schedule || !schedule.trainSetId || !locomotive) {
|
||||
this.logger.warn(
|
||||
`Schedule ${id} has no locomotive/train set — skipped.`,
|
||||
@@ -2469,7 +2476,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
} | null> {
|
||||
const schedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
const locomotive = schedule?.trainSet?.locomotive;
|
||||
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
|
||||
if (!schedule || !locomotive) return null;
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const limits = await this.capacityLimits(locomotive);
|
||||
@@ -3114,8 +3121,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const containers = (b: Booking): number =>
|
||||
(b.bookingContainers ?? []).reduce((sum, c) => sum + Number(c.quantity ?? 0), 0);
|
||||
const totalContainers = containers(primary) + containers(partner);
|
||||
const cargoTons =
|
||||
Number(primary.cargoTotalWeightVgm ?? 0) + Number(partner.cargoTotalWeightVgm ?? 0);
|
||||
const cargoTons = bookingCargoTons(primary) + bookingCargoTons(partner);
|
||||
|
||||
// Consolidation shares TEU slots, never rated payload: the pair still needs
|
||||
// enough wagons to carry its combined cargo, so the weight axis bounds the
|
||||
@@ -3222,7 +3228,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const byLength = containerWagonsForLines(booking.bookingContainers ?? []);
|
||||
|
||||
const capacityTons = this.dimsFor(booking, wagonDims).capacityTons;
|
||||
const cargoTons = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
const cargoTons = bookingCargoTons(booking);
|
||||
const byWeight =
|
||||
cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0;
|
||||
|
||||
@@ -3243,7 +3249,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return {
|
||||
wagons,
|
||||
weightTons: bookingGrossWeightTons(
|
||||
Number(booking.cargoTotalWeightVgm ?? 0),
|
||||
bookingCargoTons(booking),
|
||||
wagons,
|
||||
dims.tareWeightTons,
|
||||
),
|
||||
@@ -3270,7 +3276,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
* caps deliberately do not apply here (a mis-set global row once capped
|
||||
* every train at 14m and no export booking could board).
|
||||
*/
|
||||
private async capacityLimits(locomotive: Locomotive): Promise<TrainLimits> {
|
||||
private async capacityLimits(locomotive: LocomotiveLimits): Promise<TrainLimits> {
|
||||
const wagonTypes = await this.loadWagonTypeDimensions();
|
||||
const derived = deriveTrainCapacityFromLocomotive(
|
||||
{
|
||||
@@ -3304,7 +3310,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
*/
|
||||
private async syncScheduleMaxWagons(
|
||||
schedule: TrainSchedule,
|
||||
locomotive: Locomotive,
|
||||
locomotive: LocomotiveLimits,
|
||||
): Promise<void> {
|
||||
const physicalWagons = await this.builtTrainWagonCount(schedule);
|
||||
const maxWagons =
|
||||
@@ -3605,14 +3611,14 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
|
||||
/**
|
||||
* Built train: FULL when every physical wagon slot is taken — the consist is
|
||||
* the capacity, weight/length were settled at build time.
|
||||
* No built train: FULL on ANY capacity axis — out of wagon slots, or out of
|
||||
* pull weight / train length for even one more loaded wagon. The old
|
||||
* slot-only check let a weight-bound train (PW2: weight binds at 37 wagons =
|
||||
* 3522.4T of 3500+90T, slots bind at 44) cycle its booking window forever
|
||||
* instead of finalizing — 7 phantom slots kept it "not full" while nothing
|
||||
* could board.
|
||||
* FULL is DIRECTIONAL: the schedule's trade direction is full when the
|
||||
* border-crossing edge (which every export/import must ride) can't take one
|
||||
* more minimal wagon on any axis — slots for built trains (the consist is
|
||||
* the capacity, weight/length settled at build), all three axes otherwise
|
||||
* (PW2: weight binds at 37 wagons = 3522.4T of 3500+90T, slots bind at 44).
|
||||
* Home-side legs may still run empty; intercity ride-alongs keep filling
|
||||
* them via the per-leg budget and never consult this flag. Domestic routes
|
||||
* (no border) are full only when every edge is closed.
|
||||
*/
|
||||
async isScheduleFull(scheduleId: string): Promise<boolean> {
|
||||
const schedule =
|
||||
@@ -3663,48 +3669,69 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
/** See {@link isScheduleFull} — same check for callers that already hold the full graph. */
|
||||
private async isTrainFull(schedule: TrainSchedule): Promise<boolean> {
|
||||
// Built train: the physical consist is the only capacity axis, and a wagon
|
||||
// is committed to its booking for the WHOLE trip — wagon allocation has no
|
||||
// leg concept, so a wagon hauling Negad→Mojo cargo can never be re-sold for
|
||||
// the Doraleh→Negad edge it merely passes through. Count commitments
|
||||
// train-wide, not per corridor edge: the per-edge budget read "free slots"
|
||||
// on pass-through legs of a sold-out consist, so the window of a full train
|
||||
// cycled OPEN forever instead of concluding DONE (and the day pool's
|
||||
// leftover bookings were never expired).
|
||||
const physicalWagons = await this.builtTrainWagonCount(schedule);
|
||||
if (physicalWagons != null) {
|
||||
return (await this.committedWagons(schedule)) >= physicalWagons;
|
||||
}
|
||||
if ((await this.remainingWagons(schedule)) <= 0) return true;
|
||||
const locomotive = schedule.trainSet?.locomotive;
|
||||
if (!locomotive) return false; // no weight/length limits to bind against
|
||||
// "Full" means full FOR THE TRAIN'S TRADE DIRECTION. Every export and
|
||||
// every import must cross the ET↔DJ border edge, so once that edge can't
|
||||
// take one more minimal wagon the booking window may close — even while
|
||||
// home-side legs still run empty. Intercity ride-alongs never consult this
|
||||
// flag; they keep booking the free legs through the per-leg budget.
|
||||
// A single-country (domestic) corridor has no mandatory edge, so it is
|
||||
// full only when EVERY edge is closed on some axis.
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const limits = await this.capacityLimits(locomotive);
|
||||
const physicalWagons = await this.builtTrainWagonCount(schedule);
|
||||
let limits: TrainLimits;
|
||||
if (physicalWagons != null) {
|
||||
// The consist is the capacity; weight/length were settled at build time.
|
||||
// remainingBudget swaps in the physical wagon count per edge itself.
|
||||
limits = {
|
||||
base: {
|
||||
wagons: physicalWagons,
|
||||
weightTons: Number.POSITIVE_INFINITY,
|
||||
lengthMeters: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
tolerance: { weightTons: 0, lengthMeters: 0 },
|
||||
};
|
||||
} else {
|
||||
const locomotive = trainSetLocomotiveLimits(schedule.trainSet);
|
||||
// No loco, no built train: only the slot axis exists to bind against.
|
||||
if (!locomotive) return (await this.remainingWagons(schedule)) <= 0;
|
||||
limits = await this.capacityLimits(locomotive);
|
||||
}
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||
return budget.isExhausted(this.minPerWagonNeed(wagonDims));
|
||||
const minNeed = this.minPerWagonNeed(wagonDims);
|
||||
const border = await this.borderLeg(budget.stops);
|
||||
if (border) {
|
||||
return !budget.fits(
|
||||
{
|
||||
wagons: 1,
|
||||
weightTons: minNeed.grossWeightTons,
|
||||
lengthMeters: minNeed.lengthMeters,
|
||||
},
|
||||
border,
|
||||
);
|
||||
}
|
||||
return budget.isExhausted(minNeed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wagons the schedule's allocated + reserved bookings occupy train-wide,
|
||||
* regardless of which corridor leg each rides. Deduped by booking id — a
|
||||
* booking mid-settle can momentarily be both linked and reserved.
|
||||
* The corridor's single border-crossing edge (last home-country stop → first
|
||||
* far-country stop), or null when every stop is in one country. This is the
|
||||
* edge every EXPORT and IMPORT booking must ride, whichever sub-corridor it
|
||||
* books — which makes it the train's directional fullness gauge.
|
||||
*/
|
||||
private async committedWagons(schedule: TrainSchedule): Promise<number> {
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const allocated = (schedule.scheduleBookings ?? [])
|
||||
.map((sb) => sb.booking)
|
||||
.filter((b): b is Booking => Boolean(b));
|
||||
const reserved = await this.bookingsRepository.findReservedForSchedule(
|
||||
schedule.id,
|
||||
);
|
||||
const byId = new Map(
|
||||
[...allocated, ...reserved].map((b) => [b.id, b] as const),
|
||||
);
|
||||
let total = 0;
|
||||
for (const booking of byId.values()) {
|
||||
total += this.wagonsFor(booking, wagonDims);
|
||||
}
|
||||
return total;
|
||||
private async borderLeg(stops: string[]): Promise<CorridorLeg | null> {
|
||||
if (stops.length < 2) return null;
|
||||
const yards = await this.dataSource
|
||||
.getRepository(Yard)
|
||||
.find({ where: { id: In(stops) } });
|
||||
const countryOf = new Map(yards.map((y) => [y.id, y.country]));
|
||||
const first = countryOf.get(stops[0]);
|
||||
if (!first) return null;
|
||||
const crossIdx = stops.findIndex((id) => {
|
||||
const country = countryOf.get(id);
|
||||
return country != null && country !== first;
|
||||
});
|
||||
if (crossIdx <= 0) return null;
|
||||
return { fromEdge: crossIdx - 1, toEdge: crossIdx };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { bookingCargoTons } from './train-capacity.util';
|
||||
import type { Booking } from '../bookings/entities/booking.entity';
|
||||
import type { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import {
|
||||
@@ -187,5 +188,5 @@ export function summarizeFleetWarnings(
|
||||
}
|
||||
|
||||
export function totalAssignedWeight(bookings: Booking[]): number {
|
||||
return roundTons(bookings.reduce((sum, b) => sum + Number(b.cargoTotalWeightVgm ?? 0), 0));
|
||||
return roundTons(bookings.reduce((sum, b) => sum + bookingCargoTons(b), 0));
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
grossWagonWeightTons,
|
||||
minLocomotiveLimits,
|
||||
sizePartialOfferWagons,
|
||||
trainSetLocomotiveLimits,
|
||||
} from './train-capacity.util';
|
||||
|
||||
describe('train-capacity.util', () => {
|
||||
@@ -205,6 +206,37 @@ describe('train-capacity.util', () => {
|
||||
expect(limits?.overageToleranceTons).toBe(20);
|
||||
});
|
||||
|
||||
it('ignores unconfigured (null) tolerances instead of zeroing the set (S-2026-00024)', () => {
|
||||
// LOCO-019 had 90T tolerance, LOCO-020 had none configured: the set must
|
||||
// keep the 90, not collapse to 0 and reject 3547.6T on a 3500T train.
|
||||
const limits = minLocomotiveLimits([
|
||||
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
|
||||
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: null },
|
||||
]);
|
||||
expect(limits?.overageToleranceTons).toBe(90);
|
||||
// All unconfigured → no tolerance.
|
||||
const none = minLocomotiveLimits([
|
||||
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760 },
|
||||
]);
|
||||
expect(none?.overageToleranceTons).toBe(0);
|
||||
});
|
||||
|
||||
it('trainSetLocomotiveLimits prefers link rows and falls back to the legacy single loco', () => {
|
||||
const l1 = { maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 };
|
||||
const l2 = { maxPullWeightTons: 3600, maxTrainLengthMeters: 700, overageToleranceTons: null };
|
||||
expect(
|
||||
trainSetLocomotiveLimits({ locomotive: null, locomotives: [{ locomotive: l1 }, { locomotive: l2 }] }),
|
||||
).toEqual({
|
||||
maxPullWeightTons: 3500,
|
||||
maxTrainLengthMeters: 700,
|
||||
overageToleranceTons: 90,
|
||||
overageToleranceMeters: 0,
|
||||
});
|
||||
expect(trainSetLocomotiveLimits({ locomotive: l1 })?.maxPullWeightTons).toBe(3500);
|
||||
expect(trainSetLocomotiveLimits(null)).toBeNull();
|
||||
expect(trainSetLocomotiveLimits({ locomotive: null, locomotives: [] })).toBeNull();
|
||||
});
|
||||
|
||||
describe('sizePartialOfferWagons', () => {
|
||||
it('sizes a bulk split by the WEIGHT axis when the pull limit binds, not wagon slots', () => {
|
||||
// The 3500T-train scenario: two 1000T bookings boarded gross (each 15 PW2
|
||||
|
||||
@@ -86,6 +86,27 @@ function num(value: unknown, fallback = 0): number {
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cargo tons of a booking: the stored VGM total when present, else the sum of
|
||||
* its container lines (quantity × VGM per unit). The portal's container flow
|
||||
* stores per-line VGM and leaves `cargoTotalWeightVgm` at 0 — reading the
|
||||
* total alone made every such booking weigh only its tare.
|
||||
*/
|
||||
export function bookingCargoTons(booking: {
|
||||
cargoTotalWeightVgm?: number | string | null;
|
||||
bookingContainers?: Array<{
|
||||
quantity?: number | null;
|
||||
vgmPerUnitTons?: number | string | null;
|
||||
}> | null;
|
||||
}): number {
|
||||
const total = num(booking.cargoTotalWeightVgm);
|
||||
if (total > 0) return total;
|
||||
return (booking.bookingContainers ?? []).reduce(
|
||||
(sum, line) => sum + num(line.quantity) * num(line.vgmPerUnitTons),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
@@ -260,11 +281,34 @@ export function minLocomotiveLimits(
|
||||
};
|
||||
}
|
||||
|
||||
function minConfigured(values: Array<number | string | null | undefined>): number {
|
||||
function minConfigured(values: Array<number | null | undefined>): number {
|
||||
const configured = values.filter((v) => v != null).map((v) => num(v));
|
||||
return configured.length ? Math.min(...configured) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective limits for a whole train set: min across its linked locomotives,
|
||||
* falling back to the legacy single `locomotive` column for sets created
|
||||
* before multi-loco support. Null when the set has no locomotive at all.
|
||||
*/
|
||||
export function trainSetLocomotiveLimits(
|
||||
trainSet?: {
|
||||
locomotive?: LocomotiveLimits | null;
|
||||
locomotives?: Array<{ locomotive?: LocomotiveLimits | null }> | null;
|
||||
} | null,
|
||||
): LocomotiveLimits | null {
|
||||
if (!trainSet) return null;
|
||||
const linked = (trainSet.locomotives ?? [])
|
||||
.map((link) => link.locomotive)
|
||||
.filter((l): l is LocomotiveLimits => Boolean(l));
|
||||
const pool = linked.length
|
||||
? linked
|
||||
: trainSet.locomotive
|
||||
? [trainSet.locomotive]
|
||||
: [];
|
||||
return minLocomotiveLimits(pool);
|
||||
}
|
||||
|
||||
/** Per-booking train length from wagon count and freight-specific wagon type length. */
|
||||
export function bookingTrainLengthMeters(
|
||||
freightType: string | null | undefined,
|
||||
|
||||
@@ -122,6 +122,7 @@ import {
|
||||
roundTons,
|
||||
sumWagonsRequired,
|
||||
type TrainLimitConfig,
|
||||
maxEdgeConsistUsage,
|
||||
validateContainerPlacements,
|
||||
validateMixedTrainLimitsPerEdge,
|
||||
type ContainerPlacementInput,
|
||||
@@ -130,9 +131,12 @@ import {
|
||||
import { deriveScheduleDirection } from './derive-schedule-direction.util';
|
||||
import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util';
|
||||
import {
|
||||
bookingCargoTons,
|
||||
deriveTrainCapacityFromLocomotive,
|
||||
minLocomotiveLimits,
|
||||
trainSetLocomotiveLimits,
|
||||
wagonTypeDimensionsFromEntity,
|
||||
LocomotiveLimits,
|
||||
WagonTypeDimensions,
|
||||
} from './train-capacity.util';
|
||||
import {
|
||||
@@ -1706,25 +1710,36 @@ export class TrainSchedulingService {
|
||||
relations: { wagonType: true },
|
||||
})
|
||||
: null;
|
||||
const planTareTons = consistWagons
|
||||
const planTareTons = roundTons(
|
||||
wagonPlan.reduce((sum, slot) => sum + Number(slot.tareWeightTons ?? 0), 0),
|
||||
);
|
||||
const consistTareTons = consistWagons
|
||||
? roundTons(
|
||||
consistWagons.reduce(
|
||||
(sum, wagon) => sum + Number(wagon.wagonType?.tareWeightTons ?? 0),
|
||||
0,
|
||||
),
|
||||
)
|
||||
: roundTons(
|
||||
wagonPlan.reduce((sum, slot) => sum + Number(slot.tareWeightTons ?? 0), 0),
|
||||
);
|
||||
const grossWeightTons = roundTons(totalWeightTons + planTareTons);
|
||||
: planTareTons;
|
||||
// The pull limit binds on the HEAVIEST LEG, not the whole-route sum —
|
||||
// disjoint legs (intercity Gelan→Adama + export Adama→Doraleh) are never
|
||||
// hauled at the same time. Coupled-but-unplanned wagons ride every edge,
|
||||
// so their tare rides on top of the binding edge.
|
||||
const emptyConsistTareTons = Math.max(0, consistTareTons - planTareTons);
|
||||
const edgeUsage = maxEdgeConsistUsage(
|
||||
wagonPlan,
|
||||
await this.stopYardsForSchedule(schedule),
|
||||
);
|
||||
const grossWeightTons = roundTons(edgeUsage.grossWeightTons + emptyConsistTareTons);
|
||||
if (!dto.forceAssign && weightCapWithOverage < grossWeightTons) {
|
||||
throw new BadRequestException(
|
||||
`Train set locomotives cannot pull ${grossWeightTons}T gross (${totalWeightTons}T cargo + ${planTareTons}T wagon tare)`,
|
||||
`Train set locomotives cannot pull ${grossWeightTons}T gross on the heaviest leg (limit ${roundTons(weightCapWithOverage)}T incl. tolerance)`,
|
||||
);
|
||||
}
|
||||
if (!dto.forceAssign && lengthCapWithOverage < totalLengthMeters) {
|
||||
const maxEdgeLengthMeters = roundTons(edgeUsage.lengthMeters);
|
||||
if (!dto.forceAssign && lengthCapWithOverage < maxEdgeLengthMeters) {
|
||||
throw new BadRequestException(
|
||||
`Train set locomotives cannot support ${totalLengthMeters}m`,
|
||||
`Train set locomotives cannot support ${maxEdgeLengthMeters}m`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4081,9 +4096,6 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
const totalWeightTons = totalAssignedWeight(fittingBookings);
|
||||
// Every weight limit below (global max, loco pull) is a GROSS axis, so the
|
||||
// figure spent against it must be gross too — cargo alone under-reports the
|
||||
// train by the full consist tare and disagrees with the assign path.
|
||||
const totalTareTons = roundTons(
|
||||
wagonPlan.reduce((sum, w) => sum + (Number(w.tareWeightTons) || 0), 0),
|
||||
);
|
||||
@@ -4091,12 +4103,13 @@ export class TrainSchedulingService {
|
||||
const totalLengthMeters = roundTons(
|
||||
wagonPlan.reduce((sum, w) => sum + w.lengthMeters, 0),
|
||||
);
|
||||
if (grossWeightTons > trainLimits.maxWeightTons) {
|
||||
const message = `Total gross weight ${grossWeightTons}T (${totalWeightTons}T cargo + ${totalTareTons}T wagon tare) exceeds max train weight ${trainLimits.maxWeightTons}T`;
|
||||
if (!violations.includes(message) && !warnings.includes(message)) {
|
||||
pushLimit([message]);
|
||||
}
|
||||
}
|
||||
// Weight/length limits are enforced PER EDGE by validateMixedTrainLimitsPerEdge
|
||||
// above — the whole-route totals here are informational (summary) only. The
|
||||
// locomotive checks below also compare the heaviest single edge: a train is
|
||||
// never heavier than its heaviest leg, so disjoint legs must not be summed.
|
||||
const edgeUsage = maxEdgeConsistUsage(wagonPlan, stops);
|
||||
const maxEdgeGrossTons = roundTons(edgeUsage.grossWeightTons);
|
||||
const maxEdgeLengthMeters = roundTons(edgeUsage.lengthMeters);
|
||||
|
||||
let assignedLocomotives: Locomotive[] = [];
|
||||
if (targetScheduleId) {
|
||||
@@ -4119,9 +4132,9 @@ export class TrainSchedulingService {
|
||||
if (
|
||||
setLimits &&
|
||||
(setLimits.maxPullWeightTons + (Number(setLimits.overageToleranceTons) || 0) <
|
||||
grossWeightTons ||
|
||||
maxEdgeGrossTons ||
|
||||
setLimits.maxTrainLengthMeters + (Number(setLimits.overageToleranceMeters) || 0) <
|
||||
totalLengthMeters)
|
||||
maxEdgeLengthMeters)
|
||||
) {
|
||||
pushLimit([
|
||||
'Assigned locomotives cannot support the total train weight and length',
|
||||
@@ -4140,9 +4153,9 @@ export class TrainSchedulingService {
|
||||
!inServiceLocomotives.some(
|
||||
(l) =>
|
||||
Number(l.maxPullWeightTons) + (Number(l.overageToleranceTons) || 0) >=
|
||||
grossWeightTons &&
|
||||
maxEdgeGrossTons &&
|
||||
Number(l.maxTrainLengthMeters) + (Number(l.overageToleranceMeters) || 0) >=
|
||||
totalLengthMeters,
|
||||
maxEdgeLengthMeters,
|
||||
)
|
||||
) {
|
||||
pushLimit(['No locomotive can support the total train weight and length']);
|
||||
@@ -4201,10 +4214,7 @@ export class TrainSchedulingService {
|
||||
maxTrainLengthMeters?: number;
|
||||
maxWagonsPerTrain?: number;
|
||||
},
|
||||
locomotive?: Pick<
|
||||
Locomotive,
|
||||
'maxPullWeightTons' | 'maxTrainLengthMeters' | 'overageToleranceTons' | 'overageToleranceMeters'
|
||||
>,
|
||||
locomotive?: LocomotiveLimits | null,
|
||||
): Promise<Required<TrainLimitConfig>> {
|
||||
const row = await this.loadGlobalRulesRow();
|
||||
const configured = this.configService?.get<{
|
||||
@@ -6512,7 +6522,7 @@ export class TrainSchedulingService {
|
||||
>,
|
||||
tareDims: Awaited<ReturnType<TrainSchedulingService['loadWagonTareDims']>>,
|
||||
): number {
|
||||
const cargo = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
const cargo = bookingCargoTons(booking);
|
||||
const fallback =
|
||||
booking.freightType === 'BULK' ? tareDims.bulk : tareDims.container;
|
||||
// Same first-configured-type resolution the batch engine's dimsFor uses.
|
||||
@@ -6878,6 +6888,18 @@ export class TrainSchedulingService {
|
||||
// Ordered corridor stops (route milestones; falls back to the two
|
||||
// endpoints) — lets the UI draw per-segment occupancy and label legs.
|
||||
stops: this.mapScheduleStops(schedule),
|
||||
// Gross ceiling the validator holds each leg to: the set's weakest
|
||||
// locomotive pull limit plus its overage tolerance. Booking weightTons
|
||||
// above are gross too, so the strip can sum them per leg against this.
|
||||
maxGrossWeightTons: (() => {
|
||||
const setLimits = trainSetLocomotiveLimits(schedule.trainSet);
|
||||
return setLimits
|
||||
? roundTons(
|
||||
Number(setLimits.maxPullWeightTons) +
|
||||
(Number(setLimits.overageToleranceTons) || 0),
|
||||
)
|
||||
: null;
|
||||
})(),
|
||||
// True when the wagon plan above is served from the frozen snapshot (schedule
|
||||
// is dispatched/arrived/cancelled) rather than the live joins — the UI can badge
|
||||
// it "historical" and skip re-pin affordances.
|
||||
@@ -6973,7 +6995,10 @@ export class TrainSchedulingService {
|
||||
originStationId: schedule.originStationId,
|
||||
destinationStationId: schedule.destinationStationId,
|
||||
};
|
||||
const limits = await this.resolveTrainLimitConfig(undefined, schedule.trainSet.locomotive);
|
||||
const limits = await this.resolveTrainLimitConfig(
|
||||
undefined,
|
||||
trainSetLocomotiveLimits(schedule.trainSet),
|
||||
);
|
||||
|
||||
const validation = await this.validateBookingsForScheduling(
|
||||
previewDto,
|
||||
@@ -7099,7 +7124,7 @@ export class TrainSchedulingService {
|
||||
};
|
||||
const limits = await this.resolveTrainLimitConfig(
|
||||
undefined,
|
||||
schedule.trainSet.locomotive,
|
||||
trainSetLocomotiveLimits(schedule.trainSet),
|
||||
);
|
||||
|
||||
let validation: Awaited<ReturnType<TrainSchedulingService['validateBookingsForScheduling']>>;
|
||||
@@ -7643,7 +7668,7 @@ export class TrainSchedulingService {
|
||||
};
|
||||
const limits = await this.resolveTrainLimitConfig(
|
||||
undefined,
|
||||
schedule.trainSet.locomotive,
|
||||
trainSetLocomotiveLimits(schedule.trainSet),
|
||||
);
|
||||
|
||||
let validation: Awaited<ReturnType<TrainSchedulingService['validateBookingsForScheduling']>>;
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
containerWagonsForLines,
|
||||
expandBookingContainerUnits,
|
||||
expandContainerItems,
|
||||
maxEdgeConsistUsage,
|
||||
roundTons,
|
||||
sumWagonsRequired,
|
||||
validate20ftContainerRules,
|
||||
@@ -279,3 +280,54 @@ describe('containerWagonsForLines — TEU-aware, ceil booking total once', () =>
|
||||
expect(containerWagonsForLines([])).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('maxEdgeConsistUsage — the binding edge, not the whole-route sum', () => {
|
||||
const slot = (
|
||||
tare: number,
|
||||
cargo: number,
|
||||
length: number,
|
||||
board?: string | null,
|
||||
alight?: string | null,
|
||||
) =>
|
||||
({
|
||||
tareWeightTons: tare,
|
||||
assignedWeightTons: cargo,
|
||||
lengthMeters: length,
|
||||
boardYardId: board ?? null,
|
||||
alightYardId: alight ?? null,
|
||||
}) as never;
|
||||
|
||||
const stops = ['a', 'b', 'c'];
|
||||
|
||||
it('does not sum disjoint legs: intercity a→b + export b→c', () => {
|
||||
const plan = [
|
||||
slot(24, 65, 14, null, 'b'), // intercity, rides a→b only
|
||||
slot(24, 65, 14, 'b', null), // export, rides b→c only
|
||||
];
|
||||
// Each edge carries one slot: 89T gross / 14m — never 178T.
|
||||
expect(maxEdgeConsistUsage(plan, stops)).toEqual({
|
||||
grossWeightTons: 89,
|
||||
lengthMeters: 14,
|
||||
});
|
||||
});
|
||||
|
||||
it('sums overlapping legs on their shared edge (the S-2026-00024 shape)', () => {
|
||||
// 20 intercity a→b wagons + 20 export a→c wagons, 23.94T tare, 64.75T cargo:
|
||||
// shared edge a→b carries all 40 slots = 3547.6T gross.
|
||||
const plan = [
|
||||
...Array.from({ length: 20 }, () => slot(23.94, 64.75, 14, null, 'b')),
|
||||
...Array.from({ length: 20 }, () => slot(23.94, 64.75, 14, null, null)),
|
||||
];
|
||||
const usage = maxEdgeConsistUsage(plan, stops);
|
||||
expect(usage.grossWeightTons).toBeCloseTo(3547.6, 1);
|
||||
expect(usage.lengthMeters).toBe(560);
|
||||
});
|
||||
|
||||
it('degrades to whole-train totals on a two-stop route', () => {
|
||||
const plan = [slot(24, 65, 14), slot(24, 65, 14)];
|
||||
expect(maxEdgeConsistUsage(plan, ['a', 'b'])).toEqual({
|
||||
grossWeightTons: 178,
|
||||
lengthMeters: 28,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -539,15 +539,9 @@ export function validateMixedTrainLimitsPerEdge(
|
||||
stops: string[],
|
||||
): string[] {
|
||||
if (stops.length <= 2) return validateMixedTrainLimits(wagonPlan, wagonTypes, limits);
|
||||
const lastIdx = stops.length - 1;
|
||||
const spans = wagonPlan.map((slot) => {
|
||||
const from = slot.boardYardId ? stops.indexOf(slot.boardYardId) : 0;
|
||||
const to = slot.alightYardId ? stops.indexOf(slot.alightYardId) : lastIdx;
|
||||
// A yard missing from the stop list keeps the slot on the whole route.
|
||||
return { from: from >= 0 ? from : 0, to: to > 0 ? to : lastIdx };
|
||||
});
|
||||
const spans = slotSpans(wagonPlan, stops);
|
||||
const violations = new Set<string>();
|
||||
for (let edge = 0; edge < lastIdx; edge += 1) {
|
||||
for (let edge = 0; edge < stops.length - 1; edge += 1) {
|
||||
const active = wagonPlan.filter(
|
||||
(_, i) => spans[i].from <= edge && edge < spans[i].to,
|
||||
);
|
||||
@@ -559,6 +553,52 @@ export function validateMixedTrainLimitsPerEdge(
|
||||
return [...violations];
|
||||
}
|
||||
|
||||
/** Per-slot stop-index spans; a yard missing from the stop list keeps the slot on the whole route. */
|
||||
function slotSpans(
|
||||
wagonPlan: WagonPlanSlot[],
|
||||
stops: string[],
|
||||
): Array<{ from: number; to: number }> {
|
||||
const lastIdx = stops.length - 1;
|
||||
return wagonPlan.map((slot) => {
|
||||
const from = slot.boardYardId ? stops.indexOf(slot.boardYardId) : 0;
|
||||
const to = slot.alightYardId ? stops.indexOf(slot.alightYardId) : lastIdx;
|
||||
return { from: from >= 0 ? from : 0, to: to > 0 ? to : lastIdx };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The corridor's binding edge: gross tons (tare + assigned cargo) and length
|
||||
* summed over only the slots riding each edge, maxed across edges. This is the
|
||||
* figure a locomotive pull/length limit must be compared against — a train is
|
||||
* never heavier than its heaviest single leg, so summing disjoint legs
|
||||
* (intercity Gelan→Adama + export Adama→Doraleh) over-reports the train.
|
||||
* Two stops or fewer degrade to the whole-train totals.
|
||||
*/
|
||||
export function maxEdgeConsistUsage(
|
||||
wagonPlan: WagonPlanSlot[],
|
||||
stops: string[],
|
||||
): { grossWeightTons: number; lengthMeters: number } {
|
||||
const totals = (slots: WagonPlanSlot[]) => ({
|
||||
grossWeightTons: slots.reduce(
|
||||
(sum, w) =>
|
||||
sum + Number(w.tareWeightTons ?? 0) + Number(w.assignedWeightTons ?? 0),
|
||||
0,
|
||||
),
|
||||
lengthMeters: slots.reduce((sum, w) => sum + Number(w.lengthMeters ?? 0), 0),
|
||||
});
|
||||
if (stops.length <= 2) return totals(wagonPlan);
|
||||
const spans = slotSpans(wagonPlan, stops);
|
||||
const usage = { grossWeightTons: 0, lengthMeters: 0 };
|
||||
for (let edge = 0; edge < stops.length - 1; edge += 1) {
|
||||
const active = totals(
|
||||
wagonPlan.filter((_, i) => spans[i].from <= edge && edge < spans[i].to),
|
||||
);
|
||||
usage.grossWeightTons = Math.max(usage.grossWeightTons, active.grossWeightTons);
|
||||
usage.lengthMeters = Math.max(usage.lengthMeters, active.lengthMeters);
|
||||
}
|
||||
return usage;
|
||||
}
|
||||
|
||||
export function validate20ftContainerRules(
|
||||
units: ContainerUnitRow[],
|
||||
placements: ContainerPlacementInput[],
|
||||
|
||||
@@ -302,8 +302,6 @@ export const SCHEDULING_EXTRA_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
|
||||
// L. Administration & settings (split from the coarse admin umbrella)
|
||||
export const CONFIG_SETTINGS_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm('b3a00001-0001-4000-8000-000000000001', 'edr_freight_app:config:contract_validity:view', 'View contract validity periods'),
|
||||
perm('b3a00001-0001-4000-8000-000000000002', 'edr_freight_app:config:contract_validity:manage', 'Manage contract validity periods'),
|
||||
perm('b4a00001-0001-4000-8000-000000000001', 'edr_freight_app:settings:file_upload:view', 'View file-upload settings'),
|
||||
perm('b4a00001-0001-4000-8000-000000000002', 'edr_freight_app:settings:file_upload:manage', 'Manage file-upload settings'),
|
||||
perm('b4b00001-0001-4000-8000-000000000001', 'edr_freight_app:settings:dropdown:view', 'View dropdown settings'),
|
||||
@@ -594,12 +592,6 @@ export const FREIGHT_PERMS = {
|
||||
cancel: 'edr_freight_app:warehouse_fee_invoices:cancel',
|
||||
pay: 'edr_freight_app:warehouse_fee_invoices:pay',
|
||||
},
|
||||
config: {
|
||||
contractValidity: {
|
||||
view: 'edr_freight_app:config:contract_validity:view',
|
||||
manage: 'edr_freight_app:config:contract_validity:manage',
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
fileUpload: {
|
||||
view: 'edr_freight_app:settings:file_upload:view',
|
||||
|
||||
@@ -548,11 +548,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <Boxes />,
|
||||
children: [
|
||||
...getCategorySidebarChildren("configuration"),
|
||||
{
|
||||
label: "Contract validity",
|
||||
href: "/dashboard/configuration/contract-validity-periods",
|
||||
permission: FREIGHT_PERMS.config.contractValidity.view,
|
||||
},
|
||||
{
|
||||
label: "Train scheduling rules",
|
||||
href: "/dashboard/configuration/train-scheduling-rules",
|
||||
@@ -1465,14 +1460,14 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
{/* <Route
|
||||
path="configuration/contract-validity-periods"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.admin}>
|
||||
<ContractValidityPeriodsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
/> */}
|
||||
<Route path="configuration/cargo-types" element={<CargoTypesPage />} />
|
||||
<Route
|
||||
path="configuration/cargo-types/:id"
|
||||
|
||||
@@ -16,6 +16,8 @@ import type { Freight } from "@edr/types";
|
||||
import { formatContractApprovalProgress } from "@/features/contracts/contract-approval-progress";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import type { useContractMutations } from "@/hooks/contracts/useContracts";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { canApproveContractStep } from "@/lib/permissions";
|
||||
|
||||
type Mutations = ReturnType<typeof useContractMutations>;
|
||||
|
||||
@@ -29,6 +31,7 @@ export function ContractApprovalStepsCard({
|
||||
contract,
|
||||
mutations,
|
||||
}: ContractApprovalStepsCardProps) {
|
||||
const { user } = useAuth();
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [pendingStep, setPendingStep] =
|
||||
useState<Freight.IContractApprovalStep | null>(null);
|
||||
@@ -166,6 +169,10 @@ export function ContractApprovalStepsCard({
|
||||
key={step.id}
|
||||
step={step}
|
||||
isNext={actionable && nextPending?.id === step.id}
|
||||
// Buttons show only to the step's actual approver (matching
|
||||
// position type): a chief step never offers Approve/Reject to a
|
||||
// marketing officer. Everyone still sees the "next" highlight.
|
||||
canAct={canApproveContractStep(user, step.requiredRole)}
|
||||
isPending={
|
||||
mutations.approveStep.isPending ||
|
||||
mutations.rejectStep.isPending
|
||||
@@ -306,12 +313,14 @@ export function ContractApprovalStepsCard({
|
||||
function StepRow({
|
||||
step,
|
||||
isNext,
|
||||
canAct,
|
||||
isPending,
|
||||
onApprove,
|
||||
onReject,
|
||||
}: {
|
||||
step: Freight.IContractApprovalStep;
|
||||
isNext: boolean;
|
||||
canAct: boolean;
|
||||
isPending: boolean;
|
||||
onApprove: () => void;
|
||||
onReject: () => void;
|
||||
@@ -372,8 +381,11 @@ function StepRow({
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
{isNext && step.status === "PENDING" && (
|
||||
{/* One element type per row: action buttons on the active step (they
|
||||
already imply "pending & actionable"), a status badge otherwise.
|
||||
Mixing compact buttons + a badge here made them read as misaligned. */}
|
||||
<Group gap="xs" wrap="nowrap" align="center" style={{ flexShrink: 0 }}>
|
||||
{isNext && canAct && step.status === "PENDING" ? (
|
||||
<>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
@@ -395,16 +407,17 @@ function StepRow({
|
||||
Reject
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Badge
|
||||
variant="light"
|
||||
color={statusColor}
|
||||
size="sm"
|
||||
radius="sm"
|
||||
tt="uppercase"
|
||||
>
|
||||
{step.status}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge
|
||||
variant="light"
|
||||
color={statusColor}
|
||||
size="sm"
|
||||
radius="sm"
|
||||
tt="uppercase"
|
||||
>
|
||||
{step.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
|
||||
@@ -269,6 +269,8 @@ export default function GlCreateBookingForm() {
|
||||
const [scheduledDate, setScheduledDate] = useState("");
|
||||
const [contractRouteId, setContractRouteId] = useState<string | null>(null);
|
||||
const [notes, setNotes] = useState("");
|
||||
// What the containers carry — captured per booking (moved off the contract).
|
||||
const [cargoDescription, setCargoDescription] = useState("");
|
||||
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
|
||||
const [bulk, setBulk] = useState<BulkDraft>({
|
||||
cargoWeightTons: "",
|
||||
@@ -459,6 +461,10 @@ export default function GlCreateBookingForm() {
|
||||
);
|
||||
};
|
||||
setPrefilled(true);
|
||||
// Rebook carries the expired booking's cargo description forward.
|
||||
if (copyFromBooking.cargoFreeText) {
|
||||
setCargoDescription(copyFromBooking.cargoFreeText);
|
||||
}
|
||||
setContainerLines(
|
||||
lines.map((c) => {
|
||||
const qty = Math.max(1, c.quantity);
|
||||
@@ -705,14 +711,23 @@ export default function GlCreateBookingForm() {
|
||||
|
||||
const lineErrors = useMemo<LineErrors[]>(() => {
|
||||
if (!isContainer || !contract) return [];
|
||||
return containerLines.map((line) => {
|
||||
// A line can be 0 (the contract covers both sizes; a booking may only need
|
||||
// one) but the booking as a whole needs at least one container — anchor
|
||||
// that error on the first line's quantity so it renders in the field.
|
||||
const totalQty = containerLines.reduce(
|
||||
(sum, l) => sum + Math.max(0, Number(l.quantity) || 0),
|
||||
0,
|
||||
);
|
||||
return containerLines.map((line, idx) => {
|
||||
const errs: LineErrors = {};
|
||||
const qty = Number(line.quantity || 0);
|
||||
if (line.quantity.trim() === "") {
|
||||
errs.quantity = "Quantity is required.";
|
||||
} else if (Number.isNaN(qty) || qty < 1) {
|
||||
errs.quantity = "At least 1.";
|
||||
} else if (line.units.length < qty) {
|
||||
} else if (Number.isNaN(qty) || qty < 0) {
|
||||
errs.quantity = "Enter 0 or more.";
|
||||
} else if (idx === 0 && totalQty < 1) {
|
||||
errs.quantity = "Book at least one container (either size).";
|
||||
} else if (qty >= 1 && line.units.length < qty) {
|
||||
errs.units = `Enter details for all ${qty} container(s).`;
|
||||
}
|
||||
if (contract.isHazardous) {
|
||||
@@ -789,6 +804,11 @@ export default function GlCreateBookingForm() {
|
||||
const routeError =
|
||||
multiRoute && !contractRouteId ? "Select a route." : undefined;
|
||||
|
||||
const cargoDescriptionError =
|
||||
isContainer && !cargoDescription.trim()
|
||||
? "Describe the cargo carried in the containers."
|
||||
: undefined;
|
||||
|
||||
const cargoValid = isContainer
|
||||
? lineErrors.every(
|
||||
(e) =>
|
||||
@@ -800,7 +820,8 @@ export default function GlCreateBookingForm() {
|
||||
) &&
|
||||
unitErrors.every((line) =>
|
||||
line.every((e) => !e.containerNumber && !e.vgmTons),
|
||||
)
|
||||
) &&
|
||||
!cargoDescriptionError
|
||||
: !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer;
|
||||
|
||||
const formValid = cargoValid && !hasOdd20ft && !dateError && !routeError;
|
||||
@@ -827,6 +848,8 @@ export default function GlCreateBookingForm() {
|
||||
};
|
||||
|
||||
if (isContainer) {
|
||||
// What the containers carry — captured per booking, not on the contract.
|
||||
if (cargoDescription.trim()) payload.cargoFreeText = cargoDescription.trim();
|
||||
payload.containers = containerLines
|
||||
.filter((l) => Number(l.quantity) >= 1)
|
||||
.map((l) => ({
|
||||
@@ -1245,6 +1268,19 @@ export default function GlCreateBookingForm() {
|
||||
)}
|
||||
{remainderNotice}
|
||||
<ContractCapacityNotice contractId={contract.id} isContainer />
|
||||
<Textarea
|
||||
label="Cargo description *"
|
||||
description="What do the containers carry on this shipment?"
|
||||
placeholder="e.g. Electronics, garments, machinery spare parts…"
|
||||
value={cargoDescription}
|
||||
onChange={(e) => setCargoDescription(e.currentTarget.value)}
|
||||
error={showErrors ? cargoDescriptionError : undefined}
|
||||
radius={10}
|
||||
autosize
|
||||
minRows={2}
|
||||
maxRows={4}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
{containerLines.length === 0 ? (
|
||||
<Text fz="sm" c="dimmed">
|
||||
This contract has no container sizes in scope.
|
||||
@@ -1264,7 +1300,7 @@ export default function GlCreateBookingForm() {
|
||||
type="number"
|
||||
onKeyDown={blockNegative}
|
||||
label="Quantity *"
|
||||
min={1}
|
||||
min={0}
|
||||
value={line.quantity}
|
||||
error={
|
||||
showErrors
|
||||
|
||||
@@ -287,6 +287,8 @@ export type SegmentStripBooking = {
|
||||
destinationYardId?: string | null;
|
||||
tradeDirection?: string | null;
|
||||
wagonsRequired?: number | null;
|
||||
/** GROSS tons (cargo + tare of the booking's wagons), as the API sends it. */
|
||||
weightTons?: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -300,10 +302,13 @@ export function SegmentOccupancyStrip({
|
||||
stops,
|
||||
bookings,
|
||||
maxWagons,
|
||||
maxGrossTons,
|
||||
}: {
|
||||
stops: Array<{ yardId: string; label: string }>;
|
||||
bookings: SegmentStripBooking[];
|
||||
maxWagons?: number | null;
|
||||
/** Loco pull ceiling incl. tolerance — per-leg gross is measured against it. */
|
||||
maxGrossTons?: number | null;
|
||||
}) {
|
||||
if (stops.length < 2) return null;
|
||||
const lastIdx = stops.length - 1;
|
||||
@@ -312,6 +317,7 @@ export function SegmentOccupancyStrip({
|
||||
const segments = stops.slice(0, -1).map((stop, edge) => {
|
||||
let cargo = 0;
|
||||
let intercity = 0;
|
||||
let grossTons = 0;
|
||||
for (const b of bookings) {
|
||||
const from = (b.originYardId ? indexOf.get(b.originYardId) : undefined) ?? 0;
|
||||
const to =
|
||||
@@ -322,8 +328,15 @@ export function SegmentOccupancyStrip({
|
||||
const wagons = Number(b.wagonsRequired) || 1;
|
||||
if (b.tradeDirection === "DOMESTIC") intercity += wagons;
|
||||
else cargo += wagons;
|
||||
grossTons += Number(b.weightTons) || 0;
|
||||
}
|
||||
return { from: stop, to: stops[edge + 1], cargo, intercity };
|
||||
return {
|
||||
from: stop,
|
||||
to: stops[edge + 1],
|
||||
cargo,
|
||||
intercity,
|
||||
grossTons: Math.round(grossTons * 10) / 10,
|
||||
};
|
||||
});
|
||||
|
||||
const cap = Number(maxWagons) || null;
|
||||
@@ -393,6 +406,22 @@ export function SegmentOccupancyStrip({
|
||||
</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
{seg.grossTons > 0 ? (
|
||||
<Text
|
||||
size="xs"
|
||||
ta="center"
|
||||
fw={600}
|
||||
c={
|
||||
maxGrossTons != null && seg.grossTons > maxGrossTons
|
||||
? "red.7"
|
||||
: "dimmed"
|
||||
}
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{seg.grossTons}
|
||||
{maxGrossTons != null ? ` / ${maxGrossTons}` : ""} T gross
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
{i === segments.length - 1 ? (
|
||||
<Stack gap={2} align="center" justify="flex-end" style={{ minWidth: 0 }}>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { AuthUser } from "@/auth/types";
|
||||
import { canApproveContractStep } from "./permissions";
|
||||
|
||||
const withPositionType = (typeKey: string): AuthUser => ({
|
||||
employee: [{ positions: [{ positionType: { key: typeKey } }] }],
|
||||
});
|
||||
|
||||
const withRole = (roleKey: string): AuthUser => ({ roles: [{ key: roleKey }] });
|
||||
|
||||
const withPermission = (permKey: string): AuthUser => ({
|
||||
permissionKeys: [permKey],
|
||||
});
|
||||
|
||||
describe("canApproveContractStep", () => {
|
||||
it("shows to the matching position type only", () => {
|
||||
const chief = withPositionType("-marketing-chief");
|
||||
expect(canApproveContractStep(chief, "-marketing-chief")).toBe(true);
|
||||
// a marketing officer must NOT see the chief step's buttons
|
||||
expect(canApproveContractStep(chief, "-marketing-director-")).toBe(false);
|
||||
});
|
||||
|
||||
it("lets super/org admins action any step", () => {
|
||||
expect(canApproveContractStep(withRole("super_admin"), "anything")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
canApproveContractStep(withRole("organization_admin"), "-marketing-chief"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves legacy chain roles via their position-type aliases", () => {
|
||||
const director = withPositionType("operation-director");
|
||||
expect(canApproveContractStep(director, "DIRECTOR")).toBe(true);
|
||||
expect(canApproveContractStep(director, "CEO")).toBe(false);
|
||||
});
|
||||
|
||||
it("honours the role's own legacy approve permission", () => {
|
||||
const staff = withPermission(
|
||||
"edr_freight_app:contracts:approve_director",
|
||||
);
|
||||
expect(canApproveContractStep(staff, "DIRECTOR")).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT show to holders of an unrelated approve permission", () => {
|
||||
// the dropped blanket fallback: a line-staff approver is not a chief
|
||||
const lineStaff = withPermission(
|
||||
"edr_freight_app:contracts:approve_line_staff",
|
||||
);
|
||||
expect(canApproveContractStep(lineStaff, "-marketing-chief")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false without a user or role", () => {
|
||||
expect(canApproveContractStep(null, "-marketing-chief")).toBe(false);
|
||||
expect(canApproveContractStep(withPositionType("x"), null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -240,12 +240,6 @@ export const FREIGHT_PERMS = {
|
||||
cancel: "edr_freight_app:warehouse_fee_invoices:cancel",
|
||||
pay: "edr_freight_app:warehouse_fee_invoices:pay",
|
||||
},
|
||||
config: {
|
||||
contractValidity: {
|
||||
view: "edr_freight_app:config:contract_validity:view",
|
||||
manage: "edr_freight_app:config:contract_validity:manage",
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
fileUpload: {
|
||||
view: "edr_freight_app:settings:file_upload:view",
|
||||
@@ -420,6 +414,53 @@ export function hasPermission(
|
||||
return getPermissionKeys(user).includes(key);
|
||||
}
|
||||
|
||||
// Legacy chain roles predate position types; map each to the position types
|
||||
// that stand in for it. Mirror of the API's LEGACY_ROLE_POSITION_TYPES so the
|
||||
// button visibility matches what the approve/reject endpoint will accept.
|
||||
const LEGACY_ROLE_POSITION_TYPES: Record<string, string[]> = {
|
||||
LINE_STAFF: ["employee", "teamLeader", "officeHead", "recordOfficer"],
|
||||
DIRECTOR: ["director", "operation-director"],
|
||||
CEO: ["chief", "deputy"],
|
||||
};
|
||||
|
||||
const CONTRACT_APPROVE_ROLE_PERMISSION: Record<string, string> = {
|
||||
LINE_STAFF: FREIGHT_PERMS.contracts.approveLineStaff,
|
||||
DIRECTOR: FREIGHT_PERMS.contracts.approveDirector,
|
||||
CEO: FREIGHT_PERMS.contracts.approveCeo,
|
||||
};
|
||||
|
||||
/**
|
||||
* Can this user action a contract approval step requiring `requiredRole`?
|
||||
*
|
||||
* `requiredRole` is an `iam.position_types.key` (the role vocabulary approval
|
||||
* chains are configured in), or a legacy LINE_STAFF/DIRECTOR/CEO string. Used
|
||||
* to show Approve/Reject only to the step's actual approver — a chief step
|
||||
* shows only to a chief, a marketing-officer step only to that officer.
|
||||
*
|
||||
* Deliberately STRICTER than the API's `assertCanApproveContractStep`, which
|
||||
* also lets through anyone holding any contract-approve permission (a fallback
|
||||
* for delegates whose token omits the position type). That blanket is what made
|
||||
* every approver see the button, so it is dropped here: the visibility rule is
|
||||
* admin OR the matching position type (direct / legacy alias) OR the role's own
|
||||
* legacy approve permission. The server still guards the mutation.
|
||||
*/
|
||||
export function canApproveContractStep(
|
||||
user: AuthUser | null | undefined,
|
||||
requiredRole: string | null | undefined,
|
||||
): boolean {
|
||||
if (!user || !requiredRole) return false;
|
||||
if (isFreightApprovalAdmin(user)) return true;
|
||||
|
||||
const positionTypes = getPositionTypeKeys(user);
|
||||
if (positionTypes.includes(requiredRole)) return true;
|
||||
|
||||
const aliases = LEGACY_ROLE_POSITION_TYPES[requiredRole] ?? [];
|
||||
if (aliases.some((alias) => positionTypes.includes(alias))) return true;
|
||||
|
||||
const legacyPermission = CONTRACT_APPROVE_ROLE_PERMISSION[requiredRole];
|
||||
return Boolean(legacyPermission && hasPermission(user, legacyPermission));
|
||||
}
|
||||
|
||||
export function canAccessBookings(user: AuthUser | null | undefined): boolean {
|
||||
return hasPermission(user, FREIGHT_PERMS.bookings.view);
|
||||
}
|
||||
|
||||
@@ -935,6 +935,7 @@ export default function TrainScheduleV2DetailPage() {
|
||||
stops={schedule.stops ?? []}
|
||||
bookings={schedule.bookings ?? []}
|
||||
maxWagons={schedule.maxWagons}
|
||||
maxGrossTons={schedule.maxGrossWeightTons}
|
||||
/>
|
||||
) : (
|
||||
<Box maw={340}>
|
||||
|
||||
@@ -169,6 +169,8 @@ export interface BookingDetail {
|
||||
contractType: string;
|
||||
freightType: "CONTAINER" | "BULK";
|
||||
tradeDirection: string;
|
||||
/** What the containers carry / bulk commodity label — entered at booking time. */
|
||||
cargoFreeText?: string | null;
|
||||
cargoTotalWeightVgm: number;
|
||||
isHazardous: boolean;
|
||||
consolidationPartnerId?: string | null;
|
||||
|
||||
@@ -640,6 +640,8 @@ export interface TrainScheduleDetail {
|
||||
}>;
|
||||
/** Ordered corridor stops (route milestones) — for per-segment occupancy. */
|
||||
stops?: Array<{ yardId: string; label: string }>;
|
||||
/** Loco pull ceiling incl. overage tolerance — per-leg gross is held to it. */
|
||||
maxGrossWeightTons?: number | null;
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
} from "react-router-dom";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import {
|
||||
CONTAINER_SIZES,
|
||||
CONTRACT_STEPS,
|
||||
ContractFormInputValues,
|
||||
contractFormSchema,
|
||||
@@ -537,15 +538,15 @@ export default function NewContractPage({
|
||||
const isContainer = data.cargoType === "container";
|
||||
const isGeneral = data.contractKind === "general_contract";
|
||||
|
||||
// Cargo scope rows — no quantities (doc §5.4). Container: one row per enabled
|
||||
// size; bulk: a single commodity row. Both GENERAL and ONE_TIME are uncapped
|
||||
// (quantityCap omitted → NULL): the customer books repeatedly against a
|
||||
// GENERAL contract until its validity expires.
|
||||
// Cargo scope rows — no quantities (doc §5.4). Container: ALWAYS both sizes
|
||||
// (rates quoted for both; per-booking quantities can zero a size out) and no
|
||||
// description — that moved to booking time. Bulk: a single commodity row.
|
||||
// Both GENERAL and ONE_TIME are uncapped (quantityCap omitted → NULL): the
|
||||
// customer books repeatedly against a GENERAL contract until its validity
|
||||
// expires.
|
||||
const cargoScope: Freight.CreateContractCargoScopeDto[] = isContainer
|
||||
? data.enabledContainerSizes.map((size) => ({
|
||||
? CONTAINER_SIZES.map((size) => ({
|
||||
containerSize: size,
|
||||
// Required cargo description — what the containers carry.
|
||||
cargoFreeText: data.cargoFreeText.trim() || undefined,
|
||||
}))
|
||||
: [
|
||||
{
|
||||
|
||||
@@ -346,6 +346,10 @@ function NewShipmentBookingForm({
|
||||
// Equipment return is a container concern — bulk keeps the contract default.
|
||||
...(isContainer
|
||||
? {
|
||||
// What the containers carry — captured per booking, not on the contract.
|
||||
...(values.cargoDescription?.trim()
|
||||
? { cargoFreeText: values.cargoDescription.trim() }
|
||||
: {}),
|
||||
containers: values.containers
|
||||
.filter((l) => Number(l.quantity) >= 1)
|
||||
.map((l) => ({
|
||||
@@ -1295,6 +1299,26 @@ function CargoStep({
|
||||
)}
|
||||
{remainderNotice}
|
||||
<ContractCapacityNotice contractId={contract.id} isContainer />
|
||||
<Controller
|
||||
name="cargoDescription"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Textarea
|
||||
label="Cargo description *"
|
||||
description="What do the containers carry on this shipment?"
|
||||
placeholder="e.g. Electronics, garments, machinery spare parts…"
|
||||
value={field.value ?? ""}
|
||||
onChange={(e) => field.onChange(e.currentTarget.value)}
|
||||
onBlur={field.onBlur}
|
||||
error={fieldState.error?.message}
|
||||
radius={10}
|
||||
autosize
|
||||
minRows={2}
|
||||
maxRows={4}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{lines.map((line, index) => (
|
||||
<ContainerLineEditor
|
||||
key={line.containerSize}
|
||||
@@ -1648,7 +1672,7 @@ function ContainerLineEditor({
|
||||
type="number"
|
||||
onKeyDown={blockNegative}
|
||||
label="Quantity *"
|
||||
min={1}
|
||||
min={0}
|
||||
error={fieldState.error?.message}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
|
||||
@@ -58,12 +58,9 @@ export function contractToFormValues(
|
||||
|
||||
const scope = contract.cargoScope ?? [];
|
||||
|
||||
// Container scope: one row per enabled size, with per-size caps for GENERAL.
|
||||
const enabledContainerSizes = isContainer
|
||||
? scope
|
||||
.map((s) => s.containerSize)
|
||||
.filter((s): s is string => Boolean(s))
|
||||
: [];
|
||||
// Container scope: contracts now always cover both sizes — force both even
|
||||
// for older single-size drafts so resubmitting upgrades them.
|
||||
const enabledContainerSizes = isContainer ? ["20ft", "40ft"] : [];
|
||||
const containerSizeCaps: Record<string, number> = {};
|
||||
if (isContainer && isGeneral) {
|
||||
for (const s of scope) {
|
||||
@@ -119,10 +116,9 @@ export function contractToFormValues(
|
||||
enabledContainerSizes as ContractFormInputValues["enabledContainerSizes"],
|
||||
containerSizeCaps,
|
||||
cargoTypePath,
|
||||
// Bulk: the commodity free-text; container: the required cargo
|
||||
// description (stored on every size row — read the first).
|
||||
cargoFreeText:
|
||||
(isContainer ? scope[0]?.cargoFreeText : bulkRow?.cargoFreeText) ?? "",
|
||||
// Bulk commodity free-text only — the container cargo description is
|
||||
// captured per booking now, not on the contract.
|
||||
cargoFreeText: (isContainer ? "" : bulkRow?.cargoFreeText) ?? "",
|
||||
bulkQuantityCap:
|
||||
isGeneral && bulkRow?.quantityCap != null ? bulkRow.quantityCap : 0,
|
||||
isHazardous: contract.isHazardous,
|
||||
|
||||
@@ -160,8 +160,9 @@ export const contractFormSchema = z
|
||||
|
||||
// ── Cargo SCOPE (no quantities) ──
|
||||
cargoType: z.enum(["container", "bulk"], "Select a cargo type."),
|
||||
// Container scope: the enabled sizes (min 1). Each becomes a
|
||||
// contract_cargo_scope row.
|
||||
// Container scope: ALWAYS both sizes — the contract covers 20ft and 40ft
|
||||
// (both rates shown); the customer picks quantities per booking, where a
|
||||
// size can be 0. No picker in the UI; kept for review display/prefill.
|
||||
enabledContainerSizes: z.array(z.enum(CONTAINER_SIZES)).default([]),
|
||||
// GENERAL only: per-size container quantity cap (total bookable over the
|
||||
// validity window). Keyed by size; must be > 0 for every enabled size
|
||||
@@ -222,24 +223,8 @@ export const contractFormSchema = z
|
||||
message: "Intercity contracts are priced in ETB.",
|
||||
});
|
||||
}
|
||||
if (data.cargoType === "container") {
|
||||
// Container scope: at least one enabled size.
|
||||
if (data.enabledContainerSizes.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["enabledContainerSizes"],
|
||||
message: "Enable at least one container size.",
|
||||
});
|
||||
}
|
||||
// Containerized cargo must say WHAT is inside — required description.
|
||||
if (!data.cargoFreeText.trim()) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["cargoFreeText"],
|
||||
message: "Describe the cargo carried in the containers.",
|
||||
});
|
||||
}
|
||||
}
|
||||
// Container scope needs no validation: both sizes are always in scope and
|
||||
// the cargo description moved to booking time.
|
||||
if (data.cargoType === "bulk") {
|
||||
// Bulk scope: a commodity is required.
|
||||
if (!data.cargoTypePath[0]) {
|
||||
@@ -280,7 +265,7 @@ export const initialContractFormValues: DeepPartial<ContractFormValues> = {
|
||||
customsClearingAgent: "",
|
||||
|
||||
cargoType: "container",
|
||||
enabledContainerSizes: [],
|
||||
enabledContainerSizes: [...CONTAINER_SIZES],
|
||||
containerSizeCaps: {},
|
||||
cargoTypePath: [],
|
||||
cargoFreeText: "",
|
||||
|
||||
@@ -152,10 +152,11 @@ export function Step1ContractType({
|
||||
contract.freightType === "BULK" ? "bulk" : "container",
|
||||
);
|
||||
const scope = contract.cargoScope ?? [];
|
||||
const sizes = scope
|
||||
.map((s) => s.containerSize)
|
||||
.filter((s): s is "20ft" | "40ft" => s === "20ft" || s === "40ft");
|
||||
if (sizes.length > 0) form.setValue("enabledContainerSizes", sizes);
|
||||
// Contracts always cover both sizes now — even when renewing an older
|
||||
// single-size contract.
|
||||
if (contract.freightType !== "BULK") {
|
||||
form.setValue("enabledContainerSizes", ["20ft", "40ft"]);
|
||||
}
|
||||
const bulkScope = scope.find((s) => s.cargoTypeId);
|
||||
if (bulkScope?.cargoTypeId) {
|
||||
// Find the parent group for this commodity so the cascader prefills.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import { Check, Container, Flame, RotateCcw, Snowflake } from "lucide-react";
|
||||
import { Container, Flame, RotateCcw, Snowflake } from "lucide-react";
|
||||
import {
|
||||
Box,
|
||||
Group,
|
||||
@@ -9,33 +9,15 @@ import {
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
Textarea,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import type { Freight } from "@edr/types";
|
||||
import {
|
||||
CONTAINER_SIZES,
|
||||
ContractFormInputValues,
|
||||
type ContractFormValues,
|
||||
} from "./schema";
|
||||
import { fieldStyles, SelectField, StepLabel } from "./shared";
|
||||
|
||||
const CONTAINER_SIZE_OPTIONS: Array<{
|
||||
value: "20ft" | "40ft";
|
||||
label: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{
|
||||
value: "20ft",
|
||||
label: "20ft Container",
|
||||
description: "Standard twenty-foot unit (TEU)",
|
||||
},
|
||||
{
|
||||
value: "40ft",
|
||||
label: "40ft Container",
|
||||
description: "Standard forty-foot unit (FEU)",
|
||||
},
|
||||
];
|
||||
|
||||
const CARGO_TYPE_OPTIONS = [
|
||||
{ value: "container", label: "Containerized (20ft / 40ft)" },
|
||||
{ value: "bulk", label: "General / Bulk cargo" },
|
||||
@@ -128,11 +110,17 @@ export function Step3CargoScope({
|
||||
onChange={(v) => {
|
||||
if (!v) return;
|
||||
field.onChange(v);
|
||||
// cargoFreeText is shared (bulk commodity label / container
|
||||
// description) — clear it so text never carries across types.
|
||||
// cargoFreeText is the bulk commodity label — clear it so text
|
||||
// never carries across types (container description is captured
|
||||
// at booking time now).
|
||||
form.setValue("cargoFreeText", "", { shouldDirty: true });
|
||||
if (v === "container") {
|
||||
form.setValue("cargoTypePath", [], { shouldDirty: true });
|
||||
// Contracts always cover BOTH sizes; quantities are chosen per
|
||||
// booking (a size can be 0 there).
|
||||
form.setValue("enabledContainerSizes", [...CONTAINER_SIZES], {
|
||||
shouldDirty: true,
|
||||
});
|
||||
} else {
|
||||
form.setValue("enabledContainerSizes", [], {
|
||||
shouldDirty: true,
|
||||
@@ -152,74 +140,47 @@ export function Step3CargoScope({
|
||||
|
||||
</div>
|
||||
|
||||
{/* Container scope: enabled sizes as tick-cards — tap to toggle, one or
|
||||
both can be in scope. Clearer than a multi-select for two options. */}
|
||||
{/* Container scope: the contract always covers BOTH sizes and quotes both
|
||||
rates. Quantities (a size can be 0) and the cargo description are
|
||||
captured at booking time. */}
|
||||
{cargoType === "container" && (
|
||||
<Controller
|
||||
name="enabledContainerSizes"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => {
|
||||
const selected = (field.value ?? []) as ("20ft" | "40ft")[];
|
||||
const toggle = (size: "20ft" | "40ft") => {
|
||||
field.onChange(
|
||||
selected.includes(size)
|
||||
? selected.filter((s) => s !== size)
|
||||
: [...selected, size],
|
||||
);
|
||||
field.onBlur();
|
||||
};
|
||||
return (
|
||||
<Box>
|
||||
<StepLabel>Container sizes in scope *</StepLabel>
|
||||
<Text fz={12} c="#6B7C8E" mt={2}>
|
||||
Tick every size this contract should cover — you can select
|
||||
both.
|
||||
</Text>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
{CONTAINER_SIZE_OPTIONS.map((opt) => (
|
||||
<SizeCard
|
||||
key={opt.value}
|
||||
label={opt.label}
|
||||
description={opt.description}
|
||||
checked={selected.includes(opt.value)}
|
||||
hasError={Boolean(fieldState.error)}
|
||||
onToggle={() => toggle(opt.value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{fieldState.error?.message && (
|
||||
<Text fz={12} c="red.7" mt={6}>
|
||||
{fieldState.error.message}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
<Group
|
||||
gap={13}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
px={16}
|
||||
py={13}
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
border: "1.5px solid #CDEBDD",
|
||||
background: "#F6FBF8",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Container scope: required description of what the containers carry. */}
|
||||
{cargoType === "container" && (
|
||||
<Controller
|
||||
name="cargoFreeText"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Textarea
|
||||
label="Cargo description *"
|
||||
description="What will the containers carry under this contract?"
|
||||
placeholder="e.g. Electronics, garments, machinery spare parts…"
|
||||
value={field.value ?? ""}
|
||||
onChange={(e) => field.onChange(e.currentTarget.value)}
|
||||
onBlur={field.onBlur}
|
||||
error={fieldState.error?.message}
|
||||
radius={10}
|
||||
autosize
|
||||
minRows={2}
|
||||
maxRows={4}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 11,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "#EAF6EC",
|
||||
color: "#1E7B34",
|
||||
}}
|
||||
>
|
||||
<Container size={18} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fz={14} fw={700} c="#10202F">
|
||||
20ft & 40ft containers covered
|
||||
</Text>
|
||||
<Text fz={12} c="#6B7C8E" style={{ lineHeight: 1.4 }}>
|
||||
This contract quotes rates for both sizes. You choose the
|
||||
quantities on each booking — either size can be 0.
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{/* Bulk scope: a single commodity (cargo type path). No tonnage. */}
|
||||
@@ -330,85 +291,6 @@ export function Step3CargoScope({
|
||||
);
|
||||
}
|
||||
|
||||
/** Checkbox-style card for one container size. Whole card toggles. */
|
||||
function SizeCard({
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
hasError,
|
||||
onToggle,
|
||||
}: {
|
||||
label: string;
|
||||
description: string;
|
||||
checked: boolean;
|
||||
hasError: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<UnstyledButton
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
onClick={onToggle}
|
||||
px={16}
|
||||
py={13}
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
border: `1.5px solid ${
|
||||
checked ? "#0A6F4D" : hasError ? "#E8B4AC" : "#E6ECF2"
|
||||
}`,
|
||||
background: checked ? "#F6FBF8" : "#fff",
|
||||
transition: "all 150ms ease",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<Group gap={13} align="center" wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 7,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
border: `1.5px solid ${checked ? "#0A6F4D" : "#C7D2DC"}`,
|
||||
background: checked ? "#0A6F4D" : "#fff",
|
||||
color: "#fff",
|
||||
transition: "all 150ms ease",
|
||||
}}
|
||||
>
|
||||
{checked && <Check size={14} strokeWidth={3} />}
|
||||
</Box>
|
||||
<Box
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 11,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: checked ? "#EAF6EC" : "#F1F5F8",
|
||||
color: checked ? "#1E7B34" : "#6B7C8E",
|
||||
transition: "all 150ms ease",
|
||||
}}
|
||||
>
|
||||
<Container size={18} />
|
||||
</Box>
|
||||
<Box style={{ textAlign: "left" }}>
|
||||
<Text fz={14} fw={700} c="#10202F">
|
||||
{label}
|
||||
</Text>
|
||||
<Text fz={12} c="#6B7C8E" style={{ lineHeight: 1.4 }}>
|
||||
{description}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleRow({
|
||||
icon,
|
||||
iconBg,
|
||||
|
||||
@@ -57,10 +57,13 @@ const containerUnitSchema = z.object({
|
||||
|
||||
const containerLineSchema = z.object({
|
||||
containerSize: z.enum(["20ft", "40ft"]),
|
||||
// 0 is allowed: the contract covers both sizes, so a booking that only needs
|
||||
// one size zeroes the other line out. At least one line must be ≥ 1
|
||||
// (enforced in the superRefine).
|
||||
quantity: z
|
||||
.string()
|
||||
.refine((v) => v.trim().length > 0, "Quantity is required.")
|
||||
.refine((v) => !Number.isNaN(Number(v)) && Number(v) >= 1, "At least 1."),
|
||||
.refine((v) => !Number.isNaN(Number(v)) && Number(v) >= 0, "Enter 0 or more."),
|
||||
hazardousQuantity: z.string().default("0"),
|
||||
reeferQuantity: z.string().default("0"),
|
||||
returnQuantity: z.string().default("0"),
|
||||
@@ -74,6 +77,8 @@ const shipmentFormBase = z.object({
|
||||
// unloading. Seeded from the contract's equipment return; bulk ignores it.
|
||||
withReturn: z.boolean().default(false),
|
||||
containers: z.array(containerLineSchema).default([]),
|
||||
// What the containers carry — captured per booking (moved off the contract).
|
||||
cargoDescription: z.string().default(""),
|
||||
cargoWeightTons: z.string().default(""),
|
||||
itemCount: z.string().default(""),
|
||||
bulkHazardousQuantity: z.string().default("0"),
|
||||
@@ -92,6 +97,28 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) {
|
||||
}
|
||||
|
||||
if (ctx.isContainer) {
|
||||
// Containerized cargo must say WHAT is inside — required per booking.
|
||||
if (!data.cargoDescription.trim()) {
|
||||
refineCtx.addIssue({
|
||||
code: "custom",
|
||||
path: ["cargoDescription"],
|
||||
message: "Describe the cargo carried in the containers.",
|
||||
});
|
||||
}
|
||||
// Both sizes are always in contract scope and a line can be 0 — but the
|
||||
// booking as a whole needs at least one container. Anchor the error on
|
||||
// the first line's quantity so it renders in the field.
|
||||
const totalQty = data.containers.reduce(
|
||||
(sum, l) => sum + Math.max(0, Number(l.quantity) || 0),
|
||||
0,
|
||||
);
|
||||
if (data.containers.length > 0 && totalQty < 1) {
|
||||
refineCtx.addIssue({
|
||||
code: "custom",
|
||||
path: ["containers", 0, "quantity"],
|
||||
message: "Book at least one container (either size).",
|
||||
});
|
||||
}
|
||||
// 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.
|
||||
@@ -243,6 +270,7 @@ export const initialShipmentFormValues: DeepPartial<ShipmentFormValues> = {
|
||||
scheduledDate: "",
|
||||
withReturn: false,
|
||||
containers: [],
|
||||
cargoDescription: "",
|
||||
cargoWeightTons: "",
|
||||
itemCount: "",
|
||||
bulkHazardousQuantity: "0",
|
||||
@@ -257,6 +285,7 @@ export const shipmentStepFields: Record<
|
||||
0: ["contractRouteId"],
|
||||
1: [
|
||||
"containers",
|
||||
"cargoDescription",
|
||||
"cargoWeightTons",
|
||||
"itemCount",
|
||||
"bulkHazardousQuantity",
|
||||
|
||||
@@ -75,6 +75,52 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Node-side multipart POST — cy.request cannot stream FormData files,
|
||||
* and driving every GL upload modal through the UI is out of scope for
|
||||
* the scheduling-engine specs. Uses Node 18+ global fetch/FormData.
|
||||
*/
|
||||
async "api:upload"({
|
||||
url,
|
||||
token,
|
||||
fields = {},
|
||||
files = [],
|
||||
}: {
|
||||
url: string;
|
||||
token: string;
|
||||
fields?: Record<string, string>;
|
||||
files?: Array<{
|
||||
field: string;
|
||||
fixture: string;
|
||||
filename?: string;
|
||||
contentType?: string;
|
||||
}>;
|
||||
}) {
|
||||
const form = new FormData();
|
||||
for (const [key, value] of Object.entries(fields)) form.append(key, value);
|
||||
for (const f of files) {
|
||||
const buf = readFileSync(join(process.cwd(), "cypress", "fixtures", f.fixture));
|
||||
form.append(
|
||||
f.field,
|
||||
new Blob([buf], { type: f.contentType ?? "application/pdf" }),
|
||||
f.filename ?? "document.pdf",
|
||||
);
|
||||
}
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: form,
|
||||
});
|
||||
const text = await res.text();
|
||||
let body: unknown = text;
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch {
|
||||
// non-JSON body (rare) — return as text
|
||||
}
|
||||
return { status: res.status, body };
|
||||
},
|
||||
|
||||
async "db:seedUsers"() {
|
||||
// cwd = the e2e/freight project root when Cypress runs.
|
||||
// seed-company.sql depends on rows from seed-users.sql — keep order.
|
||||
|
||||
778
e2e/freight/cypress/e2e/flows/import-utils.ts
Normal file
778
e2e/freight/cypress/e2e/flows/import-utils.ts
Normal file
@@ -0,0 +1,778 @@
|
||||
/**
|
||||
* Shared helpers for the IMPORT corridor flow specs.
|
||||
*
|
||||
* Corridor (A→B→C→D→E→T, DJ→ET = IMPORT):
|
||||
* DJIB_PORT → NAGAD → DIRE_DAWA → E2E_AWASH → MOJO → KALITY
|
||||
*
|
||||
* Philosophy (same as segment_weight.cy.ts): these specs test the
|
||||
* scheduling/window/clearance ENGINE, not the contract wizard — contracts are
|
||||
* seeded FULLY_EXECUTED in SQL with stamped references; bookings, staff
|
||||
* reviews, window phases, payment, dispatch and clearance run through the real
|
||||
* API + UI. Window *timestamps* are arranged via db:query (the specs arrange
|
||||
* window state, they don't test the wall clock), and every transition is then
|
||||
* performed by the app's own 10s window tick or its staff endpoints.
|
||||
*
|
||||
* No module-level state besides constants: Cypress re-evaluates the spec
|
||||
* bundle on cross-origin reloads, so helpers look rows up by stamped-reference
|
||||
* SUFFIX + newest row, never by captured ids.
|
||||
*/
|
||||
|
||||
export const customer = "user@gmail.com";
|
||||
export const companyTin = "0102030405"; // seed-company.sql
|
||||
export const opsStaff = "operation@edr.local";
|
||||
/** isSuperAdmin bypasses assertFreightPermission — used for GL endpoints so a
|
||||
* missing preset permission never masks an engine regression. */
|
||||
export const superAdmin = "superadmin@tria.com";
|
||||
|
||||
export const CORRIDOR = ["DJIB_PORT", "NAGAD", "DIRE_DAWA", "E2E_AWASH", "MOJO", "KALITY"] as const;
|
||||
export const ORIGIN = "DJIB_PORT";
|
||||
export const DEST = "KALITY";
|
||||
|
||||
export const apiUrl = () => Cypress.env("apiUrl") as string;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// small generic plumbing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Row = Record<string, string | number | null>;
|
||||
|
||||
export function db<T = Row>(sql: string, params: unknown[] = []) {
|
||||
return cy.task<{ rowCount: number; rows: T[] }>("db:query", { sql, params }, { log: false });
|
||||
}
|
||||
|
||||
/** Bearer token for a staff/customer account (portal users use the demo pwd). */
|
||||
export function tokenFor(email: string): Cypress.Chainable<string> {
|
||||
const pass =
|
||||
email.endsWith("@gmail.com") ? (Cypress.env("demoPassword") as string) : undefined;
|
||||
return cy.apiLogin(email, pass).then(({ token }) => cy.wrap(token, { log: false }));
|
||||
}
|
||||
|
||||
export function apiPost(
|
||||
email: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
failOnStatusCode = true,
|
||||
) {
|
||||
return tokenFor(email).then((token) =>
|
||||
cy.request({
|
||||
method: "POST",
|
||||
url: `${apiUrl()}${path}`,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: body ?? {},
|
||||
failOnStatusCode,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Poll a 1-row query until `check` passes (10s window tick ⇒ 3s cadence). */
|
||||
export function pollDb<T = Row>(
|
||||
label: string,
|
||||
sql: string,
|
||||
params: unknown[],
|
||||
check: (row: T | undefined) => boolean,
|
||||
attempts = 40,
|
||||
) {
|
||||
const read = (attempt: number): void => {
|
||||
db<T>(sql, params).then(({ rows }) => {
|
||||
if (check(rows[0])) return;
|
||||
expect(attempt, label).to.be.lessThan(attempts);
|
||||
cy.wait(3000, { log: false }).then(() => read(attempt + 1));
|
||||
});
|
||||
};
|
||||
read(0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// time — departures pinned to 12:00 EAT so the EAT day key is unambiguous
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function departureAt(dayOffset: number): Date {
|
||||
const eatNow = new Date(Date.now() + 3 * 3_600_000);
|
||||
return new Date(
|
||||
Date.UTC(
|
||||
eatNow.getUTCFullYear(),
|
||||
eatNow.getUTCMonth(),
|
||||
eatNow.getUTCDate() + dayOffset,
|
||||
9, // 09:00 UTC = 12:00 EAT
|
||||
0,
|
||||
0,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** The EAT calendar day (`YYYY-MM-DD`) of an instant — the booking day key. */
|
||||
export const eatDayStr = (d: Date) =>
|
||||
new Date(d.getTime() + 3 * 3_600_000).toISOString().slice(0, 10);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// contracts — seeded FULLY_EXECUTED (see file header)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SeedContractOpts {
|
||||
suffix: string;
|
||||
reference: string;
|
||||
currency?: "ETB" | "USD";
|
||||
customs?: boolean;
|
||||
direction?: "IMPORT" | "DOMESTIC";
|
||||
originCode?: string;
|
||||
destCode?: string;
|
||||
}
|
||||
|
||||
export function seedImportContract(opts: SeedContractOpts) {
|
||||
const currency = opts.currency ?? "ETB";
|
||||
const customs = opts.customs ?? false;
|
||||
const direction = opts.direction ?? "IMPORT";
|
||||
db(
|
||||
`WITH c AS (
|
||||
INSERT INTO freight.contracts
|
||||
(reference, company_id, company_profile_id, contract_kind,
|
||||
trade_direction, freight_type, service_type_id, payment_currency,
|
||||
customs_clearing_enabled, clearance_status, status,
|
||||
fully_executed_at, contract_valid_from, contract_valid_until,
|
||||
contract_summary)
|
||||
SELECT $1, comp.id,
|
||||
(SELECT p.id FROM freight.company_profiles p
|
||||
WHERE p.company_id = comp.id AND p.deleted_at IS NULL
|
||||
ORDER BY CASE WHEN p.type = 'importer' THEN 0 ELSE 1 END
|
||||
LIMIT 1),
|
||||
'ONE_TIME', $2, 'CONTAINER',
|
||||
(SELECT st.id FROM freight.service_types st ORDER BY st.created_at LIMIT 1),
|
||||
$3, $4,
|
||||
CASE WHEN $4 THEN 'CLEARANCE_READY_FOR_BOOKING' ELSE 'NOT_APPLICABLE' END,
|
||||
'FULLY_EXECUTED', now(), now() - interval '1 day',
|
||||
now() + interval '60 days', 'E2E import-corridor fixture contract'
|
||||
FROM freight.companies comp
|
||||
WHERE comp.tin = $5
|
||||
-- before() re-runs on cross-origin reloads: keep one stable fresh row
|
||||
-- per suffix (skip when this run already seeded an unbooked one).
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.contracts c2
|
||||
WHERE c2.reference LIKE 'CTR-IMP-%-' || $8
|
||||
AND c2.deleted_at IS NULL
|
||||
AND c2.created_at > now() - interval '30 minutes'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.bookings b2 WHERE b2.contract_id = c2.id
|
||||
)
|
||||
)
|
||||
RETURNING id
|
||||
), r AS (
|
||||
INSERT INTO freight.contract_routes
|
||||
(contract_id, origin_yard_id, destination_yard_id, sort_order)
|
||||
SELECT c.id, o.id, d.id, 0 FROM c
|
||||
JOIN freight.yards o ON o.code = $6
|
||||
JOIN freight.yards d ON d.code = $7
|
||||
RETURNING id
|
||||
), scope AS (
|
||||
INSERT INTO freight.contract_cargo_scope
|
||||
(contract_id, container_size, cargo_free_text)
|
||||
SELECT c.id, v.size, 'E2E import corridor cargo'
|
||||
FROM c CROSS JOIN (VALUES ('20ft'), ('40ft')) AS v(size)
|
||||
)
|
||||
-- Path B gate: ONE_TIME customs bookings require the pre-booking boundary
|
||||
-- milestone (IMPORT → DO_COLLECTED) COMPLETED at contract level.
|
||||
INSERT INTO freight.clearance_milestones
|
||||
(contract_id, milestone_code, milestone_label, status, triggered_at, sort_order)
|
||||
SELECT c.id, 'DO_COLLECTED', 'Delivery order collected', 'COMPLETED', now(), 0
|
||||
FROM c WHERE $4`,
|
||||
[
|
||||
opts.reference,
|
||||
direction,
|
||||
currency,
|
||||
customs,
|
||||
companyTin,
|
||||
opts.originCode ?? ORIGIN,
|
||||
opts.destCode ?? DEST,
|
||||
opts.suffix,
|
||||
],
|
||||
);
|
||||
// Backfill the boundary milestone when the insert above was skipped because
|
||||
// a prior run's still-unbooked contract row is being reused.
|
||||
if (customs) {
|
||||
db(
|
||||
`INSERT INTO freight.clearance_milestones
|
||||
(contract_id, milestone_code, milestone_label, status, triggered_at, sort_order)
|
||||
SELECT ct.id, 'DO_COLLECTED', 'Delivery order collected', 'COMPLETED', now(), 0
|
||||
FROM freight.contracts ct
|
||||
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1 AND ct.deleted_at IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.clearance_milestones m
|
||||
WHERE m.contract_id = ct.id AND m.milestone_code = 'DO_COLLECTED'
|
||||
AND m.deleted_at IS NULL
|
||||
)`,
|
||||
[opts.suffix],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Newest seeded contract for a suffix — stamp-agnostic. */
|
||||
export function dbContractId(suffix: string) {
|
||||
return db<{ id: string }>(
|
||||
`SELECT id FROM freight.contracts
|
||||
WHERE reference LIKE 'CTR-IMP-%-' || $1
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
[suffix],
|
||||
).then(({ rows }) => {
|
||||
expect(rows, `seeded contract *-${suffix}`).to.have.length(1);
|
||||
return cy.wrap(rows[0].id, { log: false });
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// bookings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface BookingRow {
|
||||
id: string;
|
||||
reference: string;
|
||||
status: string;
|
||||
scheduling_status: string;
|
||||
train_schedule_id: string | null;
|
||||
payment_deadline: string | null;
|
||||
priority_score: number;
|
||||
is_split: boolean;
|
||||
contract_id: string;
|
||||
}
|
||||
|
||||
export function dbBooking(suffix: string) {
|
||||
return db<BookingRow>(
|
||||
`SELECT b.id, b.reference, b.status, b.scheduling_status,
|
||||
b.train_schedule_id, b.payment_deadline, b.priority_score,
|
||||
b.is_split, b.contract_id
|
||||
FROM freight.bookings b
|
||||
JOIN freight.contracts ct ON ct.id = b.contract_id
|
||||
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1
|
||||
ORDER BY b.created_at DESC LIMIT 1`,
|
||||
[suffix],
|
||||
);
|
||||
}
|
||||
|
||||
export function withBooking(suffix: string, fn: (b: BookingRow) => void) {
|
||||
dbBooking(suffix).then(({ rows }) => {
|
||||
expect(rows, `booking under *-${suffix}`).to.have.length(1);
|
||||
fn(rows[0]);
|
||||
});
|
||||
}
|
||||
|
||||
export function expectBookingStatus(suffix: string, status: string | string[]) {
|
||||
const want = Array.isArray(status) ? status : [status];
|
||||
withBooking(suffix, (b) =>
|
||||
expect(b.status, `${suffix} booking status`).to.be.oneOf(want),
|
||||
);
|
||||
}
|
||||
|
||||
export function pollBookingStatus(suffix: string, status: string | string[], attempts = 40) {
|
||||
const want = Array.isArray(status) ? status : [status];
|
||||
pollDb<BookingRow>(
|
||||
`${suffix} → ${want.join("|")}`,
|
||||
`SELECT b.status FROM freight.bookings b
|
||||
JOIN freight.contracts ct ON ct.id = b.contract_id
|
||||
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1
|
||||
ORDER BY b.created_at DESC LIMIT 1`,
|
||||
[suffix],
|
||||
(row) => !!row && want.includes(row.status as string),
|
||||
attempts,
|
||||
);
|
||||
}
|
||||
|
||||
/** ISO 6346-shaped container number, unique per run+seed (checksum unchecked). */
|
||||
export function isoNumber(runStamp: string, seed: number): string {
|
||||
return `MSCU${String((Number(runStamp.slice(-6)) * 100 + seed) % 10_000_000).padStart(7, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer books containers under a seeded contract via the API (the portal
|
||||
* booking form is exercised by export_one_time/intercity specs; a 22-wagon
|
||||
* booking means 44 ISO inputs — not a UI journey).
|
||||
*/
|
||||
export function bookContainers(opts: {
|
||||
suffix: string;
|
||||
runStamp: string;
|
||||
isoSeed: number;
|
||||
twenty?: number;
|
||||
forty?: number;
|
||||
scheduledDate?: string; // omit for DOMESTIC (intercity)
|
||||
vgmTons?: number;
|
||||
expectFailure?: string; // substring of the expected 4xx error message
|
||||
}) {
|
||||
const vgm = opts.vgmTons ?? 10;
|
||||
const lines: Array<Record<string, unknown>> = [];
|
||||
let unit = 0;
|
||||
if (opts.twenty) {
|
||||
lines.push({
|
||||
containerSize: "20ft",
|
||||
quantity: opts.twenty,
|
||||
units: Array.from({ length: opts.twenty }, () => ({
|
||||
containerNumber: isoNumber(opts.runStamp, opts.isoSeed + unit++),
|
||||
vgmTons: vgm,
|
||||
})),
|
||||
});
|
||||
}
|
||||
if (opts.forty) {
|
||||
lines.push({
|
||||
containerSize: "40ft",
|
||||
quantity: opts.forty,
|
||||
units: Array.from({ length: opts.forty }, () => ({
|
||||
containerNumber: isoNumber(opts.runStamp, opts.isoSeed + unit++),
|
||||
vgmTons: vgm,
|
||||
})),
|
||||
});
|
||||
}
|
||||
db<{ id: string; customs_clearing_enabled: boolean }>(
|
||||
`SELECT id, customs_clearing_enabled FROM freight.contracts
|
||||
WHERE reference LIKE 'CTR-IMP-%-' || $1
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
[opts.suffix],
|
||||
).then(({ rows }) => {
|
||||
expect(rows, `seeded contract *-${opts.suffix}`).to.have.length(1);
|
||||
// Path B: customs-clearance contracts are booked by Global Logistics on
|
||||
// behalf of the customer — the portal user is rejected with a 403.
|
||||
const actor = rows[0].customs_clearing_enabled ? superAdmin : customer;
|
||||
apiPost(
|
||||
actor,
|
||||
`/api/contracts/${rows[0].id}/bookings`,
|
||||
{
|
||||
...(opts.scheduledDate ? { scheduledDate: opts.scheduledDate } : {}),
|
||||
containers: lines,
|
||||
},
|
||||
!opts.expectFailure,
|
||||
).then((res) => {
|
||||
if (opts.expectFailure) {
|
||||
expect(res.status, `${opts.suffix} booking rejected`).to.be.within(400, 422);
|
||||
expect(JSON.stringify(res.body)).to.include(opts.expectFailure);
|
||||
} else {
|
||||
expect(res.status, `${opts.suffix} booking created`).to.be.oneOf([200, 201]);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Ops accepts the operation request → FULLY_EXECUTED (enters the day pool). */
|
||||
export function acceptOperation(suffix: string) {
|
||||
withBooking(suffix, (b) => {
|
||||
apiPost(opsStaff, `/api/bookings/${b.id}/operation/review`, { decision: "ACCEPT" })
|
||||
.its("status")
|
||||
.should("be.oneOf", [200, 201]);
|
||||
});
|
||||
pollBookingStatus(suffix, "FULLY_EXECUTED", 10);
|
||||
}
|
||||
|
||||
/** Batch fill reserves by priority DESC — order 1 = first pick. */
|
||||
export function setPriority(suffix: string, order: number) {
|
||||
withBooking(suffix, (b) =>
|
||||
db(`UPDATE freight.bookings SET priority_score = $2 WHERE id = $1`, [
|
||||
b.id,
|
||||
1000 - order,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/** Staff force-pay; polls PAID + SCHEDULED. */
|
||||
export function markPaid(suffix: string) {
|
||||
withBooking(suffix, (b) => {
|
||||
apiPost(opsStaff, `/api/train-scheduling/bookings/${b.id}/mark-paid`)
|
||||
.its("status")
|
||||
.should("be.oneOf", [200, 201]);
|
||||
});
|
||||
pollDb<BookingRow>(
|
||||
`${suffix} PAID+SCHEDULED`,
|
||||
`SELECT b.status, b.scheduling_status FROM freight.bookings b
|
||||
JOIN freight.contracts ct ON ct.id = b.contract_id
|
||||
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1
|
||||
ORDER BY b.created_at DESC LIMIT 1`,
|
||||
[suffix],
|
||||
(row) => row?.status === "PAID" && row?.scheduling_status === "SCHEDULED",
|
||||
20,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle a reservation through the REAL payment pipeline: seed the gateway
|
||||
* intent projection (the payment microservice is absent in e2e), link it to
|
||||
* the open invoice, then deliver the `payment.succeeded` event to the public
|
||||
* internal endpoint. This drives billing settle → `booking.invoice.paid` →
|
||||
* `advanceBookingOnPayment` → `ensurePaidBookingAllocated`, which is the ONLY
|
||||
* path that applies a pending split offer (staff mark-paid skips it).
|
||||
*/
|
||||
export function settleViaGateway(suffix: string) {
|
||||
withBooking(suffix, (b) => {
|
||||
db<{ intent_id: string; currency: string; total: string }>(
|
||||
`WITH inv AS (
|
||||
SELECT id, currency, total_amount FROM freight.invoices
|
||||
WHERE source_id = $1 AND deleted_at IS NULL AND paid_at IS NULL
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
), intent AS (
|
||||
INSERT INTO freight.payments
|
||||
(id, ref_id, type, reference_type, method, currency, amount,
|
||||
reason, raw_initiation, merchant_order_id, status)
|
||||
SELECT gen_random_uuid(), $1, 'FREIGHT', 'SHIPMENT',
|
||||
'telebirr'::freight.payments_method_enum,
|
||||
inv.currency::freight.payments_currency_enum, 1,
|
||||
'e2e gateway settle', '{}'::jsonb, 'E2E_' || $2,
|
||||
'processing'::freight.payments_status_enum
|
||||
FROM inv
|
||||
RETURNING id
|
||||
), link AS (
|
||||
UPDATE freight.invoices SET payment_id = intent.id
|
||||
FROM intent WHERE freight.invoices.id = (SELECT id FROM inv)
|
||||
RETURNING payment_id
|
||||
)
|
||||
SELECT intent.id AS intent_id, inv.currency, inv.total_amount AS total
|
||||
FROM intent, inv`,
|
||||
[b.id, `${suffix}-${b.id.slice(0, 8)}`],
|
||||
).then(({ rows }) => {
|
||||
expect(rows, `${suffix} gateway intent`).to.have.length(1);
|
||||
cy.request({
|
||||
method: "POST",
|
||||
url: `${apiUrl()}/api/internal/payments/mark-paid`,
|
||||
body: {
|
||||
version: 1,
|
||||
eventId: crypto.randomUUID(),
|
||||
eventType: "payment.succeeded",
|
||||
occurredAt: new Date().toISOString(),
|
||||
service: "FREIGHT",
|
||||
intentId: rows[0].intent_id,
|
||||
referenceType: "SHIPMENT",
|
||||
referenceId: b.id,
|
||||
merchantOrderId: `E2E_${suffix}_${b.id.slice(0, 8)}`,
|
||||
provider: "TELEBIRR",
|
||||
amountMinor: 1,
|
||||
currency: rows[0].currency,
|
||||
},
|
||||
}).then((res) => {
|
||||
expect(res.status, `${suffix} payment event accepted`).to.eq(200);
|
||||
// The global response interceptor wraps payloads in { success, data }.
|
||||
const raw = res.body as { processed?: boolean; data?: { processed?: boolean } };
|
||||
expect(raw.processed ?? raw.data?.processed, "event processed").to.eq(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
pollDb<BookingRow>(
|
||||
`${suffix} PAID via gateway`,
|
||||
`SELECT b.status FROM freight.bookings b
|
||||
JOIN freight.contracts ct ON ct.id = b.contract_id
|
||||
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1
|
||||
ORDER BY b.created_at DESC LIMIT 1`,
|
||||
[suffix],
|
||||
(row) => row?.status === "PAID",
|
||||
20,
|
||||
);
|
||||
}
|
||||
|
||||
/** Push a reservation's pay deadline into the past — the 10s tick expires it. */
|
||||
export function forceReservationExpiry(suffix: string) {
|
||||
withBooking(suffix, (b) =>
|
||||
db(
|
||||
`UPDATE freight.bookings SET payment_deadline = now() - interval '1 second'
|
||||
WHERE id = $1`,
|
||||
[b.id],
|
||||
),
|
||||
);
|
||||
pollBookingStatus(suffix, "EXPIRED");
|
||||
}
|
||||
|
||||
export function pollAllocations(suffix: string, minWagons = 1) {
|
||||
withBooking(suffix, (b) =>
|
||||
pollDb<{ n: string }>(
|
||||
`${suffix} wagon allocations`,
|
||||
`SELECT count(*) AS n FROM freight.wagon_booking_allocations
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL`,
|
||||
[b.id],
|
||||
(row) => Number(row?.n ?? 0) >= minWagons,
|
||||
30,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// route + schedule
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function dbRouteId(originCode = ORIGIN, destCode = DEST) {
|
||||
return db<{ id: string }>(
|
||||
`SELECT r.id FROM freight.routes r
|
||||
JOIN freight.yards o ON o.id = r.origin_yard_id AND o.code = $1
|
||||
JOIN freight.yards d ON d.id = r.destination_yard_id AND d.code = $2
|
||||
WHERE r.deleted_at IS NULL
|
||||
ORDER BY r.created_at DESC LIMIT 1`,
|
||||
[originCode, destCode],
|
||||
);
|
||||
}
|
||||
|
||||
/** Create the 6-stop corridor route through the API if it doesn't exist yet. */
|
||||
export function ensureCorridorRoute() {
|
||||
dbRouteId().then(({ rows }) => {
|
||||
if (rows.length > 0) return;
|
||||
db<{ id: string; code: string }>(
|
||||
`SELECT id, code FROM freight.yards WHERE code = ANY($1::text[])`,
|
||||
[[...CORRIDOR]],
|
||||
).then(({ rows: yards }) => {
|
||||
expect(yards, "corridor yards").to.have.length(CORRIDOR.length);
|
||||
const byCode = new Map(yards.map((y) => [y.code, y.id]));
|
||||
apiPost(opsStaff, "/api/routes", {
|
||||
milestones: CORRIDOR.map((code) => ({ yardId: byCode.get(code) })),
|
||||
})
|
||||
.its("status")
|
||||
.should("be.oneOf", [200, 201]);
|
||||
});
|
||||
// Direction is frozen from the endpoint countries: DJ → ET = IMPORT.
|
||||
db<{ direction: string }>(
|
||||
`SELECT r.direction FROM freight.routes r
|
||||
JOIN freight.yards o ON o.id = r.origin_yard_id AND o.code = $1
|
||||
JOIN freight.yards d ON d.id = r.destination_yard_id AND d.code = $2
|
||||
WHERE r.deleted_at IS NULL ORDER BY r.created_at DESC LIMIT 1`,
|
||||
[ORIGIN, DEST],
|
||||
).then(({ rows: created }) => {
|
||||
expect(created[0]?.direction, "corridor direction").to.eq("IMPORT");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a corridor departure-day re-runnable: soft-delete any schedule a prior
|
||||
* run left on that day and expire its leftover fixture bookings (reference
|
||||
* scope CTR-IMP-% only — never touches other suites' data). A wiped schedule
|
||||
* must never leave bookings pointing at it (ghost refs break assign).
|
||||
*/
|
||||
export function resetCorridorDay(departure: Date, destCode = DEST) {
|
||||
db(
|
||||
`WITH stale AS (
|
||||
SELECT ts.id FROM freight.train_schedules ts
|
||||
JOIN freight.yards o ON o.id = ts.origin_station_id AND o.code = $1
|
||||
JOIN freight.yards d ON d.id = ts.destination_station_id AND d.code = $2
|
||||
WHERE ts.deleted_at IS NULL
|
||||
AND abs(extract(epoch FROM (ts.scheduled_departure_date - $3::timestamptz))) < 43200
|
||||
), unlink AS (
|
||||
UPDATE freight.bookings b
|
||||
SET train_schedule_id = NULL,
|
||||
status = CASE WHEN b.status IN ('FULLY_EXECUTED','SELECTED_FOR_BATCH','AWAITING_PAYMENT')
|
||||
THEN 'EXPIRED' ELSE b.status END,
|
||||
scheduling_status = 'NOT_SCHEDULED'
|
||||
FROM freight.contracts ct
|
||||
WHERE ct.id = b.contract_id AND ct.reference LIKE 'CTR-IMP-%'
|
||||
AND b.train_schedule_id IN (SELECT id FROM stale)
|
||||
), drop_links AS (
|
||||
UPDATE freight.train_schedule_bookings SET deleted_at = now()
|
||||
WHERE train_schedule_id IN (SELECT id FROM stale) AND deleted_at IS NULL
|
||||
)
|
||||
UPDATE freight.train_schedules SET deleted_at = now()
|
||||
WHERE id IN (SELECT id FROM stale)`,
|
||||
[ORIGIN, destCode, departure.toISOString()],
|
||||
);
|
||||
// Prior-run pool leftovers (never reserved, so no schedule ref) would
|
||||
// contaminate this run's batch — a stale high-priority booking steals the
|
||||
// top-up slot from this run's waiting list. Reset runs before this run
|
||||
// books anything, so every unpinned fixture booking is debris: expire all.
|
||||
db(
|
||||
`UPDATE freight.bookings b
|
||||
SET status = 'EXPIRED'
|
||||
FROM freight.contracts ct
|
||||
WHERE ct.id = b.contract_id AND ct.reference LIKE 'CTR-IMP-%'
|
||||
AND b.status = 'FULLY_EXECUTED' AND b.train_schedule_id IS NULL`,
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
export interface ScheduleRow {
|
||||
id: string;
|
||||
status: string;
|
||||
window_phase: string;
|
||||
booking_window_status: string;
|
||||
booking_cycle_no: number;
|
||||
max_wagons: number;
|
||||
window_opens_at: string | null;
|
||||
window_closes_at: string | null;
|
||||
payment_phase_ends_at: string | null;
|
||||
scheduled_departure_date: string;
|
||||
}
|
||||
|
||||
const SCHEDULE_COLS = `ts.id, ts.status, ts.window_phase, ts.booking_window_status,
|
||||
ts.booking_cycle_no, ts.max_wagons, ts.window_opens_at, ts.window_closes_at,
|
||||
ts.payment_phase_ends_at, ts.scheduled_departure_date`;
|
||||
|
||||
/** The corridor schedule departing within ±1h of `departure` (12:00 EAT pin). */
|
||||
export function dbSchedule(departure: Date, destCode = DEST) {
|
||||
return db<ScheduleRow>(
|
||||
`SELECT ${SCHEDULE_COLS}
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.yards o ON o.id = ts.origin_station_id AND o.code = $1
|
||||
JOIN freight.yards d ON d.id = ts.destination_station_id AND d.code = $2
|
||||
WHERE ts.deleted_at IS NULL
|
||||
AND abs(extract(epoch FROM (ts.scheduled_departure_date - $3::timestamptz))) < 3600
|
||||
ORDER BY ts.created_at DESC LIMIT 1`,
|
||||
[ORIGIN, destCode, departure.toISOString()],
|
||||
);
|
||||
}
|
||||
|
||||
export function withSchedule(departure: Date, fn: (s: ScheduleRow) => void) {
|
||||
dbSchedule(departure).then(({ rows }) => {
|
||||
expect(rows, `schedule departing ${departure.toISOString()}`).to.have.length(1);
|
||||
fn(rows[0]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ops creates an import schedule on the corridor via the API — loco-pair mode
|
||||
* (no built train): capacity comes from maxWagonsPerTrain (54, the corridor
|
||||
* standard) and wagon stock is drawn from the origin yard at allocation time.
|
||||
*/
|
||||
export function createImportSchedule(opts: {
|
||||
departure: Date;
|
||||
locoPair: [string, string];
|
||||
maxWagons?: number;
|
||||
}) {
|
||||
dbSchedule(opts.departure).then(({ rows }) => {
|
||||
if (rows.length > 0) return;
|
||||
dbRouteId().then(({ rows: routes }) => {
|
||||
expect(routes, "corridor route").to.have.length(1);
|
||||
db<{ id: string }>(
|
||||
`SELECT id FROM freight.locomotives WHERE code = ANY($1::text[]) ORDER BY code`,
|
||||
[opts.locoPair],
|
||||
).then(({ rows: locos }) => {
|
||||
expect(locos, `locomotives ${opts.locoPair.join(",")}`).to.have.length(2);
|
||||
apiPost(opsStaff, "/api/train-scheduling/container/schedules", {
|
||||
routeId: routes[0].id,
|
||||
scheduleDate: opts.departure.toISOString(),
|
||||
locomotiveIds: locos.map((l) => l.id),
|
||||
maxWagonsPerTrain: opts.maxWagons ?? 54,
|
||||
})
|
||||
.its("status")
|
||||
.should("be.oneOf", [200, 201]);
|
||||
});
|
||||
});
|
||||
});
|
||||
withSchedule(opts.departure, (s) => {
|
||||
expect(s.max_wagons, "54-wagon consist").to.eq(opts.maxWagons ?? 54);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// window choreography — arrange timestamps, let the engine do the transition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function pollSchedulePhase(
|
||||
scheduleId: string,
|
||||
want: string[],
|
||||
label: string,
|
||||
attempts = 40,
|
||||
) {
|
||||
pollDb<ScheduleRow>(
|
||||
label,
|
||||
`SELECT window_phase, booking_window_status FROM freight.train_schedules WHERE id = $1`,
|
||||
[scheduleId],
|
||||
(row) => !!row && want.includes(row.window_phase as unknown as string),
|
||||
attempts,
|
||||
);
|
||||
}
|
||||
|
||||
/** Pull the window-open moment into the past; the tick flips PRE_WINDOW→OPEN. */
|
||||
export function forceWindowOpen(scheduleId: string, closesInMinutes = 45) {
|
||||
db(
|
||||
`UPDATE freight.train_schedules
|
||||
SET window_opens_at = now() - interval '1 minute',
|
||||
window_closes_at = now() + ($2 || ' minutes')::interval
|
||||
WHERE id = $1`,
|
||||
[scheduleId, String(closesInMinutes)],
|
||||
);
|
||||
pollSchedulePhase(scheduleId, ["OPEN"], `schedule ${scheduleId} window OPEN`);
|
||||
pollDb<ScheduleRow>(
|
||||
`schedule ${scheduleId} bookable`,
|
||||
`SELECT booking_window_status FROM freight.train_schedules WHERE id = $1`,
|
||||
[scheduleId],
|
||||
(row) => row?.booking_window_status === "OPEN",
|
||||
);
|
||||
}
|
||||
|
||||
/** Pull the close moment into the past; the tick flips OPEN→DOC_REVIEW. */
|
||||
export function closeBookingWindow(scheduleId: string) {
|
||||
db(
|
||||
`UPDATE freight.train_schedules
|
||||
SET window_closes_at = now() - interval '1 second'
|
||||
WHERE id = $1 AND window_phase = 'OPEN'`,
|
||||
[scheduleId],
|
||||
);
|
||||
pollSchedulePhase(scheduleId, ["DOC_REVIEW"], `schedule ${scheduleId} DOC_REVIEW`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff end document review early → PAYMENT: expires never-accepted bookings,
|
||||
* runs the priority batch over the route-day pool, reserves + issues invoices.
|
||||
* (Lands on DONE instead when the batch reserved nobody.)
|
||||
*/
|
||||
export function completeDocReview(scheduleId: string) {
|
||||
apiPost(opsStaff, `/api/train-scheduling/schedules/${scheduleId}/doc-review-complete`)
|
||||
.its("status")
|
||||
.should("be.oneOf", [200, 201]);
|
||||
pollSchedulePhase(
|
||||
scheduleId,
|
||||
["PAYMENT", "DONE", "PRE_WINDOW"],
|
||||
`schedule ${scheduleId} payment phase`,
|
||||
);
|
||||
}
|
||||
|
||||
/** End the payment phase now — the tick settles (allocate paid / expire unpaid). */
|
||||
export function endPaymentPhase(scheduleId: string) {
|
||||
db(
|
||||
`UPDATE freight.train_schedules
|
||||
SET payment_phase_ends_at = now() - interval '1 second'
|
||||
WHERE id = $1 AND window_phase = 'PAYMENT'`,
|
||||
[scheduleId],
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// train journey + clearance
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function recordCheckpoint(scheduleId: string, sequenceNo: number, kind: string) {
|
||||
apiPost(opsStaff, `/api/train-scheduling/schedules/${scheduleId}/checkpoints`, {
|
||||
sequenceNo,
|
||||
kind,
|
||||
})
|
||||
.its("status")
|
||||
.should("be.oneOf", [200, 201]);
|
||||
}
|
||||
|
||||
/** GL multipart upload via the Node-side task (cy.request can't send files). */
|
||||
export function glUpload(
|
||||
path: string,
|
||||
fields: Record<string, string> = {},
|
||||
fileField = "files",
|
||||
) {
|
||||
return tokenFor(superAdmin).then((token) =>
|
||||
cy
|
||||
.task<{ status: number; body: unknown }>("api:upload", {
|
||||
url: `${apiUrl()}${path}`,
|
||||
token,
|
||||
fields,
|
||||
files: [{ field: fileField, fixture: "docs/license.pdf", filename: "e2e-doc.pdf" }],
|
||||
})
|
||||
.then((res) => {
|
||||
expect(res.status, `upload ${path}`).to.be.within(200, 201);
|
||||
return cy.wrap(res.body, { log: false });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function completeBookingMilestone(suffix: string, code: string) {
|
||||
withBooking(suffix, (b) => {
|
||||
apiPost(superAdmin, `/api/contracts/bookings/${b.id}/milestones/${code}/complete`, {
|
||||
note: "e2e",
|
||||
})
|
||||
.its("status")
|
||||
.should("be.oneOf", [200, 201]);
|
||||
});
|
||||
}
|
||||
|
||||
export function expectMilestoneDone(suffix: string, code: string) {
|
||||
withBooking(suffix, (b) =>
|
||||
pollDb<{ n: string }>(
|
||||
`${suffix} milestone ${code}`,
|
||||
`SELECT count(*) AS n FROM freight.clearance_milestones
|
||||
WHERE booking_id = $1 AND milestone_code = $2 AND status = 'COMPLETED'
|
||||
AND deleted_at IS NULL`,
|
||||
[b.id, code],
|
||||
(row) => Number(row?.n ?? 0) > 0,
|
||||
10,
|
||||
),
|
||||
);
|
||||
}
|
||||
299
e2e/freight/cypress/e2e/flows/import_critical_matrix.cy.ts
Normal file
299
e2e/freight/cypress/e2e/flows/import_critical_matrix.cy.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* IMPORT critical-scenario matrix — the corridor edge cases that don't need a
|
||||
* full journey each. One 54-wagon train departing D+7, plus a same-day sibling:
|
||||
*
|
||||
* 1. hard gates at booking creation:
|
||||
* – no open window on the requested day → rejected
|
||||
* – duplicate ISO container number inside one booking → rejected
|
||||
* 2. route creation refuses a yard pair with no configured distance
|
||||
* 3. sub-corridor import booking (NAGAD → MOJO) rides the through-train:
|
||||
* the batch is corridor-aware, the booking reserves only its own leg
|
||||
* 4. intercity ride-along (MOJO → KALITY, DOMESTIC): dateless booking, staff
|
||||
* accept onto the import train's free leg, pay window opens, paid + linked
|
||||
* 5. same-route same-day sibling schedule JOINS the group window (shared
|
||||
* open/close timeline — no cross-expiry), and staff can move a booking
|
||||
* onto the sibling (move-schedule)
|
||||
*
|
||||
* Sequential steps — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
resetCorridorDay,
|
||||
acceptOperation,
|
||||
apiPost,
|
||||
bookContainers,
|
||||
closeBookingWindow,
|
||||
completeDocReview,
|
||||
createImportSchedule,
|
||||
db,
|
||||
departureAt,
|
||||
dbRouteId,
|
||||
eatDayStr,
|
||||
ensureCorridorRoute,
|
||||
forceWindowOpen,
|
||||
markPaid,
|
||||
opsStaff,
|
||||
pollAllocations,
|
||||
pollBookingStatus,
|
||||
pollDb,
|
||||
seedImportContract,
|
||||
withBooking,
|
||||
withSchedule,
|
||||
type ScheduleRow,
|
||||
} from "./import-utils";
|
||||
|
||||
const DEPARTURE = departureAt(9);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
const NO_WINDOW_DAY = eatDayStr(departureAt(11)); // no schedule exists there
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
describe("import critical matrix: gates, sub-corridor, intercity, sibling window", { retries: 0 }, () => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
seedImportContract({ suffix: "MX1", reference: stampedRef("MX1") }); // through-corridor
|
||||
seedImportContract({ suffix: "MX2", reference: stampedRef("MX2") }); // gate probes
|
||||
seedImportContract({
|
||||
suffix: "MXSUB",
|
||||
reference: stampedRef("MXSUB"),
|
||||
originCode: "NAGAD",
|
||||
destCode: "MOJO",
|
||||
});
|
||||
seedImportContract({
|
||||
suffix: "MXIC",
|
||||
reference: stampedRef("MXIC"),
|
||||
direction: "DOMESTIC",
|
||||
originCode: "MOJO",
|
||||
destCode: "KALITY",
|
||||
});
|
||||
seedImportContract({ suffix: "MXMOVE", reference: stampedRef("MXMOVE") });
|
||||
});
|
||||
|
||||
it("operations prepares the corridor and the D+7 train with an open window", () => {
|
||||
ensureCorridorRoute();
|
||||
resetCorridorDay(DEPARTURE);
|
||||
createImportSchedule({ departure: DEPARTURE, locoPair: ["LOCO-IMP-11", "LOCO-IMP-12"] });
|
||||
withSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 60));
|
||||
});
|
||||
|
||||
it("gate: a booking day with no open window is rejected", () => {
|
||||
bookContainers({
|
||||
suffix: "MX2",
|
||||
runStamp: stamp,
|
||||
isoSeed: 5000,
|
||||
twenty: 2,
|
||||
scheduledDate: NO_WINDOW_DAY,
|
||||
expectFailure: "booking window",
|
||||
});
|
||||
});
|
||||
|
||||
it("gate: a duplicate ISO container number inside one booking is rejected", () => {
|
||||
// Two units, same number: build the payload by hand via the same API.
|
||||
apiPostDuplicate();
|
||||
|
||||
function apiPostDuplicate() {
|
||||
const dupe = `MSCU${String(Number(stamp.slice(-6)) + 5100).padStart(7, "0")}`;
|
||||
db<{ id: string }>(
|
||||
`SELECT id FROM freight.contracts
|
||||
WHERE reference LIKE 'CTR-IMP-%-' || $1
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
["MX2"],
|
||||
).then(({ rows }) => {
|
||||
apiPost(
|
||||
"user@gmail.com",
|
||||
`/api/contracts/${rows[0].id}/bookings`,
|
||||
{
|
||||
scheduledDate: BOOKING_DAY,
|
||||
containers: [
|
||||
{
|
||||
containerSize: "20ft",
|
||||
quantity: 2,
|
||||
units: [
|
||||
{ containerNumber: dupe, vgmTons: 10 },
|
||||
{ containerNumber: dupe, vgmTons: 10 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
false,
|
||||
).then((res) => {
|
||||
expect(res.status, "duplicate ISO rejected").to.be.within(400, 422);
|
||||
expect(JSON.stringify(res.body)).to.include("Duplicate container number");
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("gate: a route over a yard pair with no configured distance is rejected", () => {
|
||||
db<{ id: string; code: string }>(
|
||||
`SELECT id, code FROM freight.yards WHERE code = ANY($1::text[])`,
|
||||
[["E2E_AWASH", "DJIB_PORT"]],
|
||||
).then(({ rows }) => {
|
||||
const byCode = new Map(rows.map((y) => [y.code, y.id]));
|
||||
// E2E_AWASH ↔ DJIB_PORT has no direct distance row.
|
||||
apiPost(
|
||||
opsStaff,
|
||||
"/api/routes",
|
||||
{
|
||||
milestones: [
|
||||
{ yardId: byCode.get("DJIB_PORT") },
|
||||
{ yardId: byCode.get("E2E_AWASH") },
|
||||
],
|
||||
},
|
||||
false,
|
||||
).then((res) => {
|
||||
expect(res.status, "distance-less route rejected").to.be.within(400, 422);
|
||||
expect(JSON.stringify(res.body)).to.include("No distance configured");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("a through-corridor booking and a NAGAD→MOJO sub-corridor booking share the train", () => {
|
||||
// All windowed bookings (incl. MXMOVE for the later move test) go in while
|
||||
// the window is still OPEN — the create gate closes with it.
|
||||
bookContainers({
|
||||
suffix: "MX1",
|
||||
runStamp: stamp,
|
||||
isoSeed: 5200,
|
||||
twenty: 40, // 20 wagons DJIB_PORT → KALITY
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
acceptOperation("MX1");
|
||||
bookContainers({
|
||||
suffix: "MXSUB",
|
||||
runStamp: stamp,
|
||||
isoSeed: 5300,
|
||||
twenty: 20, // 10 wagons, NAGAD → MOJO leg only
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
acceptOperation("MXSUB");
|
||||
bookContainers({
|
||||
suffix: "MXMOVE",
|
||||
runStamp: stamp,
|
||||
isoSeed: 5500,
|
||||
forty: 4, // 4 wagons — later moved onto the sibling train
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
acceptOperation("MXMOVE");
|
||||
|
||||
withSchedule(DEPARTURE, (s) => {
|
||||
closeBookingWindow(s.id);
|
||||
completeDocReview(s.id);
|
||||
});
|
||||
["MX1", "MXSUB", "MXMOVE"].forEach((suffix) =>
|
||||
pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
|
||||
);
|
||||
markPaid("MX1");
|
||||
pollAllocations("MX1", 20);
|
||||
markPaid("MXSUB");
|
||||
pollAllocations("MXSUB", 10);
|
||||
|
||||
// Both ride the same schedule even though MXSUB's endpoints are interior stops.
|
||||
withSchedule(DEPARTURE, (s) => {
|
||||
withBooking("MX1", (b) => expect(b.train_schedule_id).to.eq(s.id));
|
||||
withBooking("MXSUB", (b) => expect(b.train_schedule_id).to.eq(s.id));
|
||||
});
|
||||
});
|
||||
|
||||
it("intercity ride-along: dateless DOMESTIC booking accepted onto the import train's free leg", () => {
|
||||
bookContainers({
|
||||
suffix: "MXIC",
|
||||
runStamp: stamp,
|
||||
isoSeed: 5400,
|
||||
twenty: 4, // 2 wagons MOJO → KALITY — plenty of leg capacity left
|
||||
// no scheduledDate: intercity bookings are dateless
|
||||
});
|
||||
acceptOperation("MXIC");
|
||||
|
||||
withSchedule(DEPARTURE, (s) => {
|
||||
withBooking("MXIC", (b) => {
|
||||
apiPost(opsStaff, `/api/train-scheduling/schedules/${s.id}/intercity/accept`, {
|
||||
bookingIds: [b.id],
|
||||
})
|
||||
.its("status")
|
||||
.should("be.oneOf", [200, 201]);
|
||||
});
|
||||
});
|
||||
pollBookingStatus("MXIC", ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||||
withBooking("MXIC", (b) => {
|
||||
expect(b.payment_deadline, "ride-along pay window opened").to.be.a("string");
|
||||
});
|
||||
markPaid("MXIC");
|
||||
withSchedule(DEPARTURE, (s) => {
|
||||
withBooking("MXIC", (b) => {
|
||||
expect(b.train_schedule_id, "linked to the import train").to.eq(s.id);
|
||||
db<{ n: string }>(
|
||||
`SELECT count(*) AS n FROM freight.train_schedule_bookings
|
||||
WHERE booking_id = $1 AND train_schedule_id = $2 AND deleted_at IS NULL`,
|
||||
[b.id, s.id],
|
||||
).then(({ rows }) => expect(Number(rows[0].n), "link row").to.eq(1));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("a same-route same-day sibling schedule joins the shared group window", () => {
|
||||
createImportSchedule({
|
||||
departure: new Date(DEPARTURE.getTime() + 90 * 60_000), // same EAT day, later
|
||||
locoPair: ["LOCO-IMP-13", "LOCO-IMP-14"],
|
||||
});
|
||||
withSchedule(DEPARTURE, (anchor) => {
|
||||
pollDb<ScheduleRow>(
|
||||
"sibling adopts the group timeline",
|
||||
`SELECT ${["ts.id", "ts.window_phase", "ts.window_opens_at"].join(", ")}
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.yards o ON o.id = ts.origin_station_id AND o.code = 'DJIB_PORT'
|
||||
JOIN freight.yards d ON d.id = ts.destination_station_id AND d.code = 'KALITY'
|
||||
WHERE ts.deleted_at IS NULL AND ts.id <> $1
|
||||
AND abs(extract(epoch FROM (ts.scheduled_departure_date - $2::timestamptz))) < 7200
|
||||
ORDER BY ts.created_at DESC LIMIT 1`,
|
||||
[anchor.id, DEPARTURE.toISOString()],
|
||||
// The anchor is already past OPEN (we closed it) — a mid-cycle joiner
|
||||
// mirrors the group's live phase instead of restarting its own clock.
|
||||
(row) => !!row && row.window_phase === anchor.window_phase,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("staff move a reserved booking onto the sibling train, then it pays there", () => {
|
||||
withSchedule(DEPARTURE, (anchor) => {
|
||||
db<{ id: string }>(
|
||||
`SELECT ts.id FROM freight.train_schedules ts
|
||||
WHERE ts.deleted_at IS NULL AND ts.id <> $1
|
||||
AND abs(extract(epoch FROM (ts.scheduled_departure_date - $2::timestamptz))) < 7200
|
||||
ORDER BY ts.created_at DESC LIMIT 1`,
|
||||
[anchor.id, DEPARTURE.toISOString()],
|
||||
).then(({ rows: siblings }) => {
|
||||
expect(siblings, "sibling schedule").to.have.length(1);
|
||||
// move-schedule only accepts an OPEN target — the sibling joined the
|
||||
// group mid-cycle (already past OPEN), so arrange its window state.
|
||||
db(
|
||||
`UPDATE freight.train_schedules SET booking_window_status = 'OPEN'
|
||||
WHERE id = $1`,
|
||||
[siblings[0].id],
|
||||
);
|
||||
withBooking("MXMOVE", (b) => {
|
||||
apiPost(opsStaff, `/api/train-scheduling/bookings/${b.id}/move-schedule`, {
|
||||
trainScheduleId: siblings[0].id,
|
||||
})
|
||||
.its("status")
|
||||
.should("be.oneOf", [200, 201]);
|
||||
pollDb<{ train_schedule_id: string }>(
|
||||
"MXMOVE pinned to the sibling",
|
||||
`SELECT train_schedule_id FROM freight.bookings WHERE id = $1`,
|
||||
[b.id],
|
||||
(row) => row?.train_schedule_id === siblings[0].id,
|
||||
10,
|
||||
);
|
||||
});
|
||||
markPaid("MXMOVE");
|
||||
pollAllocations("MXMOVE", 4);
|
||||
withBooking("MXMOVE", (b) => {
|
||||
expect(b.train_schedule_id, "paid on the sibling").to.eq(siblings[0].id);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
export {};
|
||||
332
e2e/freight/cypress/e2e/flows/import_full_train.cy.ts
Normal file
332
e2e/freight/cypress/e2e/flows/import_full_train.cy.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* IMPORT journey 1 — six container bookings fill a 54-wagon train on the long
|
||||
* corridor DJIB_PORT → NAGAD → DIRE_DAWA → E2E_AWASH → MOJO → KALITY, all in
|
||||
* the FIRST booking window, then the full life of the train: payment,
|
||||
* allocation, gate pass, T1, dispatch, checkpoint-by-checkpoint movement,
|
||||
* arrival, and the post-arrival customs tail to IMPORT_PROCESS_COMPLETED.
|
||||
*
|
||||
* The six bookings (exact wagon math — Σ = 54, the full consist):
|
||||
* FT1 customs + USD 16×20ft = 8 wagons
|
||||
* FT2 customs + ETB 6×40ft = 6 wagons
|
||||
* FT3 self + ETB 12×20ft = 6 wagons
|
||||
* FT4 self + ETB 6×40ft = 6 wagons
|
||||
* FT5 customs + USD 44×20ft = 22 wagons (the ≥22-wagon giant)
|
||||
* FT6 self + USD 4×40ft + 4×20ft = 6 wagons
|
||||
*
|
||||
* Sequential steps of one journey — retries off (steps are not idempotent).
|
||||
*/
|
||||
|
||||
import {
|
||||
resetCorridorDay,
|
||||
acceptOperation,
|
||||
apiPost,
|
||||
bookContainers,
|
||||
closeBookingWindow,
|
||||
completeBookingMilestone,
|
||||
completeDocReview,
|
||||
createImportSchedule,
|
||||
db,
|
||||
dbBooking,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
endPaymentPhase,
|
||||
ensureCorridorRoute,
|
||||
expectMilestoneDone,
|
||||
forceWindowOpen,
|
||||
glUpload,
|
||||
markPaid,
|
||||
opsStaff,
|
||||
pollAllocations,
|
||||
pollBookingStatus,
|
||||
pollDb,
|
||||
seedImportContract,
|
||||
withBooking,
|
||||
withSchedule,
|
||||
type ScheduleRow,
|
||||
} from "./import-utils";
|
||||
|
||||
const DEPARTURE = departureAt(4);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
/** suffix → [customs, currency, twenty, forty, wagons] */
|
||||
const BOOKINGS: Array<{
|
||||
suffix: string;
|
||||
customs: boolean;
|
||||
currency: "ETB" | "USD";
|
||||
twenty: number;
|
||||
forty: number;
|
||||
wagons: number;
|
||||
}> = [
|
||||
{ suffix: "FT1", customs: true, currency: "USD", twenty: 16, forty: 0, wagons: 8 },
|
||||
{ suffix: "FT2", customs: true, currency: "ETB", twenty: 0, forty: 6, wagons: 6 },
|
||||
{ suffix: "FT3", customs: false, currency: "ETB", twenty: 12, forty: 0, wagons: 6 },
|
||||
{ suffix: "FT4", customs: false, currency: "ETB", twenty: 0, forty: 6, wagons: 6 },
|
||||
{ suffix: "FT5", customs: true, currency: "USD", twenty: 44, forty: 0, wagons: 22 },
|
||||
{ suffix: "FT6", customs: false, currency: "USD", twenty: 4, forty: 4, wagons: 6 },
|
||||
];
|
||||
const CUSTOMS = BOOKINGS.filter((b) => b.customs).map((b) => b.suffix);
|
||||
const SELF_CLEAR = BOOKINGS.filter((b) => !b.customs).map((b) => b.suffix);
|
||||
|
||||
function withScheduleId(fn: (id: string, s: ScheduleRow) => void) {
|
||||
withSchedule(DEPARTURE, (s) => fn(s.id, s));
|
||||
}
|
||||
|
||||
describe("import: six bookings fill the 54-wagon corridor train", { retries: 0 }, () => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
for (const b of BOOKINGS) {
|
||||
seedImportContract({
|
||||
suffix: b.suffix,
|
||||
reference: stampedRef(b.suffix),
|
||||
currency: b.currency,
|
||||
customs: b.customs,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("operations ensures the 6-stop import corridor route exists (direction frozen IMPORT)", () => {
|
||||
ensureCorridorRoute();
|
||||
resetCorridorDay(DEPARTURE);
|
||||
});
|
||||
|
||||
it("operations schedules the 54-wagon import train — first window forced open", () => {
|
||||
createImportSchedule({ departure: DEPARTURE, locoPair: ["LOCO-IMP-1", "LOCO-IMP-2"] });
|
||||
withScheduleId((id) => forceWindowOpen(id, 45));
|
||||
withScheduleId((_, s) => {
|
||||
expect(s.booking_cycle_no, "FIRST window cycle").to.eq(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("customer books all six shipments inside the first window", () => {
|
||||
let isoSeed = 0;
|
||||
BOOKINGS.forEach((b) => {
|
||||
bookContainers({
|
||||
suffix: b.suffix,
|
||||
runStamp: stamp,
|
||||
isoSeed,
|
||||
twenty: b.twenty,
|
||||
forty: b.forty,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
isoSeed += b.twenty + b.forty;
|
||||
pollBookingStatus(b.suffix, "OPERATION_REQUEST_PENDING", 5);
|
||||
});
|
||||
});
|
||||
|
||||
it("operations accepts all six — the whole pool is FULLY_EXECUTED (in window)", () => {
|
||||
BOOKINGS.forEach((b) => acceptOperation(b.suffix));
|
||||
});
|
||||
|
||||
it("window closes, doc review completes — the batch reserves ALL six (they fit exactly)", () => {
|
||||
withScheduleId((id) => {
|
||||
closeBookingWindow(id);
|
||||
completeDocReview(id);
|
||||
});
|
||||
BOOKINGS.forEach((b) =>
|
||||
pollBookingStatus(b.suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
|
||||
);
|
||||
// Reservation = pay deadline + a payable invoice in the CONTRACT currency.
|
||||
BOOKINGS.forEach((b) => {
|
||||
withBooking(b.suffix, (row) => {
|
||||
expect(row.payment_deadline, `${b.suffix} pay deadline`).to.be.a("string");
|
||||
pollDb<{ currency: string }>(
|
||||
`${b.suffix} invoice`,
|
||||
`SELECT currency FROM freight.invoices
|
||||
WHERE source_id = $1 AND deleted_at IS NULL
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
[row.id],
|
||||
(inv) => inv?.currency === b.currency,
|
||||
10,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("all six pay — allocated onto the train, 54/54 wagons, window FULL and schedule finalized", () => {
|
||||
BOOKINGS.forEach((b) => {
|
||||
markPaid(b.suffix);
|
||||
pollAllocations(b.suffix, b.wagons);
|
||||
});
|
||||
withScheduleId((id) => {
|
||||
endPaymentPhase(id);
|
||||
// Full train → conclude marks FULL + DONE and auto-finalizes (DRAFT→SCHEDULED).
|
||||
pollDb<ScheduleRow>(
|
||||
"schedule FULL + DONE + finalized",
|
||||
`SELECT window_phase, booking_window_status, status
|
||||
FROM freight.train_schedules WHERE id = $1`,
|
||||
[id],
|
||||
(s) =>
|
||||
s?.booking_window_status === "FULL" &&
|
||||
s?.window_phase === "DONE" &&
|
||||
s?.status === "SCHEDULED",
|
||||
);
|
||||
db<{ n: string }>(
|
||||
`SELECT count(*) AS n FROM freight.train_schedule_bookings
|
||||
WHERE train_schedule_id = $1 AND deleted_at IS NULL`,
|
||||
[id],
|
||||
).then(({ rows }) => expect(Number(rows[0].n), "6 bookings linked").to.eq(6));
|
||||
db<{ n: string }>(
|
||||
`SELECT count(DISTINCT wba.train_set_wagon_id) AS n
|
||||
FROM freight.wagon_booking_allocations wba
|
||||
JOIN freight.train_schedule_bookings tsb
|
||||
ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1
|
||||
WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL`,
|
||||
[id],
|
||||
).then(({ rows }) => expect(Number(rows[0].n), "54 wagons allocated").to.eq(54));
|
||||
});
|
||||
});
|
||||
|
||||
it("backoffice sees the full train on the schedule detail", () => {
|
||||
cy.loginBackoffice(opsStaff);
|
||||
withScheduleId((id) => cy.visit(`/dashboard/operations/train-scheduling-v2/${id}`));
|
||||
cy.contains(/54/, { timeout: 30000 }).should("exist");
|
||||
});
|
||||
|
||||
it("GL Djibouti: gate pass granted, T1 documents uploaded for the customs bookings", () => {
|
||||
withScheduleId((id) => {
|
||||
apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/import-djibouti/gatepass-granted`)
|
||||
.its("status")
|
||||
.should("be.oneOf", [200, 201]);
|
||||
});
|
||||
CUSTOMS.forEach((suffix) => {
|
||||
withBooking(suffix, (b) => {
|
||||
glUpload(`/api/contracts/bookings/${b.id}/t1-documents`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("the train dispatches — every booking boards at the origin (IN_TRANSIT)", () => {
|
||||
withScheduleId((id) => {
|
||||
apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/dispatch`)
|
||||
.its("status")
|
||||
.should("be.oneOf", [200, 201]);
|
||||
pollDb<ScheduleRow>(
|
||||
"schedule DISPATCHED",
|
||||
`SELECT status FROM freight.train_schedules WHERE id = $1`,
|
||||
[id],
|
||||
(s) => s?.status === "DISPATCHED",
|
||||
10,
|
||||
);
|
||||
});
|
||||
BOOKINGS.forEach((b) => pollBookingStatus(b.suffix, "IN_TRANSIT", 10));
|
||||
BOOKINGS.forEach((b) =>
|
||||
withBooking(b.suffix, (row) => {
|
||||
db<{ loaded_at: string | null }>(
|
||||
`SELECT loaded_at FROM freight.bookings WHERE id = $1`,
|
||||
[row.id],
|
||||
).then(({ rows }) => expect(rows[0].loaded_at, `${b.suffix} loaded`).to.be.a("string"));
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("the train runs the corridor checkpoint by checkpoint and arrives at the terminal", () => {
|
||||
withScheduleId((id) => {
|
||||
recordAll(id);
|
||||
pollDb<ScheduleRow>(
|
||||
"schedule ARRIVED",
|
||||
`SELECT status FROM freight.train_schedules WHERE id = $1`,
|
||||
[id],
|
||||
(s) => s?.status === "ARRIVED",
|
||||
20,
|
||||
);
|
||||
});
|
||||
// Final-yard auto-arrive settles every booking + its wagons at KALITY.
|
||||
BOOKINGS.forEach((b) => pollBookingStatus(b.suffix, "ARRIVED", 20));
|
||||
withScheduleId((id) => {
|
||||
db<{ n: string }>(
|
||||
`SELECT count(*) AS n FROM freight.wagon_movements
|
||||
WHERE train_schedule_id = $1`,
|
||||
[id],
|
||||
).then(({ rows }) =>
|
||||
expect(Number(rows[0].n), "wagon movement ledger rows").to.be.at.least(54),
|
||||
);
|
||||
});
|
||||
|
||||
function recordAll(id: string) {
|
||||
// seq 0 = origin DEPARTED is stamped by dispatch; walk the rest.
|
||||
[1, 2, 3, 4].forEach((seq) => {
|
||||
apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/checkpoints`, {
|
||||
sequenceNo: seq,
|
||||
kind: "PASSED",
|
||||
})
|
||||
.its("status")
|
||||
.should("be.oneOf", [200, 201]);
|
||||
});
|
||||
apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/checkpoints`, {
|
||||
sequenceNo: 5,
|
||||
kind: "ARRIVED",
|
||||
})
|
||||
.its("status")
|
||||
.should("be.oneOf", [200, 201]);
|
||||
}
|
||||
});
|
||||
|
||||
it("GL Ethiopia runs the customs tail on every customs booking (T1 close → risk → second duty → release → final invoice)", () => {
|
||||
CUSTOMS.forEach((suffix) => {
|
||||
withBooking(suffix, (b) => {
|
||||
apiPost("superadmin@tria.com", `/api/contracts/bookings/${b.id}/t1-close`)
|
||||
.its("status")
|
||||
.should("be.oneOf", [200, 201]);
|
||||
apiPost("superadmin@tria.com", `/api/contracts/bookings/${b.id}/risk`, {
|
||||
riskLevel: "GREEN",
|
||||
})
|
||||
.its("status")
|
||||
.should("be.oneOf", [200, 201]);
|
||||
glUpload(
|
||||
`/api/contracts/bookings/${b.id}/second-duty`,
|
||||
{ dutyRequired: "false" },
|
||||
"attachment",
|
||||
);
|
||||
});
|
||||
completeBookingMilestone(suffix, "IMPORT_RELEASE_GRANTED");
|
||||
expectMilestoneDone(suffix, "T1_CLOSED");
|
||||
expectMilestoneDone(suffix, "RISK_ASSIGNED");
|
||||
expectMilestoneDone(suffix, "IMPORT_RELEASE_GRANTED");
|
||||
});
|
||||
});
|
||||
|
||||
it("GL Djibouti raises the final invoice; GL confirms the slip — import process completed", () => {
|
||||
CUSTOMS.forEach((suffix) => {
|
||||
withBooking(suffix, (b) => {
|
||||
glUpload(
|
||||
`/api/contracts/bookings/${b.id}/final-invoice`,
|
||||
{ amount: "1000", description: "e2e final invoice" },
|
||||
"file",
|
||||
);
|
||||
// The customer attaches the payment slip; only then can GL confirm.
|
||||
glUpload(`/api/contracts/bookings/${b.id}/final-invoice-slip`, {}, "file");
|
||||
apiPost(
|
||||
"superadmin@tria.com",
|
||||
`/api/contracts/bookings/${b.id}/final-invoice/confirm`,
|
||||
)
|
||||
.its("status")
|
||||
.should("be.oneOf", [200, 201]);
|
||||
});
|
||||
completeBookingMilestone(suffix, "IMPORT_PROCESS_COMPLETED");
|
||||
expectMilestoneDone(suffix, "IMPORT_PROCESS_COMPLETED");
|
||||
});
|
||||
});
|
||||
|
||||
it("the self-clearance bookings arrived clean — no customs tail required", () => {
|
||||
SELF_CLEAR.forEach((suffix) => {
|
||||
withBooking(suffix, (b) => {
|
||||
expect(b.status, `${suffix} final status`).to.eq("ARRIVED");
|
||||
});
|
||||
dbBooking(suffix).then(({ rows }) => {
|
||||
db<{ n: string }>(
|
||||
`SELECT count(*) AS n FROM freight.clearance_milestones
|
||||
WHERE booking_id = $1 AND milestone_code = 'T1_CLOSED'
|
||||
AND status = 'COMPLETED' AND deleted_at IS NULL`,
|
||||
[rows[0].id],
|
||||
).then(({ rows: ms }) =>
|
||||
expect(Number(ms[0].n), `${suffix} has no T1 tail`).to.eq(0),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
export {};
|
||||
224
e2e/freight/cypress/e2e/flows/import_split_promote.cy.ts
Normal file
224
e2e/freight/cypress/e2e/flows/import_split_promote.cy.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* IMPORT journey 3 — split offer, remainder rebooking, pay-window expiry, and
|
||||
* priority-ordered waiting-list promotion, all on one 54-wagon corridor train:
|
||||
*
|
||||
* reserved by the batch (priority order):
|
||||
* SA 40×20ft = 20w, SB 14×40ft = 14w, SD 24×20ft = 12w → 46w
|
||||
* SC 48×20ft = 24w does NOT fit whole → the batch offers a PARTIAL of the
|
||||
* remaining 8 wagons (16×20ft). SC pays → the split applies (is_split +
|
||||
* pre_split_quantities), and the customer must later rebook EXACTLY the
|
||||
* whole remainder (32×20ft) — a wrong quantity is rejected.
|
||||
* SD never pays — its pay deadline passes and it EXPIRES; the freed 12
|
||||
* wagons promote the waiting list in priority order: SW1 (12×20ft = 6w)
|
||||
* and SW2 (6×40ft = 6w) get pay windows ("payment sent"); SW3 (40×20ft =
|
||||
* 20w) never fits and expires with the day.
|
||||
*
|
||||
* Final consist: SA 20 + SB 14 + SC(split) 8 + SW1 6 + SW2 6 = 54/54.
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
resetCorridorDay,
|
||||
acceptOperation,
|
||||
bookContainers,
|
||||
closeBookingWindow,
|
||||
completeDocReview,
|
||||
createImportSchedule,
|
||||
db,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
endPaymentPhase,
|
||||
ensureCorridorRoute,
|
||||
forceReservationExpiry,
|
||||
forceWindowOpen,
|
||||
markPaid,
|
||||
pollAllocations,
|
||||
pollBookingStatus,
|
||||
pollDb,
|
||||
seedImportContract,
|
||||
settleViaGateway,
|
||||
setPriority,
|
||||
withBooking,
|
||||
withSchedule,
|
||||
type ScheduleRow,
|
||||
} from "./import-utils";
|
||||
|
||||
const DEPARTURE = departureAt(6);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
/** The split remainder is rebooked onto a LATER train on the same corridor. */
|
||||
const REMAINDER_DEPARTURE = departureAt(8);
|
||||
const REMAINDER_DAY = eatDayStr(REMAINDER_DEPARTURE);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
const ORDER = ["SA", "SB", "SD", "SC", "SW1", "SW2", "SW3"] as const;
|
||||
|
||||
describe("import: split offer, remainder rebooking, expiry + promotion", { retries: 0 }, () => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
ORDER.forEach((suffix) => seedImportContract({ suffix, reference: stampedRef(suffix) }));
|
||||
});
|
||||
|
||||
it("operations prepares the corridor train with an open first window", () => {
|
||||
ensureCorridorRoute();
|
||||
resetCorridorDay(DEPARTURE);
|
||||
resetCorridorDay(REMAINDER_DEPARTURE);
|
||||
createImportSchedule({ departure: DEPARTURE, locoPair: ["LOCO-IMP-5", "LOCO-IMP-6"] });
|
||||
withSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 45));
|
||||
});
|
||||
|
||||
it("seven customers book in the first window; operations accepts them in priority order", () => {
|
||||
const shapes: Record<string, { twenty: number; forty: number }> = {
|
||||
SA: { twenty: 40, forty: 0 },
|
||||
SB: { twenty: 0, forty: 14 },
|
||||
SD: { twenty: 24, forty: 0 },
|
||||
SC: { twenty: 48, forty: 0 },
|
||||
SW1: { twenty: 12, forty: 0 },
|
||||
SW2: { twenty: 0, forty: 6 },
|
||||
SW3: { twenty: 40, forty: 0 },
|
||||
};
|
||||
let isoSeed = 1500;
|
||||
ORDER.forEach((suffix) => {
|
||||
const s = shapes[suffix];
|
||||
bookContainers({
|
||||
suffix,
|
||||
runStamp: stamp,
|
||||
isoSeed,
|
||||
twenty: s.twenty,
|
||||
forty: s.forty,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
isoSeed += s.twenty + s.forty;
|
||||
acceptOperation(suffix);
|
||||
});
|
||||
ORDER.forEach((suffix, i) => setPriority(suffix, i + 1));
|
||||
});
|
||||
|
||||
it("the batch reserves SA/SB/SD whole and offers SC a PARTIAL for the last 8 wagons", () => {
|
||||
withSchedule(DEPARTURE, (s) => {
|
||||
closeBookingWindow(s.id);
|
||||
completeDocReview(s.id);
|
||||
});
|
||||
["SA", "SB", "SD", "SC"].forEach((suffix) =>
|
||||
pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
|
||||
);
|
||||
// SC's reservation is a partial OFFER (16×20ft of its 48).
|
||||
withBooking("SC", (b) => {
|
||||
pollDb<{ status: string }>(
|
||||
"SC open partial offer",
|
||||
`SELECT status FROM freight.booking_batch_offers
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
[b.id],
|
||||
(row) => row?.status === "OFFERED",
|
||||
10,
|
||||
);
|
||||
});
|
||||
["SW1", "SW2", "SW3"].forEach((suffix) =>
|
||||
withBooking(suffix, (b) => {
|
||||
expect(b.status, `${suffix} waiting`).to.eq("FULLY_EXECUTED");
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("SA and SB pay; SC pays its partial — the split applies and the remainder is snapshotted", () => {
|
||||
markPaid("SA");
|
||||
pollAllocations("SA", 20);
|
||||
markPaid("SB");
|
||||
pollAllocations("SB", 14);
|
||||
|
||||
// SC must settle through the real payment pipeline — only the settle path
|
||||
// applies the pending split offer (staff mark-paid allocates whole).
|
||||
settleViaGateway("SC");
|
||||
pollAllocations("SC", 8);
|
||||
withBooking("SC", (b) => {
|
||||
expect(b.is_split, "SC is split").to.eq(true);
|
||||
db<{ pre_split_quantities: { bySize?: Record<string, number> } | null; n: string }>(
|
||||
`SELECT pre_split_quantities FROM freight.bookings WHERE id = $1`,
|
||||
[b.id],
|
||||
).then(({ rows }) => {
|
||||
expect(rows[0].pre_split_quantities, "pre-split snapshot").to.not.be.null;
|
||||
});
|
||||
// The booking itself shrank to the offered 16×20ft.
|
||||
db<{ q: string }>(
|
||||
`SELECT sum(quantity) AS q FROM freight.booking_container
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL`,
|
||||
[b.id],
|
||||
).then(({ rows }) => expect(Number(rows[0].q), "SC shrank to 16 boxes").to.eq(16));
|
||||
});
|
||||
});
|
||||
|
||||
it("SD misses its pay window — EXPIRED, and the freed wagons promote SW1 + SW2 (payment sent)", () => {
|
||||
forceReservationExpiry("SD");
|
||||
// The settle promotes the waiting list in priority order into the freed 12
|
||||
// wagons: SW1 (6w) and SW2 (6w) fit; SW3 (20w) does not.
|
||||
["SW1", "SW2"].forEach((suffix) =>
|
||||
pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
|
||||
);
|
||||
["SW1", "SW2"].forEach((suffix) =>
|
||||
withBooking(suffix, (b) => {
|
||||
expect(b.payment_deadline, `${suffix} got a pay window`).to.be.a("string");
|
||||
}),
|
||||
);
|
||||
withBooking("SW3", (b) => {
|
||||
expect(b.status, "SW3 still has no seat").to.eq("FULLY_EXECUTED");
|
||||
});
|
||||
});
|
||||
|
||||
it("SW1 and SW2 pay — the train is FULL at 54; SW3 expires with the day", () => {
|
||||
markPaid("SW1");
|
||||
pollAllocations("SW1", 6);
|
||||
markPaid("SW2");
|
||||
pollAllocations("SW2", 6);
|
||||
|
||||
withSchedule(DEPARTURE, (s) => {
|
||||
endPaymentPhase(s.id);
|
||||
pollDb<ScheduleRow>(
|
||||
"window FULL + DONE",
|
||||
`SELECT window_phase, booking_window_status FROM freight.train_schedules WHERE id = $1`,
|
||||
[s.id],
|
||||
(row) => row?.booking_window_status === "FULL" && row?.window_phase === "DONE",
|
||||
);
|
||||
db<{ n: string }>(
|
||||
`SELECT count(DISTINCT wba.train_set_wagon_id) AS n
|
||||
FROM freight.wagon_booking_allocations wba
|
||||
JOIN freight.train_schedule_bookings tsb
|
||||
ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1
|
||||
WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL`,
|
||||
[s.id],
|
||||
).then(({ rows }) => expect(Number(rows[0].n), "54 wagons allocated").to.eq(54));
|
||||
});
|
||||
pollBookingStatus("SW3", "EXPIRED");
|
||||
});
|
||||
|
||||
it("the split customer must rebook EXACTLY the whole remainder — wrong quantity rejected, exact accepted", () => {
|
||||
createImportSchedule({
|
||||
departure: REMAINDER_DEPARTURE,
|
||||
locoPair: ["LOCO-IMP-7", "LOCO-IMP-8"],
|
||||
});
|
||||
withSchedule(REMAINDER_DEPARTURE, (s) => forceWindowOpen(s.id, 45));
|
||||
|
||||
// 48 booked − 16 shipped-by-split = 32×20ft outstanding. 8 ≠ 32 → rejected.
|
||||
bookContainers({
|
||||
suffix: "SC",
|
||||
runStamp: stamp,
|
||||
isoSeed: 3000,
|
||||
twenty: 8,
|
||||
scheduledDate: REMAINDER_DAY,
|
||||
expectFailure: "must take the whole remainder",
|
||||
});
|
||||
|
||||
bookContainers({
|
||||
suffix: "SC",
|
||||
runStamp: stamp,
|
||||
isoSeed: 3100,
|
||||
twenty: 32,
|
||||
scheduledDate: REMAINDER_DAY,
|
||||
});
|
||||
pollBookingStatus("SC", "OPERATION_REQUEST_PENDING", 5);
|
||||
});
|
||||
});
|
||||
|
||||
export {};
|
||||
143
e2e/freight/cypress/e2e/flows/import_waiting_expiry.cy.ts
Normal file
143
e2e/freight/cypress/e2e/flows/import_waiting_expiry.cy.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* IMPORT journey 2 — the train fills from THREE bookings; three more sit in
|
||||
* the waiting pool of the same (first) window. The three selected bookings
|
||||
* pay and allocate; when the cycle concludes with the train FULL, the three
|
||||
* waiting bookings have nowhere left to go on the day and expire.
|
||||
*
|
||||
* Wagon math (54-wagon consist):
|
||||
* selected: WA 40×20ft = 20w, WB 20×40ft = 20w, WC 28×20ft = 14w → Σ 54
|
||||
* waiting: WW1 20×20ft = 10w, WW2 10×40ft = 10w, WW3 20×20ft = 10w
|
||||
*
|
||||
* Priority order (score DESC drives the batch): WA > WB > WC > WW1 > WW2 > WW3.
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
resetCorridorDay,
|
||||
acceptOperation,
|
||||
bookContainers,
|
||||
closeBookingWindow,
|
||||
completeDocReview,
|
||||
createImportSchedule,
|
||||
db,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
endPaymentPhase,
|
||||
ensureCorridorRoute,
|
||||
forceWindowOpen,
|
||||
markPaid,
|
||||
pollAllocations,
|
||||
pollBookingStatus,
|
||||
pollDb,
|
||||
seedImportContract,
|
||||
setPriority,
|
||||
withBooking,
|
||||
withSchedule,
|
||||
type ScheduleRow,
|
||||
} from "./import-utils";
|
||||
|
||||
const DEPARTURE = departureAt(5);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
const SELECTED = [
|
||||
{ suffix: "WA", twenty: 40, forty: 0, wagons: 20 },
|
||||
{ suffix: "WB", twenty: 0, forty: 20, wagons: 20 },
|
||||
{ suffix: "WC", twenty: 28, forty: 0, wagons: 14 },
|
||||
];
|
||||
const WAITING = [
|
||||
{ suffix: "WW1", twenty: 20, forty: 0, wagons: 10 },
|
||||
{ suffix: "WW2", twenty: 0, forty: 10, wagons: 10 },
|
||||
{ suffix: "WW3", twenty: 20, forty: 0, wagons: 10 },
|
||||
];
|
||||
const ALL = [...SELECTED, ...WAITING];
|
||||
|
||||
describe("import: 3 bookings fill the train, 3 wait and expire", { retries: 0 }, () => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
ALL.forEach((b) =>
|
||||
seedImportContract({ suffix: b.suffix, reference: stampedRef(b.suffix) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("operations prepares the corridor and a 54-wagon train with an open first window", () => {
|
||||
ensureCorridorRoute();
|
||||
resetCorridorDay(DEPARTURE);
|
||||
createImportSchedule({ departure: DEPARTURE, locoPair: ["LOCO-IMP-3", "LOCO-IMP-4"] });
|
||||
withSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 45));
|
||||
});
|
||||
|
||||
it("six customers book in the first window; operations accepts all six", () => {
|
||||
let isoSeed = 500;
|
||||
ALL.forEach((b) => {
|
||||
bookContainers({
|
||||
suffix: b.suffix,
|
||||
runStamp: stamp,
|
||||
isoSeed,
|
||||
twenty: b.twenty,
|
||||
forty: b.forty,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
isoSeed += b.twenty + b.forty;
|
||||
acceptOperation(b.suffix);
|
||||
});
|
||||
ALL.forEach((b, i) => setPriority(b.suffix, i + 1));
|
||||
});
|
||||
|
||||
it("the batch selects exactly the three that fill 54 wagons; the rest keep waiting", () => {
|
||||
withSchedule(DEPARTURE, (s) => {
|
||||
closeBookingWindow(s.id);
|
||||
completeDocReview(s.id);
|
||||
});
|
||||
SELECTED.forEach((b) =>
|
||||
pollBookingStatus(b.suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
|
||||
);
|
||||
// Waiting bookings stay in the pool: FULLY_EXECUTED, no pay window opened.
|
||||
WAITING.forEach((b) =>
|
||||
withBooking(b.suffix, (row) => {
|
||||
expect(row.status, `${b.suffix} still waiting`).to.eq("FULLY_EXECUTED");
|
||||
expect(row.payment_deadline, `${b.suffix} has no pay deadline`).to.be.null;
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("the three selected bookings pay and allocate — 54/54", () => {
|
||||
SELECTED.forEach((b) => {
|
||||
markPaid(b.suffix);
|
||||
pollAllocations(b.suffix, b.wagons);
|
||||
});
|
||||
withSchedule(DEPARTURE, (s) => {
|
||||
db<{ n: string }>(
|
||||
`SELECT count(DISTINCT wba.train_set_wagon_id) AS n
|
||||
FROM freight.wagon_booking_allocations wba
|
||||
JOIN freight.train_schedule_bookings tsb
|
||||
ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1
|
||||
WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL`,
|
||||
[s.id],
|
||||
).then(({ rows }) => expect(Number(rows[0].n), "54 wagons allocated").to.eq(54));
|
||||
});
|
||||
});
|
||||
|
||||
it("the cycle concludes FULL — the three waiting bookings expire with the day", () => {
|
||||
withSchedule(DEPARTURE, (s) => {
|
||||
endPaymentPhase(s.id);
|
||||
pollDb<ScheduleRow>(
|
||||
"window FULL + DONE",
|
||||
`SELECT window_phase, booking_window_status FROM freight.train_schedules WHERE id = $1`,
|
||||
[s.id],
|
||||
(row) => row?.booking_window_status === "FULL" && row?.window_phase === "DONE",
|
||||
);
|
||||
});
|
||||
// No sibling train on the route-day can take them → the leftover day pool
|
||||
// expires (the paid three are untouched).
|
||||
WAITING.forEach((b) => pollBookingStatus(b.suffix, "EXPIRED"));
|
||||
SELECTED.forEach((b) =>
|
||||
withBooking(b.suffix, (row) => expect(row.status, `${b.suffix} stays PAID`).to.eq("PAID")),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
export {};
|
||||
126
e2e/freight/cypress/e2e/flows/import_window_reopen.cy.ts
Normal file
126
e2e/freight/cypress/e2e/flows/import_window_reopen.cy.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* IMPORT journey 4 — nobody pays in the first window cycle: every reserved
|
||||
* booking expires, the cycle concludes NOT-full and the window REOPENS for a
|
||||
* second cycle on the same train. A fresh booking arrives in cycle 2, pays,
|
||||
* and allocates — the train recovers from a dead first window.
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
resetCorridorDay,
|
||||
acceptOperation,
|
||||
bookContainers,
|
||||
closeBookingWindow,
|
||||
completeDocReview,
|
||||
createImportSchedule,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
endPaymentPhase,
|
||||
ensureCorridorRoute,
|
||||
forceReservationExpiry,
|
||||
forceWindowOpen,
|
||||
markPaid,
|
||||
pollAllocations,
|
||||
pollBookingStatus,
|
||||
pollDb,
|
||||
seedImportContract,
|
||||
withBooking,
|
||||
withSchedule,
|
||||
type ScheduleRow,
|
||||
} from "./import-utils";
|
||||
|
||||
const DEPARTURE = departureAt(7);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
describe("import: dead first cycle — expire all, reopen, book again", { retries: 0 }, () => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
["RA", "RB", "RC"].forEach((suffix) =>
|
||||
seedImportContract({ suffix, reference: stampedRef(suffix) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("operations prepares the corridor train — first window opens (cycle 1)", () => {
|
||||
ensureCorridorRoute();
|
||||
resetCorridorDay(DEPARTURE);
|
||||
createImportSchedule({ departure: DEPARTURE, locoPair: ["LOCO-IMP-9", "LOCO-IMP-10"] });
|
||||
withSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 45));
|
||||
withSchedule(DEPARTURE, (s) => expect(s.booking_cycle_no, "cycle 1").to.eq(1));
|
||||
});
|
||||
|
||||
it("two customers book and are reserved in cycle 1", () => {
|
||||
bookContainers({
|
||||
suffix: "RA",
|
||||
runStamp: stamp,
|
||||
isoSeed: 4000,
|
||||
twenty: 40,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
bookContainers({
|
||||
suffix: "RB",
|
||||
runStamp: stamp,
|
||||
isoSeed: 4100,
|
||||
forty: 20,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
["RA", "RB"].forEach((suffix) => acceptOperation(suffix));
|
||||
withSchedule(DEPARTURE, (s) => {
|
||||
closeBookingWindow(s.id);
|
||||
completeDocReview(s.id);
|
||||
});
|
||||
["RA", "RB"].forEach((suffix) =>
|
||||
pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("nobody pays — both reservations expire and the cycle concludes not-full", () => {
|
||||
["RA", "RB"].forEach((suffix) => forceReservationExpiry(suffix));
|
||||
withSchedule(DEPARTURE, (s) => {
|
||||
endPaymentPhase(s.id);
|
||||
// Not full + departure days away → the engine schedules a fresh cycle.
|
||||
pollDb<ScheduleRow>(
|
||||
"window reopens (PRE_WINDOW, cycle 2 pending)",
|
||||
`SELECT window_phase FROM freight.train_schedules WHERE id = $1`,
|
||||
[s.id],
|
||||
(row) => row?.window_phase === "PRE_WINDOW",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("the second window opens (cycle 2) and a fresh booking pays and allocates", () => {
|
||||
withSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 45));
|
||||
withSchedule(DEPARTURE, (s) => expect(s.booking_cycle_no, "cycle 2").to.eq(2));
|
||||
|
||||
bookContainers({
|
||||
suffix: "RC",
|
||||
runStamp: stamp,
|
||||
isoSeed: 4200,
|
||||
twenty: 20,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
acceptOperation("RC");
|
||||
withSchedule(DEPARTURE, (s) => {
|
||||
closeBookingWindow(s.id);
|
||||
completeDocReview(s.id);
|
||||
});
|
||||
pollBookingStatus("RC", ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||||
markPaid("RC");
|
||||
pollAllocations("RC", 10);
|
||||
|
||||
// The dead cycle's corpses stay dead; the recovery booking is on the train.
|
||||
["RA", "RB"].forEach((suffix) =>
|
||||
withBooking(suffix, (b) => expect(b.status, `${suffix} stays expired`).to.eq("EXPIRED")),
|
||||
);
|
||||
withSchedule(DEPARTURE, (s) => {
|
||||
withBooking("RC", (b) => {
|
||||
expect(b.train_schedule_id, "RC rides the reopened train").to.eq(s.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
export {};
|
||||
634
e2e/freight/cypress/e2e/flows/segment_weight.cy.ts
Normal file
634
e2e/freight/cypress/e2e/flows/segment_weight.cy.ts
Normal file
@@ -0,0 +1,634 @@
|
||||
/**
|
||||
* Segment weight & tolerance journeys — per-edge capacity on one corridor
|
||||
* (Mojo Dry Port → Dire Dawa Yard → Nagad Terminal), two dedicated
|
||||
* trains departing the same day (seed-segment-weight.sql):
|
||||
*
|
||||
* TRN-SEG-W "tolerance train" — 240T pull; one loco carries a 90T overage
|
||||
* tolerance, the second has NONE CONFIGURED (the S-2026-00024 regression
|
||||
* pair: the set's tolerance must stay 90, not collapse to 0).
|
||||
*
|
||||
* 1. 130T wheat Mojo→Djibouti → 179.6T gross (2 CW4) on both legs
|
||||
* 2. intercity 2×20ft VGM 24 Mojo→Dire → 70.4T gross (1 NW5); the shared
|
||||
* Mojo→Dire leg hits 250T — OVER the 240T base, inside the 330T
|
||||
* ceiling. Wagon allocation must succeed (allocation is what silently
|
||||
* failed on S-2026-00024: PAID + SCHEDULED, zero allocations).
|
||||
* 3. a second identical ride-along → 320.4T, still inside the ceiling —
|
||||
* the tolerance admits whole bookings repeatedly until spent
|
||||
* 4. the schedule detail strip shows the per-leg gross vs the ceiling
|
||||
*
|
||||
* TRN-SEG-F "border-full train" — 200T pull, no tolerance, 4 NW5.
|
||||
*
|
||||
* 5. 8×20ft export boarding at DIRE (sub-corridor) commits all 4 wagons
|
||||
* on the border edge (W's border edge only has 2 free, so FCFS lands
|
||||
* it on F) → the window goes FULL for the trade direction
|
||||
* 6. the FULL train still accepts an intercity ride-along Mojo→Dire on
|
||||
* its free home leg (the old whole-train sum — 169.6 + 70.4 = 240T >
|
||||
* 200T pull — rejected the accept outright; per-edge math admits it).
|
||||
* Wagon ALLOCATION of that shared wagon is a known gap: physical
|
||||
* pinning is slot-exclusive, so the test asserts accept + link only.
|
||||
*
|
||||
* Contracts are seeded FULLY_EXECUTED straight into SQL (stamped references,
|
||||
* re-runnable) — contract lifecycle is covered by the other flow specs; this
|
||||
* spec is about the scheduling engine. Run against a fresh e2e stack: the
|
||||
* trains' capacity math assumes empty consists.
|
||||
*
|
||||
* Sequential steps of one journey — retries off (steps are not idempotent).
|
||||
*/
|
||||
|
||||
const customer = "user@gmail.com";
|
||||
const companyTin = "0102030405"; // seed-company.sql
|
||||
const opsStaff = "operation@edr.local";
|
||||
|
||||
// Own corridor (…→ Nagad, not Djibouti Port): other specs schedule TRN-E2E-1
|
||||
// on the Djibouti Port route, and an earlier-departing same-day train there
|
||||
// would steal these FCFS bookings.
|
||||
const ORIGIN_YARD = "Mojo Dry Port";
|
||||
const MID_YARD = "Dire Dawa Yard";
|
||||
const PORT_YARD = "Nagad Terminal, Djibouti";
|
||||
const TRAIN_W = "TRN-SEG-W";
|
||||
const TRAIN_F = "TRN-SEG-F";
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const isoNumber = (prefix: string, offset: number) =>
|
||||
`${prefix}${String(Number(stamp.slice(-7)) + offset).padStart(7, "0")}`;
|
||||
|
||||
// A ONE_TIME contract is spent after one booking, so every run seeds a fresh
|
||||
// set with stamped references. Lookups go by SUFFIX + newest row: Cypress
|
||||
// re-evaluates the spec bundle on cross-origin reloads, so a module-scope
|
||||
// stamp drifts between tests and must never key a lookup.
|
||||
const REF = {
|
||||
exportMojo: "EXP1",
|
||||
exportDire: "EXP2",
|
||||
ic1: "IC1",
|
||||
ic2: "IC2",
|
||||
ic3: "IC3",
|
||||
} as const;
|
||||
const stampedRef = (suffix: string) => `CTR-SEG-${stamp}-${suffix}`;
|
||||
|
||||
/** Both trains depart just past the 24h export lead — windows open in minutes. */
|
||||
const DEPART_W = new Date(Date.now() + 24 * 3_600_000 + 4 * 60_000);
|
||||
const DEPART_F = new Date(Date.now() + 24 * 3_600_000 + 7 * 60_000);
|
||||
|
||||
const apiUrl = () => Cypress.env("apiUrl") as string;
|
||||
|
||||
/** Seed one FULLY_EXECUTED ONE_TIME contract (contract + route + cargo scope). */
|
||||
function seedContract(opts: {
|
||||
suffix: string;
|
||||
reference: string;
|
||||
direction: "EXPORT" | "DOMESTIC";
|
||||
freight: "CONTAINER" | "BULK";
|
||||
originCode: string;
|
||||
destCode: string;
|
||||
}) {
|
||||
cy.task("db:query", {
|
||||
sql: `WITH c AS (
|
||||
INSERT INTO freight.contracts
|
||||
(reference, company_id, company_profile_id, contract_kind,
|
||||
trade_direction, freight_type, service_type_id, payment_currency,
|
||||
status, fully_executed_at, contract_valid_from,
|
||||
contract_valid_until, contract_summary)
|
||||
SELECT $1, comp.id,
|
||||
-- bookings.company_profile_id is NOT NULL and inherits from
|
||||
-- the contract: exporter profile for exports, any active
|
||||
-- profile otherwise.
|
||||
(SELECT p.id FROM freight.company_profiles p
|
||||
WHERE p.company_id = comp.id AND p.deleted_at IS NULL
|
||||
ORDER BY CASE
|
||||
WHEN $2 = 'EXPORT' AND p.type = 'exporter' THEN 0
|
||||
ELSE 1
|
||||
END
|
||||
LIMIT 1),
|
||||
'ONE_TIME', $2, $3,
|
||||
(SELECT st.id FROM freight.service_types st ORDER BY st.created_at LIMIT 1),
|
||||
'ETB', 'FULLY_EXECUTED', now(), now() - interval '1 day',
|
||||
now() + interval '60 days', 'E2E segment-weight fixture contract'
|
||||
FROM freight.companies comp
|
||||
WHERE comp.tin = $4
|
||||
-- before() re-runs on Cypress reloads: skip when this run
|
||||
-- already seeded a fresh, still-unbooked contract for the
|
||||
-- suffix, so lookups keep pointing at one stable row.
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.contracts c2
|
||||
WHERE c2.reference LIKE 'CTR-SEG-%-' || $7
|
||||
AND c2.deleted_at IS NULL
|
||||
AND c2.created_at > now() - interval '15 minutes'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.bookings b2 WHERE b2.contract_id = c2.id
|
||||
)
|
||||
)
|
||||
RETURNING id
|
||||
), r AS (
|
||||
INSERT INTO freight.contract_routes
|
||||
(contract_id, origin_yard_id, destination_yard_id, sort_order)
|
||||
SELECT c.id, o.id, d.id, 0 FROM c
|
||||
JOIN freight.yards o ON o.code = $5
|
||||
JOIN freight.yards d ON d.code = $6
|
||||
RETURNING id
|
||||
)
|
||||
INSERT INTO freight.contract_cargo_scope
|
||||
(contract_id, container_size, cargo_type_id, cargo_free_text)
|
||||
SELECT c.id,
|
||||
CASE WHEN $3 = 'CONTAINER' THEN '20ft' END,
|
||||
CASE WHEN $3 = 'BULK' THEN
|
||||
(SELECT ct.id FROM freight.cargo_types ct WHERE ct.code = 'E2E_WHEAT' LIMIT 1)
|
||||
END,
|
||||
'E2E segment-weight cargo'
|
||||
FROM c`,
|
||||
params: [
|
||||
opts.reference,
|
||||
opts.direction,
|
||||
opts.freight,
|
||||
companyTin,
|
||||
opts.originCode,
|
||||
opts.destCode,
|
||||
opts.suffix,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** Newest seeded contract for a suffix — stamp-agnostic (see REF). */
|
||||
function dbContractId(suffix: string) {
|
||||
return cy
|
||||
.task<{ rows: Array<{ id: string }> }>("db:query", {
|
||||
sql: `SELECT id FROM freight.contracts
|
||||
WHERE reference LIKE 'CTR-SEG-%-' || $1
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
params: [suffix],
|
||||
})
|
||||
.then(({ rows }) => {
|
||||
expect(rows, `seeded contract *-${suffix}`).to.have.length(1);
|
||||
return cy.wrap(rows[0].id, { log: false });
|
||||
});
|
||||
}
|
||||
|
||||
type BookingRow = {
|
||||
id: string;
|
||||
reference: string;
|
||||
status: string;
|
||||
scheduling_status: string;
|
||||
train_schedule_id: string | null;
|
||||
payment_deadline: string | null;
|
||||
};
|
||||
|
||||
/** The (only) booking under this run's seeded contract for a suffix. */
|
||||
function withBooking(suffix: string, fn: (b: BookingRow) => void) {
|
||||
cy.task<{ rows: BookingRow[] }>("db:query", {
|
||||
sql: `SELECT b.id, b.reference, b.status, b.scheduling_status,
|
||||
b.train_schedule_id, b.payment_deadline
|
||||
FROM freight.bookings b
|
||||
JOIN freight.contracts ct ON ct.id = b.contract_id
|
||||
WHERE ct.reference LIKE 'CTR-SEG-%-' || $1
|
||||
ORDER BY b.created_at DESC LIMIT 1`,
|
||||
params: [suffix],
|
||||
}).then(({ rows }) => {
|
||||
expect(rows, `booking under *-${suffix}`).to.have.length(1);
|
||||
fn(rows[0]);
|
||||
});
|
||||
}
|
||||
|
||||
type ScheduleRow = {
|
||||
id: string;
|
||||
booking_window_status: string;
|
||||
window_closes_at: string;
|
||||
};
|
||||
|
||||
/** The live schedule riding a given built train on the NAGAD corridor. */
|
||||
function dbScheduleFor(trainCode: string) {
|
||||
return cy.task<{ rows: ScheduleRow[] }>("db:query", {
|
||||
sql: `SELECT ts.id, ts.booking_window_status, ts.window_closes_at
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.train_sets se ON se.id = ts.train_set_id
|
||||
JOIN freight.trains t ON t.id = se.train_id
|
||||
JOIN freight.yards d ON d.id = ts.destination_station_id
|
||||
WHERE t.code = $1 AND d.code = 'NAGAD' AND ts.deleted_at IS NULL
|
||||
ORDER BY ts.created_at DESC LIMIT 1`,
|
||||
params: [trainCode],
|
||||
});
|
||||
}
|
||||
|
||||
function withSchedule(trainCode: string, fn: (s: ScheduleRow) => void) {
|
||||
dbScheduleFor(trainCode).then(({ rows }) => {
|
||||
expect(rows, `schedule for ${trainCode}`).to.have.length(1);
|
||||
fn(rows[0]);
|
||||
});
|
||||
}
|
||||
|
||||
function fill(label: string | RegExp, value: string) {
|
||||
cy.contains("label", label)
|
||||
.invoke("attr", "for")
|
||||
.then((id) => {
|
||||
cy.get(`[id="${id}"]`).clear({ force: true }).type(value, { force: true });
|
||||
});
|
||||
}
|
||||
|
||||
/** Pick the departure day on the booking form's inline calendar. */
|
||||
function pickShipmentDay(date: Date) {
|
||||
cy.contains(/available day/, { timeout: 30000 }).should("exist");
|
||||
const day = String(date.getDate());
|
||||
cy.get("button:not(:disabled)", { timeout: 15000 })
|
||||
.contains(new RegExp(`^${day}$`))
|
||||
.click({ force: true });
|
||||
}
|
||||
|
||||
/** Create one schedule from a built train, departing at the given moment. */
|
||||
function createSchedule(trainCode: string, departure: Date) {
|
||||
cy.visit("/dashboard/operations/train-scheduling-v2");
|
||||
cy.contains("button", "New schedule", { timeout: 20000 }).click();
|
||||
cy.contains("Create train schedule", { timeout: 15000 }).should("be.visible");
|
||||
cy.mantineSelect(/^Route$/, /Nagad/);
|
||||
const local = new Date(departure.getTime() - departure.getTimezoneOffset() * 60000)
|
||||
.toISOString()
|
||||
.slice(0, 16);
|
||||
cy.get('.mantine-Modal-content input[type="datetime-local"]')
|
||||
.clear({ force: true })
|
||||
.type(local, { force: true });
|
||||
cy.mantineSelect(/^Train$/, new RegExp(trainCode));
|
||||
cy.get(".mantine-Modal-content").contains("button", "Create").click();
|
||||
cy.location("pathname", { timeout: 30000 }).should(
|
||||
"match",
|
||||
/\/dashboard\/operations\/train-scheduling-v2\/.+/,
|
||||
);
|
||||
}
|
||||
|
||||
/** Ops accepts a booking's operation request from the booking-requests page. */
|
||||
function acceptOperationRequest(contractRef: string) {
|
||||
cy.loginBackoffice(opsStaff);
|
||||
withBooking(contractRef, (b) => cy.visit(`/dashboard/booking-requests/${b.id}`));
|
||||
cy.contains("button", /Accept operation|^Accept$/, { timeout: 20000 }).click();
|
||||
cy.contains("Accept operation request?", { timeout: 15000 }).should("be.visible");
|
||||
cy.get(".mantine-Modal-content").contains("button", /^Accept$/).click();
|
||||
cy.get(".mantine-Modal-content", { timeout: 30000 }).should("not.exist");
|
||||
}
|
||||
|
||||
/** Book 2×20ft VGM 24T each under a seeded DOMESTIC contract (48T cargo, 1 NW5). */
|
||||
function bookIntercityPair(contractRef: string, isoOffset: number) {
|
||||
cy.loginPortal(customer);
|
||||
dbContractId(contractRef).then((id) => cy.visitPortal(`/contracts/${id}/bookings/new`));
|
||||
cy.contains("New Shipment Booking", { timeout: 20000 }).should("be.visible");
|
||||
|
||||
fill(/^Quantity/, "2");
|
||||
cy.get('input[placeholder*="MSCU"]', { timeout: 15000 }).should("have.length", 2);
|
||||
cy.get('input[placeholder*="MSCU"]').eq(0).type(isoNumber("MSCU", isoOffset));
|
||||
cy.get('input[placeholder*="MSCU"]').eq(1).type(isoNumber("TCLU", isoOffset + 1));
|
||||
cy.get('input[placeholder*="24.5"]').each(($input) => {
|
||||
cy.wrap($input).clear({ force: true }).type("24", { force: true });
|
||||
});
|
||||
cy.contains("Shipment day").should("not.exist");
|
||||
|
||||
cy.contains("button", "Review price & book").should("not.be.disabled").click();
|
||||
cy.contains("Confirm shipment price", { timeout: 30000 }).should("be.visible");
|
||||
cy.contains("button", "Confirm & book").click();
|
||||
cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/);
|
||||
}
|
||||
|
||||
/** Accept a waiting intercity booking from a schedule's ride-along panel. */
|
||||
function acceptRideAlong(trainCode: string, contractRef: string) {
|
||||
cy.loginBackoffice(opsStaff);
|
||||
withSchedule(trainCode, (s) =>
|
||||
cy.visit(`/dashboard/operations/train-scheduling-v2/${s.id}`),
|
||||
);
|
||||
cy.contains('[role="tab"]', "Workspace", { timeout: 30000 }).click();
|
||||
cy.contains("Intercity ride-along", { timeout: 30000 }).should("exist");
|
||||
withBooking(contractRef, (b) => {
|
||||
cy.contains("tr", b.reference, { timeout: 30000 })
|
||||
.find('input[type="checkbox"]')
|
||||
.check({ force: true });
|
||||
cy.contains("button", /Accept .*onto this train/).click();
|
||||
cy.contains("Awaiting payment", { timeout: 30000 }).should("exist");
|
||||
});
|
||||
}
|
||||
|
||||
/** Staff mark-paid (no mounted UI button), then wait for wagon allocation. */
|
||||
function markPaidAndAssertAllocated(contractRef: string) {
|
||||
withBooking(contractRef, (b) => {
|
||||
cy.apiLogin(opsStaff).then(({ token }) => {
|
||||
cy.request({
|
||||
method: "POST",
|
||||
url: `${apiUrl()}/api/train-scheduling/bookings/${b.id}/mark-paid`,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.its("status")
|
||||
.should("be.oneOf", [200, 201]);
|
||||
});
|
||||
});
|
||||
withBooking(contractRef, (b) => {
|
||||
expect(b.status).to.eq("PAID");
|
||||
expect(b.scheduling_status).to.eq("SCHEDULED");
|
||||
|
||||
// Wagon allocation runs async after allocate() — poll for its rows.
|
||||
// Zero allocations with a PAID/SCHEDULED booking is exactly the
|
||||
// S-2026-00024 failure shape this spec guards against.
|
||||
const waitForAllocation = (attempt: number) => {
|
||||
cy.task<{ rows: Array<{ n: string }> }>("db:query", {
|
||||
sql: `SELECT count(*) AS n FROM freight.wagon_booking_allocations
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL`,
|
||||
params: [b.id],
|
||||
}).then(({ rows }) => {
|
||||
if (Number(rows[0].n) > 0) return;
|
||||
expect(attempt, `wagon allocations for ${contractRef}`).to.be.lessThan(20);
|
||||
cy.wait(2000).then(() => waitForAllocation(attempt + 1));
|
||||
});
|
||||
};
|
||||
waitForAllocation(0);
|
||||
});
|
||||
}
|
||||
|
||||
describe(
|
||||
"segment weight: per-edge caps, loco tolerance, directional FULL",
|
||||
{ retries: 0 },
|
||||
() => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-intercity.sql");
|
||||
cy.task("db:seedFile", "seed-export.sql");
|
||||
cy.task("db:seedFile", "seed-segment-weight.sql");
|
||||
seedContract({
|
||||
suffix: REF.exportMojo,
|
||||
reference: stampedRef(REF.exportMojo),
|
||||
direction: "EXPORT",
|
||||
freight: "BULK",
|
||||
originCode: "MOJO",
|
||||
destCode: "NAGAD",
|
||||
});
|
||||
seedContract({
|
||||
suffix: REF.exportDire,
|
||||
reference: stampedRef(REF.exportDire),
|
||||
direction: "EXPORT",
|
||||
freight: "CONTAINER",
|
||||
originCode: "DIRE_DAWA",
|
||||
destCode: "NAGAD",
|
||||
});
|
||||
for (const ref of [REF.ic1, REF.ic2, REF.ic3]) {
|
||||
seedContract({
|
||||
suffix: ref,
|
||||
reference: stampedRef(ref),
|
||||
direction: "DOMESTIC",
|
||||
freight: "CONTAINER",
|
||||
originCode: "MOJO",
|
||||
destCode: "DIRE_DAWA",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── Infrastructure ───────────────────────────────────────────────────────
|
||||
|
||||
it("operations ensures the export route exists", () => {
|
||||
cy.loginBackoffice(opsStaff);
|
||||
cy.task<{ rows: Array<{ n: string }> }>("db:query", {
|
||||
sql: `SELECT count(*) AS n
|
||||
FROM freight.routes r
|
||||
JOIN freight.yards o ON o.id = r.origin_yard_id AND o.code = 'MOJO'
|
||||
JOIN freight.yards d ON d.id = r.destination_yard_id AND d.code = 'NAGAD'
|
||||
WHERE r.deleted_at IS NULL`,
|
||||
}).then(({ rows }) => {
|
||||
if (Number(rows[0].n) > 0) return;
|
||||
|
||||
cy.visit("/dashboard/routes");
|
||||
cy.contains("button", "Add route", { timeout: 20000 }).click();
|
||||
cy.contains("Add Route", { timeout: 15000 }).should("be.visible");
|
||||
cy.get(".mantine-Modal-content").contains("button", "Add milestone").click();
|
||||
const pickYard = (index: number, yard: string) => {
|
||||
cy.get('.mantine-Modal-content input[placeholder="Select yard"]')
|
||||
.eq(index)
|
||||
.click({ force: true });
|
||||
cy.get('[role="option"]:visible').contains(yard).click();
|
||||
};
|
||||
pickYard(0, ORIGIN_YARD);
|
||||
pickYard(1, MID_YARD);
|
||||
pickYard(2, PORT_YARD);
|
||||
cy.get(".mantine-Modal-content").contains("button", "Save").click();
|
||||
});
|
||||
});
|
||||
|
||||
it("operations schedules both segment trains — same route, same day", () => {
|
||||
cy.loginBackoffice(opsStaff);
|
||||
// Two different physical trains may share a route+day (the guard only
|
||||
// blocks the SAME train twice); export windows are per-schedule.
|
||||
dbScheduleFor(TRAIN_W).then(({ rows }) => {
|
||||
if (rows.length === 0) createSchedule(TRAIN_W, DEPART_W);
|
||||
});
|
||||
dbScheduleFor(TRAIN_F).then(({ rows }) => {
|
||||
if (rows.length === 0) createSchedule(TRAIN_F, DEPART_F);
|
||||
});
|
||||
|
||||
// The export window opens at departure − 24h CLAMPED into the booking
|
||||
// desk hours (8–17 EAT), and the engine can take minutes to advance a
|
||||
// second same-day train. Force both windows OPEN directly — the spec
|
||||
// arranges window state, it does not test the window engine.
|
||||
for (const trainCode of [TRAIN_W, TRAIN_F]) {
|
||||
dbScheduleFor(trainCode).then(({ rows }) => {
|
||||
expect(rows, `schedule for ${trainCode}`).to.have.length(1);
|
||||
cy.task("db:query", {
|
||||
sql: `UPDATE freight.train_schedules
|
||||
SET window_opens_at = LEAST(window_opens_at, now()),
|
||||
window_phase = 'OPEN',
|
||||
booking_window_status = 'OPEN'
|
||||
WHERE id = $1 AND booking_window_status <> 'FULL'`,
|
||||
params: [rows[0].id],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Belt-and-braces: confirm the engine keeps them OPEN.
|
||||
const waitForOpen = (trainCode: string, attempt: number) => {
|
||||
dbScheduleFor(trainCode).then(({ rows }) => {
|
||||
expect(rows, `schedule for ${trainCode}`).to.have.length(1);
|
||||
if (rows[0].booking_window_status === "OPEN") return;
|
||||
expect(attempt, `${trainCode} window OPEN`).to.be.lessThan(60);
|
||||
cy.wait(10000).then(() => waitForOpen(trainCode, attempt + 1));
|
||||
});
|
||||
};
|
||||
waitForOpen(TRAIN_W, 0);
|
||||
waitForOpen(TRAIN_F, 0);
|
||||
});
|
||||
|
||||
// ── Tolerance train (TRN-SEG-W) ──────────────────────────────────────────
|
||||
|
||||
it("customer books 130T of wheat Mojo→Djibouti", () => {
|
||||
cy.loginPortal(customer);
|
||||
dbContractId(REF.exportMojo).then((id) =>
|
||||
cy.visitPortal(`/contracts/${id}/bookings/new`),
|
||||
);
|
||||
cy.contains("New Shipment Booking", { timeout: 20000 }).should("be.visible");
|
||||
fill(/^Quantity \(tons\)/, "130");
|
||||
pickShipmentDay(DEPART_W);
|
||||
cy.contains("button", "Review price & book").should("not.be.disabled").click();
|
||||
cy.contains("Confirm shipment price", { timeout: 30000 }).should("be.visible");
|
||||
cy.contains("button", "Confirm & book").click();
|
||||
cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/);
|
||||
});
|
||||
|
||||
it("the wheat lands on the tolerance train and allocates", () => {
|
||||
acceptOperationRequest(REF.exportMojo);
|
||||
|
||||
// FCFS picks the earliest fitting train of the day — the W train.
|
||||
withSchedule(TRAIN_W, (s) => {
|
||||
withBooking(REF.exportMojo, (b) => {
|
||||
expect(b.status).to.eq("SELECTED_FOR_BATCH");
|
||||
expect(b.train_schedule_id, "reserved on the tolerance train").to.eq(s.id);
|
||||
});
|
||||
});
|
||||
markPaidAndAssertAllocated(REF.exportMojo);
|
||||
});
|
||||
|
||||
it("customer books the first intercity pair Mojo→Dire", () => {
|
||||
bookIntercityPair(REF.ic1, 0);
|
||||
});
|
||||
|
||||
it("the ride-along boards the shared leg through the overage tolerance", () => {
|
||||
// Mojo→Dire now carries 179.6T (wheat). Adding 70.4T (2×20ft VGM 24 on
|
||||
// one NW5) puts the leg at 250T — over the 240T base, inside 240+90.
|
||||
// Before the tolerance fix the second loco's NULL tolerance zeroed the
|
||||
// set and this exact allocation failed at "3500" scale.
|
||||
acceptOperationRequest(REF.ic1);
|
||||
acceptRideAlong(TRAIN_W, REF.ic1);
|
||||
|
||||
withSchedule(TRAIN_W, (s) => {
|
||||
withBooking(REF.ic1, (b) => {
|
||||
expect(b.status).to.eq("SELECTED_FOR_BATCH");
|
||||
expect(b.train_schedule_id).to.eq(s.id);
|
||||
expect(new Date(b.payment_deadline!).getTime()).to.be.at.most(
|
||||
new Date(s.window_closes_at).getTime(),
|
||||
);
|
||||
});
|
||||
});
|
||||
markPaidAndAssertAllocated(REF.ic1);
|
||||
});
|
||||
|
||||
it("customer books the second intercity pair Mojo→Dire", () => {
|
||||
bookIntercityPair(REF.ic2, 2);
|
||||
});
|
||||
|
||||
it("a second ride-along still fits whole — tolerance spends per booking, not once", () => {
|
||||
// 250T + 70.4T = 320.4T on Mojo→Dire — still under the 330T ceiling.
|
||||
acceptOperationRequest(REF.ic2);
|
||||
acceptRideAlong(TRAIN_W, REF.ic2);
|
||||
markPaidAndAssertAllocated(REF.ic2);
|
||||
|
||||
// The border edge (Dire→Djibouti) still has room, so the tolerance
|
||||
// train's window must NOT be FULL — fullness is directional.
|
||||
withSchedule(TRAIN_W, (s) => {
|
||||
expect(s.booking_window_status, "W window stays open").to.eq("OPEN");
|
||||
});
|
||||
});
|
||||
|
||||
it("the schedule detail strip shows per-leg gross against the tolerance ceiling", () => {
|
||||
cy.loginBackoffice(opsStaff);
|
||||
withSchedule(TRAIN_W, (s) =>
|
||||
cy.visit(`/dashboard/operations/train-scheduling-v2/${s.id}`),
|
||||
);
|
||||
// Mojo→Dire: 179.6 (wheat) + 70.4 + 70.4 (ride-alongs) = 320.4T gross;
|
||||
// ceiling = 240 base + 90 tolerance = 330T (the weakest CONFIGURED
|
||||
// tolerance governs — the second loco has none set).
|
||||
cy.contains("320.4 / 330 T gross", { timeout: 30000 }).should("exist");
|
||||
// Border leg carries only the wheat.
|
||||
cy.contains("179.6 / 330 T gross").should("exist");
|
||||
});
|
||||
|
||||
// ── Border-full train (TRN-SEG-F) ────────────────────────────────────────
|
||||
|
||||
it("customer books an 8×20ft export from the MID yard", () => {
|
||||
cy.loginPortal(customer);
|
||||
dbContractId(REF.exportDire).then((id) =>
|
||||
cy.visitPortal(`/contracts/${id}/bookings/new`),
|
||||
);
|
||||
cy.contains("New Shipment Booking", { timeout: 20000 }).should("be.visible");
|
||||
fill(/^Quantity/, "8");
|
||||
cy.get('input[placeholder*="MSCU"]', { timeout: 15000 }).should("have.length", 8);
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
cy.get('input[placeholder*="MSCU"]').eq(i).type(isoNumber("MSCU", 10 + i));
|
||||
}
|
||||
cy.get('input[placeholder*="24.5"]').each(($input) => {
|
||||
cy.wrap($input).clear({ force: true }).type("10", { force: true });
|
||||
});
|
||||
pickShipmentDay(DEPART_F);
|
||||
cy.contains("button", "Review price & book").should("not.be.disabled").click();
|
||||
cy.contains("Confirm shipment price", { timeout: 30000 }).should("be.visible");
|
||||
cy.contains("button", "Confirm & book").click();
|
||||
cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/);
|
||||
});
|
||||
|
||||
it("the export fills the border edge — the window goes FULL for the direction", () => {
|
||||
acceptOperationRequest(REF.exportDire);
|
||||
|
||||
// 8×20ft = 4 wagons. W's border edge has only 2 wagons free (the wheat
|
||||
// holds the other 2), so FCFS lands this Dire→Djibouti sub-corridor
|
||||
// booking on the F train — all 4 of its wagons, but only past Dire.
|
||||
withSchedule(TRAIN_F, (s) => {
|
||||
withBooking(REF.exportDire, (b) => {
|
||||
expect(b.status).to.eq("SELECTED_FOR_BATCH");
|
||||
expect(b.train_schedule_id, "reserved on the border-full train").to.eq(s.id);
|
||||
});
|
||||
});
|
||||
markPaidAndAssertAllocated(REF.exportDire);
|
||||
|
||||
// Every wagon on the border edge is committed: the train is FULL for
|
||||
// its trade direction even though Mojo→Dire runs completely empty.
|
||||
const waitForFull = (attempt: number) => {
|
||||
dbScheduleFor(TRAIN_F).then(({ rows }) => {
|
||||
if (rows[0]?.booking_window_status === "FULL") return;
|
||||
expect(attempt, "F window FULL").to.be.lessThan(20);
|
||||
cy.wait(3000).then(() => waitForFull(attempt + 1));
|
||||
});
|
||||
};
|
||||
waitForFull(0);
|
||||
});
|
||||
|
||||
it("customer books the third intercity pair Mojo→Dire", () => {
|
||||
bookIntercityPair(REF.ic3, 4);
|
||||
});
|
||||
|
||||
it("the FULL train still accepts and allocates an intercity ride-along on its free leg", () => {
|
||||
// Mojo→Dire on the F train is empty (the export boards at Dire): the
|
||||
// ride-along uses the SAME wagons there and alights before they load.
|
||||
// The old whole-train sum — 169.6 + 70.4 = 240T > 200T pull — rejected
|
||||
// this; per-edge math sees 70.4T on Mojo→Dire and 169.6T on the border,
|
||||
// both within the cap. The FULL flag closes the export window only.
|
||||
acceptOperationRequest(REF.ic3);
|
||||
acceptRideAlong(TRAIN_F, REF.ic3);
|
||||
|
||||
withSchedule(TRAIN_F, (s) => {
|
||||
withBooking(REF.ic3, (b) => {
|
||||
expect(b.status).to.eq("SELECTED_FOR_BATCH");
|
||||
expect(b.train_schedule_id, "accepted onto the FULL train").to.eq(s.id);
|
||||
});
|
||||
});
|
||||
|
||||
// Mark paid: PAID + SCHEDULED + linked. Wagon allocation is asserted
|
||||
// only as far as today's model supports: physical wagon pinning is
|
||||
// slot-exclusive (one wagon serves ONE slot), so the same wagon cannot
|
||||
// yet be pinned to the intercity's Mojo→Dire slot AND the export's
|
||||
// Dire→Nagad slot even though the per-edge budget admits both. KNOWN
|
||||
// GAP — when wagon↔slot pinning becomes leg-aware, restore
|
||||
// markPaidAndAssertAllocated(REF.ic3) here.
|
||||
withBooking(REF.ic3, (b) => {
|
||||
cy.apiLogin(opsStaff).then(({ token }) => {
|
||||
cy.request({
|
||||
method: "POST",
|
||||
url: `${apiUrl()}/api/train-scheduling/bookings/${b.id}/mark-paid`,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.its("status")
|
||||
.should("be.oneOf", [200, 201]);
|
||||
});
|
||||
});
|
||||
withBooking(REF.ic3, (b) => {
|
||||
expect(b.status).to.eq("PAID");
|
||||
expect(b.scheduling_status).to.eq("SCHEDULED");
|
||||
cy.task<{ rows: Array<{ n: string }> }>("db:query", {
|
||||
sql: `SELECT count(*) AS n FROM freight.train_schedule_bookings
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL`,
|
||||
params: [b.id],
|
||||
}).then(({ rows }) => {
|
||||
expect(Number(rows[0].n), "train_schedule_bookings link").to.eq(1);
|
||||
});
|
||||
});
|
||||
|
||||
// The ride-along never reopens the export window.
|
||||
withSchedule(TRAIN_F, (s) => {
|
||||
expect(s.booking_window_status, "F window stays FULL").to.eq("FULL");
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export {};
|
||||
152
e2e/freight/cypress/fixtures/seed-import-corridor.sql
Normal file
152
e2e/freight/cypress/fixtures/seed-import-corridor.sql
Normal file
@@ -0,0 +1,152 @@
|
||||
-- Arrange-data for the IMPORT corridor flow specs
|
||||
-- (flows/import_full_train, import_waiting_expiry, import_split_promote,
|
||||
-- import_window_reopen, import_critical_matrix). Idempotent.
|
||||
--
|
||||
-- Long import corridor (A→B→C→D→E→T, 6 stops, DJ→ET = IMPORT):
|
||||
-- DJIB_PORT → NAGAD → DIRE_DAWA → E2E_AWASH → MOJO → KALITY
|
||||
--
|
||||
-- The specs create the route + schedules through the API; this fixture provides
|
||||
-- what the journeys cannot reasonably create in-flow:
|
||||
-- 1. the extra mid-corridor yard (E2E_AWASH) + container facility rows
|
||||
-- 2. container types 20FT/40FT + NW5 allow-list (shared with seed-intercity)
|
||||
-- 3. NW5 rated for 54 wagons per train (the corridor trains run 54)
|
||||
-- 4. eight locomotives at Djibouti Port (each import schedule needs >= 2)
|
||||
-- 5. free NW5 wagon stock parked at DJIB_PORT (+ a NAGAD pocket for the
|
||||
-- sub-corridor scenario) so wagon allocation has physical stock
|
||||
-- 6. yard distances for every consecutive pair (route creation refuses
|
||||
-- unconfigured pairs)
|
||||
-- 7. LIVE CONTAINER_IMPORT rates on the legs the specs book (pricing
|
||||
-- hard-blocks a container line without a rate on its exact leg) + an
|
||||
-- INTERCITY_CONTAINER rate for the ride-along scenario
|
||||
|
||||
-- 0. The split-remainder chain (assertExactRemainder in contract-booking)
|
||||
-- deliberately creates a SECOND live booking under a split ONE_TIME contract,
|
||||
-- but no migration ever relaxed the 1822 one-live-booking unique index for it
|
||||
-- (the dev DB was hand-patched). Drop it here the same way — and note it as a
|
||||
-- missing production migration.
|
||||
DROP INDEX IF EXISTS freight.uq_one_active_booking_per_one_time_contract;
|
||||
|
||||
-- 1a. Extra Ethiopian mid-corridor yard.
|
||||
INSERT INTO freight.yards (id, code, label, country, is_active, display_order)
|
||||
SELECT gen_random_uuid(), 'E2E_AWASH', 'E2E Awash Yard', 'Ethiopia', true, 50
|
||||
WHERE NOT EXISTS (SELECT 1 FROM freight.yards WHERE code = 'E2E_AWASH');
|
||||
|
||||
-- 1b. Container-capable facility rows for every corridor yard the specs load
|
||||
-- or unload at (booking-journey's yard gate reads freight.yard_facilities).
|
||||
INSERT INTO freight.yard_facilities
|
||||
(id, yard_id, has_warehouse, handles_container, handles_bulk, is_active)
|
||||
SELECT gen_random_uuid(), y.id, false, true, true, true
|
||||
FROM freight.yards y
|
||||
WHERE y.code IN ('DJIB_PORT', 'NAGAD', 'DIRE_DAWA', 'E2E_AWASH', 'MOJO', 'KALITY')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.yard_facilities f
|
||||
WHERE f.yard_id = y.id AND f.deleted_at IS NULL
|
||||
);
|
||||
|
||||
-- 2a. Container types (booking form + API resolve 20ft/40ft by size_ft).
|
||||
INSERT INTO freight.container_types (id, code, label, size_ft, is_active)
|
||||
SELECT gen_random_uuid(), v.code, v.label, v.size_ft, true
|
||||
FROM (VALUES ('20FT', '20FT', 20), ('40FT', '40FT', 40)) AS v(code, label, size_ft)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM freight.container_types t WHERE t.code = v.code);
|
||||
|
||||
-- 2b. 20ft/40ft containers ride NW5 flat wagons.
|
||||
INSERT INTO freight.container_type_wagon_types (container_type_id, wagon_type_id)
|
||||
SELECT ct.id, wt.id
|
||||
FROM freight.container_types ct
|
||||
JOIN freight.wagon_types wt ON wt.code = 'NW5'
|
||||
WHERE ct.code IN ('20FT', '40FT')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.container_type_wagon_types x
|
||||
WHERE x.container_type_id = ct.id AND x.wagon_type_id = wt.id
|
||||
);
|
||||
|
||||
-- 3. Pin the derived slot count at 54: the batch engine recomputes
|
||||
-- schedule.max_wagons as floor(locoLength / SHORTEST active wagon length)
|
||||
-- (syncScheduleMaxWagons). GW2 (12.228 m) is not part of these flows but is
|
||||
-- the shortest active type — deactivate it so NW5 (13.966 m) governs, and run
|
||||
-- 760 m locos: floor(760 / 13.966) = 54 slots, and 54 NW5 = 754.2 m still
|
||||
-- fits the per-edge length budget.
|
||||
UPDATE freight.wagon_types SET is_active = false WHERE code = 'GW2' AND is_active;
|
||||
|
||||
-- 4. Fourteen locomotives at Djibouti Port. 9000T pull comfortably clears a
|
||||
-- 54-wagon container consist; each spec's schedule picks its own pair.
|
||||
INSERT INTO freight.locomotives
|
||||
(id, code, max_pull_weight_tons, max_train_length_meters, current_yard_id)
|
||||
SELECT gen_random_uuid(), v.code, 9000, 760, y.id
|
||||
FROM (VALUES ('LOCO-IMP-1'), ('LOCO-IMP-2'), ('LOCO-IMP-3'), ('LOCO-IMP-4'),
|
||||
('LOCO-IMP-5'), ('LOCO-IMP-6'), ('LOCO-IMP-7'), ('LOCO-IMP-8'),
|
||||
('LOCO-IMP-9'), ('LOCO-IMP-10'), ('LOCO-IMP-11'), ('LOCO-IMP-12'),
|
||||
('LOCO-IMP-13'), ('LOCO-IMP-14'))
|
||||
AS v(code)
|
||||
JOIN freight.yards y ON y.code = 'DJIB_PORT'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM freight.locomotives l WHERE l.code = v.code);
|
||||
|
||||
-- Prior seeds may have created the fleet at other dimensions — enforce.
|
||||
UPDATE freight.locomotives
|
||||
SET max_pull_weight_tons = 9000, max_train_length_meters = 760
|
||||
WHERE code LIKE 'LOCO-IMP-%'
|
||||
AND (max_pull_weight_tons IS DISTINCT FROM 9000
|
||||
OR max_train_length_meters IS DISTINCT FROM 760);
|
||||
|
||||
-- 5. Wagon stock: park every free NW5 flat at Djibouti Port, then move 20 of
|
||||
-- them to NAGAD for the sub-corridor boarding scenario. Coupled wagons
|
||||
-- (train_id set — e.g. TRN-E2E-1's four) are untouched.
|
||||
UPDATE freight.wagons w
|
||||
SET current_yard_id = (SELECT id FROM freight.yards WHERE code = 'DJIB_PORT')
|
||||
FROM freight.wagon_types wt
|
||||
WHERE wt.id = w.wagon_type_id AND wt.code = 'NW5'
|
||||
AND w.train_id IS NULL AND w.deleted_at IS NULL;
|
||||
|
||||
UPDATE freight.wagons w
|
||||
SET current_yard_id = (SELECT id FROM freight.yards WHERE code = 'NAGAD')
|
||||
FROM (
|
||||
SELECT w2.id
|
||||
FROM freight.wagons w2
|
||||
JOIN freight.wagon_types wt ON wt.id = w2.wagon_type_id AND wt.code = 'NW5'
|
||||
WHERE w2.train_id IS NULL AND w2.deleted_at IS NULL
|
||||
ORDER BY w2.wagon_number DESC
|
||||
LIMIT 20
|
||||
) pick
|
||||
WHERE w.id = pick.id;
|
||||
|
||||
-- 6. Segment distances for every consecutive corridor pair (symmetric rows).
|
||||
INSERT INTO freight.yard_distances (id, from_yard_id, to_yard_id, distance_km)
|
||||
SELECT gen_random_uuid(), a.id, b.id, v.km
|
||||
FROM (VALUES
|
||||
('DJIB_PORT', 'NAGAD', 20),
|
||||
('NAGAD', 'DIRE_DAWA', 310),
|
||||
('DIRE_DAWA', 'E2E_AWASH', 200),
|
||||
('E2E_AWASH', 'MOJO', 250),
|
||||
('MOJO', 'KALITY', 70)
|
||||
) AS v(from_code, to_code, km)
|
||||
JOIN freight.yards a ON a.code = v.from_code
|
||||
JOIN freight.yards b ON b.code = v.to_code
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM freight.yard_distances d
|
||||
WHERE (d.from_yard_id = a.id AND d.to_yard_id = b.id)
|
||||
OR (d.from_yard_id = b.id AND d.to_yard_id = a.id)
|
||||
);
|
||||
|
||||
-- 7. LIVE import rates on every leg the specs book, plus the intercity
|
||||
-- ride-along leg (rates are configured in USD and converted per booking).
|
||||
INSERT INTO freight.rates
|
||||
(id, rate_type, applies_to, trigger, currency, rate_value, rate_unit, status,
|
||||
origin_yard_id, destination_yard_id, proposed_by_staff_id)
|
||||
SELECT gen_random_uuid(), v.rate_type, v.applies_to, 'ALWAYS', 'USD', v.value,
|
||||
'PER_CONTAINER', 'LIVE', a.id, b.id, u.id
|
||||
FROM (VALUES
|
||||
('CONTAINER_IMPORT', 'CONTAINER', 'DJIB_PORT', 'KALITY', 800),
|
||||
('CONTAINER_IMPORT', 'CONTAINER', 'DJIB_PORT', 'MOJO', 700),
|
||||
('CONTAINER_IMPORT', 'CONTAINER', 'NAGAD', 'KALITY', 650),
|
||||
('CONTAINER_IMPORT', 'CONTAINER', 'NAGAD', 'MOJO', 600),
|
||||
('INTERCITY_CONTAINER', 'INTERCITY', 'MOJO', 'KALITY', 200)
|
||||
) AS v(rate_type, applies_to, from_code, to_code, value)
|
||||
JOIN freight.yards a ON a.code = v.from_code
|
||||
JOIN freight.yards b ON b.code = v.to_code
|
||||
JOIN iam.users u ON u.email = 'operation@edr.local'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM freight.rates r
|
||||
WHERE r.rate_type = v.rate_type
|
||||
AND r.origin_yard_id = a.id AND r.destination_yard_id = b.id
|
||||
AND r.deleted_at IS NULL
|
||||
);
|
||||
175
e2e/freight/cypress/fixtures/seed-segment-weight.sql
Normal file
175
e2e/freight/cypress/fixtures/seed-segment-weight.sql
Normal file
@@ -0,0 +1,175 @@
|
||||
-- Arrange-data for flows/segment_weight.cy.ts. Idempotent.
|
||||
-- Run AFTER seed-intercity.sql + seed-export.sql (container types, E2E_WHEAT
|
||||
-- cargo, Mojo→Djibouti rates, yard distances come from those).
|
||||
--
|
||||
-- Two dedicated trains on the Mojo → Dire Dawa → Djibouti Port corridor:
|
||||
--
|
||||
-- TRN-SEG-W ("tolerance train") — locos 240T pull; LOCO-SEG-A carries a 90T
|
||||
-- overage tolerance, LOCO-SEG-B has NONE CONFIGURED (null). The S-2026-00024
|
||||
-- regression pair: min-across-locos must keep the 90, not zero it.
|
||||
-- Consist: 2 CW4 (bulk) + 2 NW5 (containers).
|
||||
--
|
||||
-- TRN-SEG-F ("border-full train") — locos 200T pull, no tolerance.
|
||||
-- Consist: 4 NW5. An 8×20ft export boarding at Dire Dawa commits every
|
||||
-- wagon on the border edge → the train goes FULL for its trade direction
|
||||
-- while the Mojo→Dire leg stays free: an intercity ride-along boards the
|
||||
-- SAME wagons there and alights before the export loads them.
|
||||
--
|
||||
-- Wagons are dedicated inserts (WGN-SEG-*) so the fixture never competes with
|
||||
-- other specs for free fleet stock, and the corridor targets NAGAD (not
|
||||
-- DJIB_PORT) so no other spec's same-day schedule can steal FCFS bookings.
|
||||
|
||||
-- 0. Reset any PREVIOUS segment-weight run (namespaced: TRN-SEG-* trains,
|
||||
-- CTR-SEG-* contracts, WGN-SEG-* wagons) so the spec re-runs on a warm DB.
|
||||
-- Everything is age-guarded (45 min): Cypress re-runs the spec's before()
|
||||
-- hook on cross-origin reloads, and an unguarded reset would soft-delete the
|
||||
-- CURRENT run's own schedules and contracts mid-flight. Consequence: rerun
|
||||
-- the spec no sooner than 45 minutes after a crashed run (or restack).
|
||||
UPDATE freight.train_schedules ts
|
||||
SET deleted_at = now(), booking_window_status = 'CLOSED'
|
||||
WHERE ts.deleted_at IS NULL
|
||||
AND ts.created_at < now() - interval '45 minutes'
|
||||
AND ts.train_set_id IN (
|
||||
SELECT se.id FROM freight.train_sets se
|
||||
JOIN freight.trains t ON t.id = se.train_id
|
||||
WHERE t.code LIKE 'TRN-SEG-%'
|
||||
);
|
||||
|
||||
UPDATE freight.wagon_booking_allocations a
|
||||
SET deleted_at = now()
|
||||
WHERE a.deleted_at IS NULL
|
||||
AND a.booking_id IN (
|
||||
SELECT b.id FROM freight.bookings b
|
||||
JOIN freight.contracts c ON c.id = b.contract_id
|
||||
WHERE c.reference LIKE 'CTR-SEG-%'
|
||||
AND c.created_at < now() - interval '45 minutes'
|
||||
);
|
||||
|
||||
UPDATE freight.bookings b
|
||||
SET deleted_at = now()
|
||||
WHERE b.deleted_at IS NULL
|
||||
AND b.contract_id IN (
|
||||
SELECT id FROM freight.contracts
|
||||
WHERE reference LIKE 'CTR-SEG-%'
|
||||
AND created_at < now() - interval '45 minutes'
|
||||
);
|
||||
|
||||
UPDATE freight.contracts
|
||||
SET deleted_at = now()
|
||||
WHERE deleted_at IS NULL
|
||||
AND reference LIKE 'CTR-SEG-%'
|
||||
AND created_at < now() - interval '45 minutes';
|
||||
|
||||
-- Un-pin only wagons whose pin points at a dead schedule — live pins from the
|
||||
-- current run must survive a mid-run re-seed.
|
||||
UPDATE freight.wagons w
|
||||
SET current_train_schedule_id = NULL,
|
||||
train_set_wagon_id = NULL,
|
||||
current_yard_id = (SELECT id FROM freight.yards WHERE code = 'MOJO')
|
||||
WHERE w.wagon_number LIKE 'WGN-SEG-%'
|
||||
AND w.current_train_schedule_id IS NOT NULL
|
||||
AND w.current_train_schedule_id IN (
|
||||
SELECT id FROM freight.train_schedules WHERE deleted_at IS NOT NULL
|
||||
);
|
||||
|
||||
-- 1. Locomotives at Mojo.
|
||||
INSERT INTO freight.locomotives
|
||||
(id, code, max_pull_weight_tons, max_train_length_meters,
|
||||
overage_tolerance_tons, current_yard_id)
|
||||
SELECT gen_random_uuid(), v.code, v.pull, 760, v.tol, y.id
|
||||
FROM (VALUES
|
||||
('LOCO-SEG-A', 240, 90),
|
||||
('LOCO-SEG-B', 240, NULL),
|
||||
('LOCO-SEG-C', 200, NULL),
|
||||
('LOCO-SEG-D', 200, NULL)
|
||||
) AS v(code, pull, tol)
|
||||
JOIN freight.yards y ON y.code = 'MOJO'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM freight.locomotives l WHERE l.code = v.code);
|
||||
|
||||
-- 2. Built trains at Mojo.
|
||||
INSERT INTO freight.trains (id, code, train_name, capacity_tons, current_yard_id)
|
||||
SELECT gen_random_uuid(), v.code, v.name, 2000, y.id
|
||||
FROM (VALUES
|
||||
('TRN-SEG-W', 'E2E Tolerance Carrier'),
|
||||
('TRN-SEG-F', 'E2E Border-Full Carrier')
|
||||
) AS v(code, name)
|
||||
JOIN freight.yards y ON y.code = 'MOJO'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM freight.trains t WHERE t.code = v.code);
|
||||
|
||||
-- 3. Couple the locomotive pairs (schedulable trains need >= 2 locos).
|
||||
INSERT INTO freight.train_locomotives (id, train_id, locomotive_id, sequence_no)
|
||||
SELECT gen_random_uuid(), t.id, l.id, v.seq
|
||||
FROM (VALUES
|
||||
('TRN-SEG-W', 'LOCO-SEG-A', 0),
|
||||
('TRN-SEG-W', 'LOCO-SEG-B', 1),
|
||||
('TRN-SEG-F', 'LOCO-SEG-C', 0),
|
||||
('TRN-SEG-F', 'LOCO-SEG-D', 1)
|
||||
) AS v(train_code, loco_code, seq)
|
||||
JOIN freight.trains t ON t.code = v.train_code
|
||||
JOIN freight.locomotives l ON l.code = v.loco_code
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM freight.train_locomotives tl
|
||||
WHERE tl.train_id = t.id AND tl.locomotive_id = l.id
|
||||
);
|
||||
|
||||
-- 4. Dedicated wagons, parked at Mojo.
|
||||
INSERT INTO freight.wagons (id, wagon_number, wagon_type_id, current_yard_id)
|
||||
SELECT gen_random_uuid(), v.num, wt.id, y.id
|
||||
FROM (VALUES
|
||||
('WGN-SEG-C1', 'CW4'), ('WGN-SEG-C2', 'CW4'),
|
||||
('WGN-SEG-N1', 'NW5'), ('WGN-SEG-N2', 'NW5'),
|
||||
('WGN-SEG-N3', 'NW5'), ('WGN-SEG-N4', 'NW5'),
|
||||
('WGN-SEG-N5', 'NW5'), ('WGN-SEG-N6', 'NW5')
|
||||
) AS v(num, wt_code)
|
||||
JOIN freight.wagon_types wt ON wt.code = v.wt_code
|
||||
JOIN freight.yards y ON y.code = 'MOJO'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM freight.wagons w WHERE w.wagon_number = v.num);
|
||||
|
||||
-- 5. Couple them: W = 2 CW4 + 2 NW5, F = 4 NW5.
|
||||
UPDATE freight.wagons w
|
||||
SET train_id = t.id, sequence_number = v.seq
|
||||
FROM freight.trains t,
|
||||
(VALUES
|
||||
('WGN-SEG-C1', 'TRN-SEG-W', 1), ('WGN-SEG-C2', 'TRN-SEG-W', 2),
|
||||
('WGN-SEG-N1', 'TRN-SEG-W', 3), ('WGN-SEG-N2', 'TRN-SEG-W', 4),
|
||||
('WGN-SEG-N3', 'TRN-SEG-F', 1), ('WGN-SEG-N4', 'TRN-SEG-F', 2),
|
||||
('WGN-SEG-N5', 'TRN-SEG-F', 3), ('WGN-SEG-N6', 'TRN-SEG-F', 4)
|
||||
) AS v(num, train_code, seq)
|
||||
WHERE w.wagon_number = v.num
|
||||
AND t.code = v.train_code
|
||||
AND w.train_id IS DISTINCT FROM t.id;
|
||||
|
||||
-- 6. LIVE export rates for the NAGAD corridor: bulk from Mojo (tolerance
|
||||
-- train's wheat) and container from Dire Dawa (border-full scenario's
|
||||
-- mid-route export).
|
||||
INSERT INTO freight.rates
|
||||
(id, rate_type, applies_to, trigger, currency, rate_value, rate_unit, status,
|
||||
origin_yard_id, destination_yard_id, proposed_by_staff_id)
|
||||
SELECT gen_random_uuid(), v.rate_type, v.applies_to, 'ALWAYS', 'USD', v.value,
|
||||
v.unit, 'LIVE', a.id, b.id, u.id
|
||||
FROM (VALUES
|
||||
('BULK_EXPORT', 'BULK', 'MOJO', 25, 'PER_TON'),
|
||||
('CONTAINER_EXPORT', 'CONTAINER', 'DIRE_DAWA', 600, 'PER_CONTAINER')
|
||||
) AS v(rate_type, applies_to, origin_code, value, unit)
|
||||
JOIN freight.yards a ON a.code = v.origin_code
|
||||
JOIN freight.yards b ON b.code = 'NAGAD'
|
||||
JOIN iam.users u ON u.email = 'operation@edr.local'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM freight.rates r
|
||||
WHERE r.rate_type = v.rate_type
|
||||
AND r.origin_yard_id = a.id AND r.destination_yard_id = b.id
|
||||
AND r.deleted_at IS NULL
|
||||
);
|
||||
|
||||
-- 7. Segment distance for the new corridor's border leg (Mojo–Dire comes
|
||||
-- from seed-intercity.sql).
|
||||
INSERT INTO freight.yard_distances (id, from_yard_id, to_yard_id, distance_km)
|
||||
SELECT gen_random_uuid(), a.id, b.id, 460
|
||||
FROM freight.yards a
|
||||
JOIN freight.yards b ON b.code = 'NAGAD'
|
||||
WHERE a.code = 'DIRE_DAWA'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.yard_distances d
|
||||
WHERE (d.from_yard_id = a.id AND d.to_yard_id = b.id)
|
||||
OR (d.from_yard_id = b.id AND d.to_yard_id = a.id)
|
||||
);
|
||||
@@ -848,6 +848,8 @@ export interface CreateBookingUnderContractDto {
|
||||
equipmentReturn?: string;
|
||||
containers?: CreateBookingContainerLineDto[];
|
||||
bulkLines?: CreateBulkLineDto[];
|
||||
/** What the containers carry — captured per booking (container freight). */
|
||||
cargoFreeText?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user