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:
Marshal
2026-07-28 05:02:58 +00:00
parent 481878e553
commit 2429f6b629
83 changed files with 3083 additions and 3729 deletions

View File

@@ -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.