import { Injectable, Logger } from "@nestjs/common"; import { DataSource } from "typeorm"; import { BookingCargoModifier } from "../modules/bookings/entities/booking-cargo-modifier.entity"; import { CargoType } from "../modules/rule-engine/entities/cargo-type.entity"; import { ContainerType } from "../modules/rule-engine/entities/container-type.entity"; import { PriorityConfig } from "../modules/rule-engine/entities/priority-config.entity"; import { Rate } from "../modules/rule-engine/entities/rate.entity"; import { ServiceType } from "../modules/rule-engine/entities/service-type.entity"; import { ShippingLine } from "../modules/rule-engine/entities/shipping-line.entity"; import { WeightLimitRule } from "../modules/rule-engine/entities/weight-limit-rule.entity"; import { Route } from "../modules/routes/entities/route.entity"; import { RouteMilestone } from "../modules/routes/entities/route-milestone.entity"; import { Yard } from "../modules/rule-engine/entities/yard.entity"; const STAFF_USER_ID = "00000000-0000-0000-0000-000000000001"; const CEO_USER_ID = "00000000-0000-0000-0000-000000000002"; @Injectable() export class PricingDataSeeder { private readonly logger = new Logger(PricingDataSeeder.name); constructor(private readonly dataSource: DataSource) { } async run(): Promise { await this.dataSource.transaction(async (manager) => { const ctRepo = manager.getRepository(ContainerType); const stRepo = manager.getRepository(ServiceType); const yRepo = manager.getRepository(Yard); const slRepo = manager.getRepository(ShippingLine); const wlRepo = manager.getRepository(WeightLimitRule); const prRepo = manager.getRepository(PriorityConfig); const rRepo = manager.getRepository(Rate); await this.upsertReferenceData(manager, ctRepo, yRepo, slRepo); await this.seedDomesticRoute(manager, yRepo); await this.seedWeightLimits(wlRepo, ctRepo); await this.seedPriorityConfigs(prRepo); const containerTypes = await ctRepo.find(); const ctByCode = new Map(containerTypes.map((ct) => [ct.code, ct])); // Clear booking cargo modifiers up front — they reference rate snapshots // that get recomputed when bookings are repriced. await manager.getRepository(BookingCargoModifier).createQueryBuilder().delete().execute(); const cargoTypesForRates = await manager.getRepository(CargoType).find(); const cargoForRatesByCode = new Map(cargoTypesForRates.map((c) => [c.code, c])); await this.seedRates(rRepo, ctByCode, cargoForRatesByCode); const yards = await yRepo.find(); const yardByCode = new Map(yards.map((y) => [y.code, y])); const serviceTypes = await stRepo.find(); const stByCode = new Map(serviceTypes.map((st) => [st.code, st])); const shippingLines = await slRepo.find(); const slByCode = new Map(shippingLines.map((sl) => [sl.code, sl])); const cargoTypes = await manager.getRepository(CargoType).find(); const cargoByCode = new Map(cargoTypes.map((c) => [c.code, c])); await this.seedDraftBookings( ctByCode, yardByCode, stByCode, slByCode, cargoByCode, ); }); this.logger.log("Seeded pricing data"); } private async upsertReferenceData( manager: any, ctRepo: any, yRepo: any, slRepo: any, ): Promise { await yRepo.upsert( [ { code: "DJIBOUTI", label: "Djibouti", country: "Djibouti", displayOrder: 1, isActive: true, }, { code: "ADDIS_ABABA", label: "Addis Ababa", country: "Ethiopia", displayOrder: 2, isActive: true, }, { code: "DIRE_DAWA", label: "Dire Dawa", country: "Ethiopia", displayOrder: 3, isActive: true, }, { code: "MODJO", label: "Modjo", country: "Ethiopia", displayOrder: 4, isActive: true, }, ], { conflictPaths: { code: true } }, ); await ctRepo.upsert( [ { code: "20FT", label: "20FT Standard", sizeFt: 20, wagonsPerUnit: 0.5, isReefer: false, isOpenTop: false, isActive: true, displayOrder: 1, }, { code: "40FT", label: "40FT Standard", sizeFt: 40, wagonsPerUnit: 1, isReefer: false, isOpenTop: false, isActive: true, displayOrder: 2, }, { code: "20FT_REEFER", label: "20FT Reefer", sizeFt: 20, wagonsPerUnit: 0.5, isReefer: true, isOpenTop: false, isActive: true, displayOrder: 3, }, { code: "40FT_REEFER", label: "40FT Reefer", sizeFt: 40, wagonsPerUnit: 1, isReefer: true, isOpenTop: false, isActive: true, displayOrder: 4, }, ], { conflictPaths: { code: true } }, ); await slRepo.upsert( [ { code: "MAERSK", label: "Maersk Line", mappedToCode: "MAERSK", showExtraFeeNotice: true, isActive: true, }, { code: "MSC", label: "MSC", mappedToCode: "MSC", showExtraFeeNotice: true, isActive: true, }, { code: "CMA_CGM", label: "CMA CGM", mappedToCode: "CMA_CGM", showExtraFeeNotice: true, isActive: true, }, { code: "COSCO", label: "COSCO Shipping", mappedToCode: "COSCO", showExtraFeeNotice: true, isActive: true, }, { code: "OTHER", label: "Other Line", mappedToCode: null, showExtraFeeNotice: false, isActive: true, }, ], { conflictPaths: { code: true } }, ); await this.seedCargoTypes(manager); } /** * Cargo types are a fixed two-level tree: two top-level groups — Bulk and * Break Bulk — each with a set of commodity children. The groups are the * stable parents the booking wizard renders; children carry the * unit_of_measure used when reserving quantity (PER_TON for bulk commodities, * PER_ITEM for break-bulk items like vehicles/machinery). * * Parents are upserted first, then re-read by code to resolve their ids so the * children can be linked via parent_group_id (upsert doesn't return ids). */ private async seedCargoTypes(manager: any): Promise { const repo = manager.getRepository(CargoType); const groups = [ { code: "BULK", cargoTypeName: "Bulk", displayOrder: 1 }, { code: "BREAK_BULK", cargoTypeName: "Break Bulk", displayOrder: 2 }, ]; await repo.upsert( groups.map((g) => ({ ...g, isActive: true })), { conflictPaths: { code: true } }, ); const bulk = await repo.findOneBy({ code: "BULK" }); const breakBulk = await repo.findOneBy({ code: "BREAK_BULK" }); if (!bulk || !breakBulk) return; // Bulk commodities — measured by tonnage (PER_TON). const bulkChildren = [ { code: "SUGAR", cargoTypeName: "Sugar" }, { code: "GRAIN", cargoTypeName: "Grain / Cereals" }, { code: "WHEAT", cargoTypeName: "Wheat" }, { code: "FERTILIZER", cargoTypeName: "Fertilizer" }, { code: "CEMENT", cargoTypeName: "Cement / Clinker" }, { code: "COAL", cargoTypeName: "Coal" }, ]; // Break-bulk items — counted as whole units (PER_ITEM). const breakBulkChildren = [ { code: "CARS", cargoTypeName: "Cars / Vehicles" }, { code: "MACHINERY", cargoTypeName: "Heavy Machinery", requiresDirectorApproval: true, }, { code: "STEEL", cargoTypeName: "Steel / Rebar", requiresDirectorApproval: true, }, { code: "PIPES", cargoTypeName: "Pipes" }, { code: "TIMBER", cargoTypeName: "Timber" }, ]; await repo.upsert( [ ...bulkChildren.map((c, i) => ({ ...c, parentGroupId: bulk.id, unitOfMeasure: "PER_TON", isActive: true, displayOrder: i + 1, })), ...breakBulkChildren.map((c, i) => ({ ...c, parentGroupId: breakBulk.id, unitOfMeasure: "PER_ITEM", isActive: true, displayOrder: i + 1, })), ], { conflictPaths: { code: true } }, ); // Retire the old flat "Other Bulk Cargo" top-level type from earlier seeds so // it no longer shows alongside the Bulk / Break Bulk groups. No-op on a fresh // DB where it was never seeded. await repo.update({ code: "OTHER_BULK" }, { isActive: false }); } private async seedDomesticRoute(manager: any, yRepo: any): Promise { const addis = await yRepo.findOneBy({ code: "ADDIS_ABABA" }); const direDawa = await yRepo.findOneBy({ code: "DIRE_DAWA" }); if (!addis || !direDawa) return; const routeRepo = manager.getRepository(Route); const milestoneRepo = manager.getRepository(RouteMilestone); const routeName = "Addis Ababa → Dire Dawa"; let route = await routeRepo.findOneBy({ name: routeName }); if (!route) { route = await routeRepo.save( routeRepo.create({ name: routeName, originYardId: addis.id, destinationYardId: direDawa.id, isActive: true, }), ); await milestoneRepo.save([ milestoneRepo.create({ routeId: route.id, yardId: addis.id, sequenceNo: 1, }), milestoneRepo.create({ routeId: route.id, yardId: direDawa.id, sequenceNo: 2, }), ]); this.logger.log("Seeded domestic route Addis Ababa → Dire Dawa"); } } private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { const twenty = await ctRepo.findOneByOrFail({ code: "20FT" }); const forty = await ctRepo.findOneByOrFail({ code: "40FT" }); const base = new Date("2026-01-01"); const rules = [ { containerTypeId: twenty.id, tradeDirection: "IMPORT", maxVgmTons: 26, effectiveFrom: base, isActive: true, }, { containerTypeId: twenty.id, tradeDirection: "EXPORT", maxVgmTons: 26, effectiveFrom: base, isActive: true, }, { containerTypeId: forty.id, tradeDirection: "IMPORT", maxVgmTons: 28, effectiveFrom: base, isActive: true, }, { containerTypeId: forty.id, tradeDirection: "EXPORT", maxVgmTons: 28, effectiveFrom: base, isActive: true, }, ]; for (const rule of rules) { const existing = await wlRepo.findOne({ where: { containerTypeId: rule.containerTypeId, tradeDirection: rule.tradeDirection, }, }); if (existing) { await wlRepo.update(existing.id, { maxVgmTons: rule.maxVgmTons, effectiveFrom: rule.effectiveFrom, }); } else { await wlRepo.insert(rule); } } this.logger.log("Seeded weight limit rules"); } private async seedPriorityConfigs(prRepo: any): Promise { // Priority rule = Wagon Block + Currency Block (both additive; see RuleEngineService.evaluate). // Combined with the service-type bonus the total priority score caps at 100: // service-type bonus (≤ 15) + wagon block (≤ 50) + currency block (≤ 35) = 100. // Wagon Count Block — independent, applies regardless of currency. Max 50. // Currency Block — applies only to the matching payment currency, within the wagon range. Max 35. const rows = [ // ── Wagon Count Block ─────────────────────────────────────────────── { type: "WAGON", label: "Wagons 1–20", currency: null, minWagonCount: 1, maxWagonCount: 20, scorePoints: 0, displayOrder: 1 }, { type: "WAGON", label: "Wagons 21–30", currency: null, minWagonCount: 21, maxWagonCount: 30, scorePoints: 15, displayOrder: 2 }, { type: "WAGON", label: "Wagons 31–40", currency: null, minWagonCount: 31, maxWagonCount: 40, scorePoints: 30, displayOrder: 3 }, { type: "WAGON", label: "Wagons 41–50", currency: null, minWagonCount: 41, maxWagonCount: 50, scorePoints: 50, displayOrder: 4 }, // ── Payment Currency Block ────────────────────────────────────────── { type: "CURRENCY", label: "USD · Wagons 1–25", currency: "USD", minWagonCount: 1, maxWagonCount: 25, scorePoints: 15, displayOrder: 5 }, { type: "CURRENCY", label: "USD · Wagons 26–50", currency: "USD", minWagonCount: 26, maxWagonCount: 50, scorePoints: 35, displayOrder: 6 }, { type: "CURRENCY", label: "ETB · Wagons 1–50", currency: "ETB", minWagonCount: 1, maxWagonCount: 50, scorePoints: 0, displayOrder: 7 }, ]; for (const row of rows) { const existing = await prRepo.findOne({ where: { type: row.type, label: row.label }, withDeleted: true, }); if (existing) { await prRepo.save({ ...existing, ...row, isActive: true, deletedAt: null }); } else { await prRepo.save(prRepo.create({ ...row, isActive: true })); } } this.logger.log("Seeded priority configs"); } private async seedRates( rRepo: any, ctByCode: Map, cargoByCode: Map, ): Promise { const effectiveFrom = new Date("2026-01-01"); const now = new Date(); // Each rate is self-describing: `appliesTo` + `trigger` decide how the // engine uses it. trigger=ALWAYS → base freight; anything else → a // surcharge that stacks additively when the booking matches. const rateData = [ // ── Container base freight ────────────────────────────────────────── { appliesTo: "CONTAINER", trigger: "ALWAYS", rateType: "CONTAINER_IMPORT", tradeDirection: "IMPORT", containerTypeId: ctByCode.get("20FT")!.id, rateValue: 800, rateUnit: "PER_CONTAINER" }, { appliesTo: "CONTAINER", trigger: "ALWAYS", rateType: "CONTAINER_IMPORT", tradeDirection: "IMPORT", containerTypeId: ctByCode.get("40FT")!.id, rateValue: 1200, rateUnit: "PER_CONTAINER" }, { appliesTo: "CONTAINER", trigger: "ALWAYS", rateType: "CONTAINER_EXPORT", tradeDirection: "EXPORT", containerTypeId: ctByCode.get("20FT")!.id, rateValue: 600, rateUnit: "PER_CONTAINER" }, { appliesTo: "CONTAINER", trigger: "ALWAYS", rateType: "CONTAINER_EXPORT", tradeDirection: "EXPORT", containerTypeId: ctByCode.get("40FT")!.id, rateValue: 900, rateUnit: "PER_CONTAINER" }, { appliesTo: "CONTAINER", trigger: "ALWAYS", rateType: "CONTAINER_IMPORT", tradeDirection: "IMPORT", containerTypeId: null, rateValue: 1000, rateUnit: "PER_CONTAINER" }, { appliesTo: "CONTAINER", trigger: "ALWAYS", rateType: "CONTAINER_EXPORT", tradeDirection: "EXPORT", containerTypeId: null, rateValue: 750, rateUnit: "PER_CONTAINER" }, // ── Intercity base freight ────────────────────────────────────────── { appliesTo: "INTERCITY", trigger: "ALWAYS", rateType: "INTERCITY_CONTAINER", containerTypeId: ctByCode.get("20FT")!.id, rateValue: 350, rateUnit: "PER_CONTAINER" }, { appliesTo: "INTERCITY", trigger: "ALWAYS", rateType: "INTERCITY_CONTAINER", containerTypeId: ctByCode.get("40FT")!.id, rateValue: 550, rateUnit: "PER_CONTAINER" }, { appliesTo: "INTERCITY", trigger: "ALWAYS", rateType: "INTERCITY_CONTAINER", containerTypeId: null, rateValue: 400, rateUnit: "PER_CONTAINER" }, { appliesTo: "INTERCITY", trigger: "ALWAYS", rateType: "INTERCITY_BULK", rateValue: 35, rateUnit: "PER_TON" }, // ── Bulk base freight (by leaf cargo type where known) ────────────── { appliesTo: "BULK", trigger: "ALWAYS", rateType: "BULK_IMPORT", tradeDirection: "IMPORT", cargoTypeId: cargoByCode.get("GRAIN")?.id ?? null, rateValue: 50, rateUnit: "PER_TON" }, { appliesTo: "BULK", trigger: "ALWAYS", rateType: "BULK_EXPORT", tradeDirection: "EXPORT", cargoTypeId: cargoByCode.get("GRAIN")?.id ?? null, rateValue: 40, rateUnit: "PER_TON" }, { appliesTo: "BULK", trigger: "ALWAYS", rateType: "BULK_IMPORT", tradeDirection: "IMPORT", cargoTypeId: null, rateValue: 50, rateUnit: "PER_TON" }, { appliesTo: "BULK", trigger: "ALWAYS", rateType: "BULK_EXPORT", tradeDirection: "EXPORT", cargoTypeId: null, rateValue: 40, rateUnit: "PER_TON" }, // ── Surcharges (trigger-based) ────────────────────────────────────── { appliesTo: "OTHER", trigger: "OVERWEIGHT", rateType: "OVERWEIGHT_PER_TON", rateValue: 25, rateUnit: "PER_TON" }, { appliesTo: "OTHER", trigger: "HAZARDOUS", rateType: "HAZARD_SURCHARGE", rateValue: 150, rateUnit: "FLAT" }, // Reefer surcharge scales with the freight shape: container bookings bill // per reefer container, bulk bookings bill per ton. The engine now honors // each rate's unit, so both rows can coexist — only the matching one // produces a non-zero line (the other multiplies by 0 and is dropped). // Small test values (< 20) so the surcharge stays a minor add for now. { appliesTo: "OTHER", trigger: "REEFER", rateType: "REEFER_SURCHARGE", rateValue: 15, rateUnit: "PER_CONTAINER" }, { appliesTo: "OTHER", trigger: "REEFER", rateType: "REEFER_SURCHARGE", rateValue: 2, rateUnit: "PER_TON" }, { appliesTo: "OTHER", trigger: "SHIPPING_LINE", rateType: "DOUBLE_HANDLING", rateValue: 100, rateUnit: "PER_CONTAINER" }, { appliesTo: "OTHER", trigger: "CONSOLIDATION", rateType: "LASHING", rateValue: 50, rateUnit: "PER_CONTAINER" }, ]; // Idempotent: insert each canonical rate only if no row with the same // signature already exists. Re-running the seeder must NOT accumulate // duplicate rows — duplicated surcharge rates would otherwise repeat on // every booking's price breakdown. const signature = (r: { rateType: string; rateUnit: string; rateValue: number; currency: string; containerTypeId?: string | null; cargoTypeId?: string | null; }) => [ r.rateType, r.rateUnit, Number(r.rateValue), r.currency, r.containerTypeId ?? "", r.cargoTypeId ?? "", ].join("|"); const existing: Rate[] = await rRepo.find(); const existingBySignature = new Set(existing.map((r) => signature(r))); const toCreate = rateData .map((d) => ({ currency: "USD", ...d, status: "LIVE" as const, proposedByStaffId: STAFF_USER_ID, approvedByCeoId: CEO_USER_ID, approvedAt: now, effectiveFrom, })) .filter((d) => !existingBySignature.has(signature(d))); if (toCreate.length === 0) { this.logger.log("Rates already seeded — skipping (idempotent)"); return existing; } const created = await rRepo.save(toCreate.map((d) => rRepo.create(d))); this.logger.log(`Seeded ${created.length} new rate(s)`); return [...existing, ...created]; } private async seedDraftBookings( ctByCode: Map, yardByCode: Map, stByCode: Map, slByCode: Map, cargoByCode: Map, ): Promise { const djibouti = yardByCode.get("DJIBOUTI"); const addis = yardByCode.get("ADDIS_ABABA"); const railContainer = stByCode.get("RAIL_CONTAINER"); const railBulk = stByCode.get("RAIL_BULK"); const maersk = slByCode.get("MAERSK"); const grain = cargoByCode.get("GRAIN"); const twenty = ctByCode.get("20FT"); const forty = ctByCode.get("40FT"); const twentyReefer = ctByCode.get("20FT_REEFER"); const missing: string[] = []; if (!djibouti) missing.push("yard:DJIBOUTI"); if (!addis) missing.push("yard:ADDIS_ABABA"); if (!railContainer) missing.push("serviceType:RAIL_CONTAINER"); if (!railBulk) missing.push("serviceType:RAIL_BULK"); if (!grain) missing.push("cargoType:GRAIN"); if (!twenty) missing.push("containerType:20FT"); if (!forty) missing.push("containerType:40FT"); if (!twentyReefer) missing.push("containerType:20FT_REEFER"); if (missing.length > 0) { this.logger.warn(`seedDraftBookings: skipping — missing reference data: ${missing.join(", ")}`); return; } const drafts = [ { reference: "BKG-PRICE-001", description: "Standard 20FT container import — base rail only", freightType: "CONTAINER" as const, tradeDirection: "IMPORT", paymentCurrency: "USD", serviceTypeId: railContainer.id, originYardId: djibouti.id, destinationYardId: addis.id, isHazardous: false, shippingLineId: null, cargoTypeId: null, cargoTotalWeightVgm: 250, containers: [ { containerTypeId: twenty.id, quantity: 10, vgmPerUnitTons: 25 }, ], expectedBaseRate: 800, expectedSurcharges: [], }, { reference: "BKG-PRICE-002", description: "40FT container import + hazardous surcharge", freightType: "CONTAINER" as const, tradeDirection: "IMPORT", paymentCurrency: "USD", serviceTypeId: railContainer.id, originYardId: djibouti.id, destinationYardId: addis.id, isHazardous: true, shippingLineId: null, cargoTypeId: null, cargoTotalWeightVgm: 135, containers: [ { containerTypeId: forty.id, quantity: 5, vgmPerUnitTons: 27 }, ], expectedBaseRate: 1200, expectedSurcharges: ["HAZARDOUS_CARGO"], }, { reference: "BKG-PRICE-003", description: "20FT container import + shipping line (USD)", freightType: "CONTAINER" as const, tradeDirection: "IMPORT", paymentCurrency: "USD", serviceTypeId: railContainer.id, originYardId: djibouti.id, destinationYardId: addis.id, isHazardous: false, shippingLineId: maersk.id, cargoTypeId: null, cargoTotalWeightVgm: 480, containers: [ { containerTypeId: twenty.id, quantity: 20, vgmPerUnitTons: 24 }, ], expectedBaseRate: 800, expectedSurcharges: ["SHIPPING_LINE_FEE"], }, { reference: "BKG-PRICE-004", description: "40FT container import + consolidation (USD)", freightType: "CONTAINER" as const, tradeDirection: "IMPORT", paymentCurrency: "USD", serviceTypeId: railContainer.id, originYardId: djibouti.id, destinationYardId: addis.id, isHazardous: false, shippingLineId: null, cargoTypeId: null, cargoTotalWeightVgm: 224, containers: [ { containerTypeId: forty.id, quantity: 8, vgmPerUnitTons: 28 }, ], expectedBaseRate: 1200, expectedSurcharges: ["CONSOLIDATION_FEE"], }, { reference: "BKG-PRICE-005", description: "Bulk import — grain", freightType: "BULK" as const, tradeDirection: "IMPORT", paymentCurrency: "USD", serviceTypeId: railBulk.id, originYardId: djibouti.id, destinationYardId: addis.id, isHazardous: false, shippingLineId: null, cargoTypeId: grain.id, cargoTotalWeightVgm: 500, containers: [], expectedBaseRate: 50, expectedSurcharges: [], }, { reference: "BKG-PRICE-006", description: "20FT reefer container import + reefer surcharge", freightType: "CONTAINER" as const, tradeDirection: "IMPORT", paymentCurrency: "USD", serviceTypeId: railContainer.id, originYardId: djibouti.id, destinationYardId: addis.id, isHazardous: false, shippingLineId: null, cargoTypeId: null, cargoTotalWeightVgm: 75, containers: [ { containerTypeId: twentyReefer.id, quantity: 3, vgmPerUnitTons: 25 }, ], expectedBaseRate: 800, expectedSurcharges: ["REEFER_CARGO"], }, { reference: "BKG-PRICE-007", description: "20FT container import + overweight (30t > 26t limit)", freightType: "CONTAINER" as const, tradeDirection: "IMPORT", paymentCurrency: "USD", serviceTypeId: railContainer.id, originYardId: djibouti.id, destinationYardId: addis.id, isHazardous: false, shippingLineId: null, cargoTypeId: null, cargoTotalWeightVgm: 300, containers: [ { containerTypeId: twenty.id, quantity: 10, vgmPerUnitTons: 30 }, ], expectedBaseRate: 800, expectedSurcharges: ["OVERWEIGHT_CARGO"], }, ]; this.logger.log(`Seeded ${drafts.length} DRAFT bookings for pricing`); } }