diff --git a/apps/edr-freight-api/src/scripts/cmds/index.ts b/apps/edr-freight-api/src/scripts/cmds/index.ts index 60a2c1896..369aff010 100644 --- a/apps/edr-freight-api/src/scripts/cmds/index.ts +++ b/apps/edr-freight-api/src/scripts/cmds/index.ts @@ -2,7 +2,9 @@ import type Vorpal from "vorpal"; import type { CommandContext } from "./types"; import { registerSeedTestContracts } from "./seed-test-contracts.cmd"; +import { registerSeedTestSchedules } from "./seed-test-schedules.cmd"; export function registerCommands(vorpal: Vorpal, ctx: CommandContext): void { registerSeedTestContracts(vorpal, ctx); + registerSeedTestSchedules(vorpal, ctx); } diff --git a/apps/edr-freight-api/src/scripts/cmds/seed-test-schedules.cmd.ts b/apps/edr-freight-api/src/scripts/cmds/seed-test-schedules.cmd.ts new file mode 100644 index 000000000..2d9002555 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/cmds/seed-test-schedules.cmd.ts @@ -0,0 +1,298 @@ +import type Vorpal from "vorpal"; +import { DataSource } from "typeorm"; +import { WagonStatus } from "@edr/types"; +import type { CommandContext } from "./types"; +import { Yard } from "../../modules/rule-engine/entities/yard.entity"; +import { Route } from "../../modules/routes/entities/route.entity"; +import { RouteMilestone } from "../../modules/routes/entities/route-milestone.entity"; +import { Locomotive } from "../../modules/locomotives/entities/locomotive.entity"; +import { Wagon } from "../../modules/wagons/entities/wagon.entity"; +import { WagonType } from "../../modules/wagon-types/entities/wagon-type.entity"; +import { TrainSet } from "../../modules/train-sets/entities/train-set.entity"; +import { TrainSetLocomotive } from "../../modules/train-sets/entities/train-set-locomotive.entity"; +import { TrainSetWagon } from "../../modules/train-sets/entities/train-set-wagon.entity"; +import { TrainSchedule } from "../../modules/train-schedules/entities/train-schedule.entity"; +import { TrainScheduleBooking } from "../../modules/train-schedules/entities/train-schedule-booking.entity"; +import { Booking } from "../../modules/bookings/entities/booking.entity"; + +export function registerSeedTestSchedules( + vorpal: Vorpal, + ctx: CommandContext, +): void { + vorpal + .command("seed:test-schedules", "Seed train schedules with routes, wagons, and all deps for booking") + .option("-n, --count ", "Number of schedules to create (default: 3)") + .option("--direction ", "IMPORT,EXPORT (default: both)") + .option("--status ", "DRAFT,SCHEDULED,DISPATCHED (default: SCHEDULED)") + .option("--days-ahead ", "Days from now for departure (default: 3)") + .action(async function (this: any, args: any) { + const { app } = ctx; + const ds = app.get(DataSource); + + const count = Math.max(1, Math.min(10, parseInt(args.options?.count ?? "3", 10))); + const directionList = (args.options?.direction ?? "IMPORT,EXPORT") + .split(",").map((s: string) => s.toUpperCase().trim()) + .filter((s: string) => s === "IMPORT" || s === "EXPORT"); + const statusList = (args.options?.status ?? "SCHEDULED") + .split(",").map((s: string) => s.toUpperCase().trim()) + .filter((s: string) => s === "DRAFT" || s === "SCHEDULED" || s === "DISPATCHED"); + const daysAhead = Math.max(0, parseInt(args.options?.daysAhead ?? "3", 10)); + + if (directionList.length === 0 || statusList.length === 0) { + this.log("error: at least one direction and status required"); + return; + } + + this.log(`Seeding ${count} schedules (direction=${directionList.join(",")}, status=${statusList.join(",")}, days_ahead=${daysAhead})...`); + + const yards = await ds.getRepository(Yard).find({ where: { isActive: true } }); + const yardByCode = new Map(yards.map((y) => [y.code.toUpperCase(), y])); + const djibouti = yardByCode.get("DJIBOUTI") ?? yards.find((y) => y.country === "Djibouti"); + const addis = yardByCode.get("ADDIS_ABABA") ?? yards.find((y) => y.country === "Ethiopia"); + + if (!djibouti || !addis) { + this.log("error: need at least one Djibouti and one Ethiopia yard"); + return; + } + + const wagonTypes = await ds.getRepository(WagonType).find({ where: { isActive: true } }); + if (wagonTypes.length === 0) { + this.log("error: no wagon types found — seed reference data first"); + return; + } + + const route = await ensureTestRoute(ds, yards, djibouti, addis, (msg) => this.log(msg)); + + const scheduleRepo = ds.getRepository(TrainSchedule); + const trainSetRepo = ds.getRepository(TrainSet); + let seeded = 0; + + for (let i = 0; i < count; i++) { + const dir = directionList[i % directionList.length]; + const status = statusList[i % statusList.length]; + const trainNumber = `TST-SCH-${String(i + 1).padStart(3, "0")}`; + + const existing = await scheduleRepo.findOne({ where: { trainNumber } }); + if (existing) { + this.log(` Schedule ${trainNumber} already exists — skipping`); + seeded++; + continue; + } + + const originYard = dir === "IMPORT" ? djibouti : addis; + const destYard = dir === "IMPORT" ? addis : djibouti; + const isDispatched = status === "DISPATCHED"; + + const now = new Date(); + const departure = new Date(now); + departure.setDate(departure.getDate() + daysAhead); + departure.setHours(7, 0, 0, 0); + + const travelHours = dir === "IMPORT" ? 12 : 10; + const arrival = new Date(departure.getTime() + travelHours * 60 * 60 * 1000); + + const locomotive = await ensureTestLocomotive(ds, originYard.id, (msg) => this.log(msg)); + const wagonCount = 4; + const wagonType = wagonTypes[0]; + const wagonCapacity = Number(wagonType.capacityTons) || 70; + const wagonLength = Number(wagonType.lengthMeters) || 14; + const tareWeight = Number(wagonType.tareWeightTons) || 14; + const totalWagonWeight = wagonCount * (tareWeight + 20); + + const trainSet = await trainSetRepo.save( + trainSetRepo.create({ + locomotiveId: locomotive.id, + totalWeightTons: totalWagonWeight, + totalLengthMeters: wagonLength * wagonCount, + wagonCount, + status: isDispatched ? "DISPATCHED" : status === "DRAFT" ? "DRAFT" : "ASSIGNED", + }), + ); + + await ds.getRepository(TrainSetLocomotive).save( + ds.getRepository(TrainSetLocomotive).create({ + trainSetId: trainSet.id, + locomotiveId: locomotive.id, + sequenceNo: 0, + }), + ); + + const schedule = await scheduleRepo.save( + scheduleRepo.create({ + trainSetId: trainSet.id, + routeId: route.id, + originStationId: originYard.id, + destinationStationId: destYard.id, + scheduledDepartureDate: departure, + scheduledArrivalDate: arrival, + actualDepartureAt: isDispatched ? departure : null, + status, + trainNumber, + direction: dir, + maxWagons: 53, + bookingWindowStatus: isDispatched ? "CLOSED" : "OPEN", + }), + ); + + for (let w = 0; w < wagonCount; w++) { + const seq = w + 1; + const wagonNumber = `${trainNumber}-W${String(seq).padStart(2, "0")}`; + + const physicalWagon = await ensureTestWagon(ds, { + wagonNumber, + wagonTypeId: wagonType.id, + yardId: originYard.id, + trainScheduleId: schedule.id, + tareWeight, + capacityTons: wagonCapacity, + dispatched: isDispatched, + }); + + await ds.getRepository(TrainSetWagon).save( + ds.getRepository(TrainSetWagon).create({ + trainSetId: trainSet.id, + wagonTypeId: wagonType.id, + physicalWagonId: physicalWagon.id, + sequenceNo: seq, + capacityTons: wagonCapacity, + lengthMeters: wagonLength, + assignedWeightTons: 20, + status: isDispatched ? "DEPARTED" : "PLANNED", + }), + ); + } + + const scheduleBookings = await ds.getRepository(TrainScheduleBooking).find({ + where: { trainScheduleId: schedule.id }, + }); + if (scheduleBookings.length === 0) { + const booking = await ds.getRepository(Booking).findOne({ where: {}, order: { createdAt: "DESC" } }); + if (booking) { + await ds.getRepository(TrainScheduleBooking).save( + ds.getRepository(TrainScheduleBooking).create({ + trainScheduleId: schedule.id, + bookingId: booking.id, + }), + ); + } + } + + seeded++; + this.log(` Created ${status} ${dir} schedule: ${trainNumber} (${originYard.label} → ${destYard.label})`); + } + + this.log(`Done — ${seeded} train schedules ensured`); + }); +} + +async function ensureTestRoute( + ds: DataSource, + yards: Yard[], + originYard: Yard, + destYard: Yard, + log: (msg: string) => void, +): Promise { + const repo = ds.getRepository(Route); + const name = `TST-RTE-${originYard.code}_${destYard.code}`; + let route = await repo.findOne({ where: { name } }); + if (route) return route; + + route = await repo.save( + repo.create({ + name, + originYardId: originYard.id, + destinationYardId: destYard.id, + isActive: true, + }), + ); + log(` Created route: ${name}`); + + const milestoneRepo = ds.getRepository(RouteMilestone); + const intermediateYards = yards.filter( + (y) => y.id !== originYard.id && y.id !== destYard.id, + ); + + await milestoneRepo.save( + milestoneRepo.create({ + routeId: route.id, + yardId: originYard.id, + sequenceNo: 0, + }), + ); + + for (const [idx, y] of intermediateYards.entries()) { + await milestoneRepo.save( + milestoneRepo.create({ + routeId: route.id, + yardId: y.id, + sequenceNo: (idx + 1) * 2, + }), + ); + } + + await milestoneRepo.save( + milestoneRepo.create({ + routeId: route.id, + yardId: destYard.id, + sequenceNo: (intermediateYards.length + 1) * 2, + }), + ); + + log(` Added ${intermediateYards.length + 2} milestones`); + return route; +} + +async function ensureTestLocomotive( + ds: DataSource, + yardId: string, + log: (msg: string) => void, +): Promise { + const repo = ds.getRepository(Locomotive); + const code = "TST-LOCO-01"; + let loco = await repo.findOne({ where: { code } }); + if (loco) return loco; + + loco = await repo.save( + repo.create({ + code, + name: "Test Locomotive", + locomotiveType: "DIESEL", + maxPullWeightTons: 4200, + maxTrainLengthMeters: 760, + status: "AVAILABLE", + currentYardId: yardId, + }), + ); + log(` Created locomotive: ${code}`); + return loco; +} + +async function ensureTestWagon( + ds: DataSource, + input: { + wagonNumber: string; + wagonTypeId: string; + yardId: string; + trainScheduleId: string; + tareWeight: number; + capacityTons: number; + dispatched: boolean; + }, +): Promise { + const repo = ds.getRepository(Wagon); + const existing = await repo.findOne({ where: { wagonNumber: input.wagonNumber } }); + if (existing) return existing; + + const wagon = repo.create({ + wagonNumber: input.wagonNumber, + wagonTypeId: input.wagonTypeId, + currentYardId: input.yardId, + currentTrainScheduleId: input.trainScheduleId, + tareWeight: input.tareWeight, + maxPayloadWeight: input.capacityTons, + status: input.dispatched ? WagonStatus.Assigned : WagonStatus.Available, + notes: "Test seed wagon", + }); + const saved = await repo.save(wagon as any); + return Array.isArray(saved) ? saved[0] : saved; +}