mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 04:08:11 +00:00
implement contract cancellation feature and update contract statuses
- Added functionality to cancel contracts, allowing users to provide a reason for cancellation. - Updated contract statuses to include SUSPENDED and changed CLOSED to COMPLETED. - Enhanced the UI to reflect the new cancellation option and updated messaging for contract statuses. - Refactored contract booking actions to accommodate changes in booking logic for ONE_TIME and GENERAL contracts. - Removed clearance document management from the contract detail page, as it is now handled per booking. - Introduced a SQL script to reset bookings and train schedules for development purposes.
This commit is contained in:
@@ -87,6 +87,7 @@ import {
|
||||
OverageTolerance,
|
||||
stopYardsFor,
|
||||
} from './corridor-capacity.util';
|
||||
import { WagonStockLedger } from './wagon-stock-ledger.util';
|
||||
|
||||
export type { Capacity } from './corridor-capacity.util';
|
||||
|
||||
@@ -1619,6 +1620,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const limits = await this.capacityLimits(locomotive);
|
||||
await this.syncScheduleMaxWagons(schedule, locomotive);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||
const stock = await this.stockLedgerFor(schedule, budget);
|
||||
const allowedWagonTypes = await this.loadAllowedWagonTypeIds();
|
||||
const minPerWagon = this.minPerWagonNeed(wagonDims);
|
||||
if (budget.isExhausted(minPerWagon)) {
|
||||
await this.setWindow(scheduleId, "FULL");
|
||||
@@ -1655,14 +1658,19 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// Consolidated partners always share one corridor, so the primary's leg
|
||||
// stands for the pair.
|
||||
const leg = budget.legForYards(booking.originYardId, booking.destinationYardId);
|
||||
const wagonTypeIds = this.allowedWagonTypeIdsFor(booking, allowedWagonTypes);
|
||||
// Abstract room AND real wagons of a type this booking can ride — see
|
||||
// fillRouteDayInternal for why both gates are needed.
|
||||
const stocked = this.hasWagonStock(stock, wagonTypeIds, need.wagons, leg);
|
||||
|
||||
// Per-unit fit trace: which axis (wagons/weight/length) admits or rejects.
|
||||
// Per-unit fit trace: which axis (wagons/weight/length/stock) admits or rejects.
|
||||
this.logger.debug(
|
||||
`[fillSchedule ${scheduleId}] unit ${booking.reference}: need=${JSON.stringify(need)} ` +
|
||||
`roomOnLeg=${JSON.stringify(budget.remainingFor(leg))} fits=${budget.fits(need, leg)}`,
|
||||
`roomOnLeg=${JSON.stringify(budget.remainingFor(leg))} fits=${budget.fits(need, leg)} ` +
|
||||
`stocked=${stocked}`,
|
||||
);
|
||||
|
||||
if (!budget.fits(need, leg)) {
|
||||
if (!budget.fits(need, leg) || !stocked) {
|
||||
if (isGov) {
|
||||
const freed = await this.preemptForGovernment(
|
||||
scheduleId,
|
||||
@@ -1677,16 +1685,19 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// Doesn't fit whole. A split-eligible import booking is offered the part
|
||||
// that fits in the remaining room (top-up path splits the boundary
|
||||
// booking, mirroring fillRouteDay); otherwise skip and try the next.
|
||||
const cand: { id: string; budget: CorridorBudget; armed: boolean } = {
|
||||
id: scheduleId,
|
||||
budget,
|
||||
armed,
|
||||
};
|
||||
if (await this.maybeOfferPartial(booking, isPair, [cand], need)) {
|
||||
const cand: {
|
||||
id: string;
|
||||
budget: CorridorBudget;
|
||||
armed: boolean;
|
||||
stock: WagonStockLedger;
|
||||
} = { id: scheduleId, budget, armed, stock };
|
||||
if (
|
||||
await this.maybeOfferPartial(booking, isPair, [cand], need, wagonTypeIds)
|
||||
) {
|
||||
armed = cand.armed;
|
||||
continue;
|
||||
}
|
||||
continue; // skip a unit that exceeds weight/length/wagons, try the next
|
||||
continue; // skip a unit that exceeds weight/length/wagons/stock, try the next
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1704,6 +1715,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
commercialReserved += 1;
|
||||
}
|
||||
budget.subtract(need, leg);
|
||||
// Hold the physical wagons too — the next unit must not re-count them.
|
||||
stock.consume(wagonTypeIds, need.wagons, leg);
|
||||
reservedThisPass += 1;
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
@@ -1823,11 +1836,14 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const allowedWagonTypes = await this.loadAllowedWagonTypeIds();
|
||||
|
||||
// Live per-schedule corridor budget + arm/changed flags, in departure order.
|
||||
// Live per-schedule corridor budget + physical wagon-type stock + arm/changed
|
||||
// flags, in departure order.
|
||||
const trains: Array<{
|
||||
id: string;
|
||||
budget: CorridorBudget;
|
||||
stock: WagonStockLedger;
|
||||
armed: boolean;
|
||||
changed: boolean;
|
||||
}> = [];
|
||||
@@ -1844,7 +1860,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const limits = await this.capacityLimits(locomotive);
|
||||
await this.syncScheduleMaxWagons(schedule, locomotive);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||
trains.push({ id, budget, armed: false, changed: false });
|
||||
const stock = await this.stockLedgerFor(schedule, budget);
|
||||
trains.push({ id, budget, stock, armed: false, changed: false });
|
||||
}
|
||||
if (trains.length === 0) return { scheduleIds, commercialReserved: 0 };
|
||||
|
||||
@@ -1884,12 +1901,20 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
const legOn = (t: { budget: CorridorBudget }): CorridorLeg | null =>
|
||||
t.budget.legOf(booking.originYardId, booking.destinationYardId);
|
||||
// Consolidated pairs share one wagon set; the primary's types stand for both.
|
||||
const wagonTypeIds = this.allowedWagonTypeIdsFor(booking, allowedWagonTypes);
|
||||
|
||||
// First train (earliest departure) whose corridor carries this booking's
|
||||
// leg and still fits it as-is.
|
||||
// leg, still fits it as-is AND physically holds enough wagons of a type the
|
||||
// booking can ride. Both gates matter: abstract room without the right
|
||||
// wagon type is space the allocator can never turn into a loaded consist.
|
||||
let target = trains.find((t) => {
|
||||
const leg = legOn(t);
|
||||
return leg != null && t.budget.fits(need, leg);
|
||||
return (
|
||||
leg != null &&
|
||||
t.budget.fits(need, leg) &&
|
||||
this.hasWagonStock(t.stock, wagonTypeIds, need.wagons, leg)
|
||||
);
|
||||
});
|
||||
|
||||
// Per-unit trace: chosen train + each train's remaining room on this leg.
|
||||
@@ -1934,7 +1959,13 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// already consumed most of the room). Consolidated pairs / government /
|
||||
// non-import never split — isSplitEligible guards that. Passing the live
|
||||
// `trains` entries lets maybeOfferPartial mutate the chosen budget/armed.
|
||||
const offered = await this.maybeOfferPartial(booking, isPair, trains, need);
|
||||
const offered = await this.maybeOfferPartial(
|
||||
booking,
|
||||
isPair,
|
||||
trains,
|
||||
need,
|
||||
wagonTypeIds,
|
||||
);
|
||||
if (offered) {
|
||||
// A partial offer opens a real commercial pay window, same as reserve().
|
||||
commercialReserved += 1;
|
||||
@@ -1964,6 +1995,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
commercialReserved += 1;
|
||||
}
|
||||
target.budget.subtract(need, legOn(target)!);
|
||||
// Hold the physical wagons too, so the next unit in this pass sees them
|
||||
// gone — otherwise two bookings both "fit" the same 16 NW5.
|
||||
target.stock.consume(wagonTypeIds, need.wagons, legOn(target)!);
|
||||
target.changed = true;
|
||||
reservedThisPass += 1;
|
||||
} catch (err) {
|
||||
@@ -2027,14 +2061,32 @@ export class BookingBatchService implements OnModuleInit {
|
||||
private async maybeOfferPartial(
|
||||
booking: Booking,
|
||||
isPair: boolean,
|
||||
candidates: Array<{ id: string; budget: CorridorBudget; armed: boolean }>,
|
||||
candidates: Array<{
|
||||
id: string;
|
||||
budget: CorridorBudget;
|
||||
armed: boolean;
|
||||
stock?: WagonStockLedger;
|
||||
}>,
|
||||
need: Capacity,
|
||||
wagonTypeIds: string[] = [],
|
||||
): Promise<boolean> {
|
||||
if (!this.isSplitEligible(booking, isPair)) return false;
|
||||
const target = candidates
|
||||
.map((c) => {
|
||||
const leg = c.budget.legOf(booking.originYardId, booking.destinationYardId);
|
||||
return leg ? { c, leg, room: c.budget.remainingFor(leg) } : null;
|
||||
if (!leg) return null;
|
||||
const room = c.budget.remainingFor(leg);
|
||||
// The offer may never exceed the wagons that physically exist in a type
|
||||
// this booking can ride. This is what turns "20 free wagons, only 16 of
|
||||
// them NW5" into an offer for 16 — the customer pays for 16 and the
|
||||
// other 4 leave as the usual remainder booking, instead of paying for
|
||||
// 20 and stalling at allocation on wagon 17.
|
||||
const physical = wagonTypeIds.length
|
||||
? c.stock?.availableFor(wagonTypeIds, leg)
|
||||
: undefined;
|
||||
const wagons =
|
||||
physical == null ? room.wagons : Math.min(room.wagons, physical);
|
||||
return { c, leg, room: { ...room, wagons } };
|
||||
})
|
||||
.filter((x): x is NonNullable<typeof x> => x != null && x.room.wagons >= 1)
|
||||
.sort((a, b) => b.room.wagons - a.room.wagons)[0];
|
||||
@@ -2047,6 +2099,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
);
|
||||
if (!offered) return false;
|
||||
target.c.budget.subtract(offered, target.leg);
|
||||
target.c.stock?.consume(wagonTypeIds, offered.wagons, target.leg);
|
||||
target.c.armed = true;
|
||||
return true;
|
||||
}
|
||||
@@ -3468,6 +3521,131 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return dims.length ? dims : [fallback];
|
||||
}
|
||||
|
||||
/**
|
||||
* Physical wagon-type stock for one schedule, on the same corridor edges its
|
||||
* {@link CorridorBudget} uses. Sourced from the scheduling service so the
|
||||
* batch counts exactly the wagons the allocator will later plan against.
|
||||
*/
|
||||
private async stockLedgerFor(
|
||||
schedule: TrainSchedule,
|
||||
budget: CorridorBudget,
|
||||
): Promise<WagonStockLedger> {
|
||||
const stock = await this.trainSchedulingService.wagonStockForSchedule(
|
||||
schedule.id,
|
||||
schedule.originStationId,
|
||||
budget.stops,
|
||||
);
|
||||
return new WagonStockLedger(
|
||||
stock.remainingByTypeId,
|
||||
Math.max(1, budget.stops.length - 1),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the train holds enough PHYSICAL wagons of the types this booking may
|
||||
* ride. Unresolvable configuration (no allowed wagon type) returns true: the
|
||||
* abstract budget still governs, and a mis-configured cargo type must not
|
||||
* silently strand every booking that uses it.
|
||||
*/
|
||||
private hasWagonStock(
|
||||
stock: WagonStockLedger,
|
||||
wagonTypeIds: string[],
|
||||
wagonsNeeded: number,
|
||||
leg: CorridorLeg,
|
||||
): boolean {
|
||||
if (!wagonTypeIds.length) return true;
|
||||
return stock.availableFor(wagonTypeIds, leg) >= wagonsNeeded;
|
||||
}
|
||||
|
||||
private allowedWagonTypeCache: {
|
||||
byCargoTypeId: Map<string, string[]>;
|
||||
byContainerTypeId: Map<string, string[]>;
|
||||
expiresAt: number;
|
||||
} | null = null;
|
||||
|
||||
/**
|
||||
* Wagon-type ids each cargo / container type may ride, read straight from the
|
||||
* join tables.
|
||||
*
|
||||
* The batch pool finders deliberately do NOT join `cargoType.wagonTypes` /
|
||||
* `containerType.wagonTypes` — those many-to-many joins multiply rows badly on
|
||||
* a hot path. So the pool's booking entities carry the type FK but not the
|
||||
* allowed list, and resolving it per booking through the relation would come
|
||||
* back empty. Two small lookups, cached for a minute like {@link loadWagonDims},
|
||||
* give the same answer without touching the pool query.
|
||||
*/
|
||||
private async loadAllowedWagonTypeIds(): Promise<{
|
||||
byCargoTypeId: Map<string, string[]>;
|
||||
byContainerTypeId: Map<string, string[]>;
|
||||
}> {
|
||||
if (this.allowedWagonTypeCache && this.allowedWagonTypeCache.expiresAt > Date.now()) {
|
||||
return this.allowedWagonTypeCache;
|
||||
}
|
||||
// Inactive wagon types are excluded, matching loadAllowedWagonTypes() in the
|
||||
// scheduling service — the allocator will not plan against them either.
|
||||
const [cargoRows, containerRows]: [
|
||||
Array<{ typeId: string; wagonTypeId: string }>,
|
||||
Array<{ typeId: string; wagonTypeId: string }>,
|
||||
] = await Promise.all([
|
||||
this.dataSource.query(
|
||||
`SELECT ct.cargo_type_id AS "typeId", ct.wagon_type_id AS "wagonTypeId"
|
||||
FROM freight.cargo_type_wagon_types ct
|
||||
JOIN freight.wagon_types wt ON wt.id = ct.wagon_type_id
|
||||
WHERE wt.is_active IS NOT FALSE`,
|
||||
),
|
||||
this.dataSource.query(
|
||||
`SELECT ct.container_type_id AS "typeId", ct.wagon_type_id AS "wagonTypeId"
|
||||
FROM freight.container_type_wagon_types ct
|
||||
JOIN freight.wagon_types wt ON wt.id = ct.wagon_type_id
|
||||
WHERE wt.is_active IS NOT FALSE`,
|
||||
),
|
||||
]);
|
||||
|
||||
const collect = (rows: Array<{ typeId: string; wagonTypeId: string }>) => {
|
||||
const map = new Map<string, string[]>();
|
||||
for (const row of rows) {
|
||||
const list = map.get(row.typeId) ?? [];
|
||||
list.push(row.wagonTypeId);
|
||||
map.set(row.typeId, list);
|
||||
}
|
||||
return map;
|
||||
};
|
||||
|
||||
const value = {
|
||||
byCargoTypeId: collect(cargoRows),
|
||||
byContainerTypeId: collect(containerRows),
|
||||
};
|
||||
this.allowedWagonTypeCache = { ...value, expiresAt: Date.now() + 60_000 };
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every wagon-type id this booking may ride. Empty means "unresolvable" — the
|
||||
* caller must then skip the physical-stock gate rather than block the booking
|
||||
* on missing configuration.
|
||||
*/
|
||||
private allowedWagonTypeIdsFor(
|
||||
booking: Booking,
|
||||
allowed: {
|
||||
byCargoTypeId: Map<string, string[]>;
|
||||
byContainerTypeId: Map<string, string[]>;
|
||||
},
|
||||
): string[] {
|
||||
if (booking.freightType === "BULK") {
|
||||
const cargoTypeId = booking.cargoTypeId ?? booking.cargoType?.id;
|
||||
return cargoTypeId ? (allowed.byCargoTypeId.get(cargoTypeId) ?? []) : [];
|
||||
}
|
||||
const ids = new Set<string>();
|
||||
for (const line of booking.bookingContainers ?? []) {
|
||||
const containerTypeId = line.containerTypeId ?? line.containerType?.id;
|
||||
if (!containerTypeId) continue;
|
||||
for (const id of allowed.byContainerTypeId.get(containerTypeId) ?? []) {
|
||||
ids.add(id);
|
||||
}
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered stop yards of the schedule's route (origin → milestones →
|
||||
* destination); the legacy two-stop pseudo-route when milestones are absent.
|
||||
|
||||
Reference in New Issue
Block a user