Files
edr-platform/apps/edr-freight-api/src/seed/pricing-data.seeder.ts

663 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<void> {
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, stRepo, 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,
stRepo: any,
yRepo: any,
slRepo: any,
): Promise<void> {
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 stRepo.upsert(
[
{
code: "RAIL_CONTAINER",
serviceName: "Rail Container Service",
description: "Standard rail container transport",
canBeBookedAlone: true,
includesFirstMile: false,
includesLastMile: false,
includesCustoms: false,
priorityBonusPoints: 0,
isActive: true,
displayOrder: 1,
},
{
code: "RAIL_FORWARDING",
serviceName: "Rail Forwarding Service",
description: "Rail transport with first/last mile and customs",
canBeBookedAlone: true,
includesFirstMile: true,
includesLastMile: true,
includesCustoms: true,
priorityBonusPoints: 15,
isActive: true,
displayOrder: 2,
},
{
code: "RAIL_BULK",
serviceName: "Rail Bulk Transport",
description: "Bulk commodity rail transport",
canBeBookedAlone: true,
includesFirstMile: false,
includesLastMile: false,
includesCustoms: false,
priorityBonusPoints: 10,
isActive: true,
displayOrder: 3,
},
],
{ 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 manager.getRepository(CargoType).upsert(
[
{
code: "GRAIN",
cargoTypeName: "Grain / Cereals",
showFreeTextBox: false,
requiresDirectorApproval: false,
isActive: true,
displayOrder: 1,
},
{
code: "FERTILIZER",
cargoTypeName: "Fertilizer",
showFreeTextBox: false,
requiresDirectorApproval: false,
isActive: true,
displayOrder: 2,
},
{
code: "CEMENT",
cargoTypeName: "Cement / Clinker",
showFreeTextBox: false,
requiresDirectorApproval: false,
isActive: true,
displayOrder: 3,
},
{
code: "STEEL",
cargoTypeName: "Steel / Rebar",
showFreeTextBox: false,
requiresDirectorApproval: true,
isActive: true,
displayOrder: 4,
},
{
code: "MACHINERY",
cargoTypeName: "Heavy Machinery",
showFreeTextBox: false,
requiresDirectorApproval: true,
isActive: true,
displayOrder: 5,
},
{
code: "OTHER_BULK",
cargoTypeName: "Other Bulk Cargo",
showFreeTextBox: false,
requiresDirectorApproval: false,
isActive: true,
displayOrder: 6,
},
],
{ conflictPaths: { code: true } },
);
}
private async seedDomesticRoute(manager: any, yRepo: any): Promise<void> {
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<void> {
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<void> {
// 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 120", currency: null, minWagonCount: 1, maxWagonCount: 20, scorePoints: 0, displayOrder: 1 },
{ type: "WAGON", label: "Wagons 2130", currency: null, minWagonCount: 21, maxWagonCount: 30, scorePoints: 15, displayOrder: 2 },
{ type: "WAGON", label: "Wagons 3140", currency: null, minWagonCount: 31, maxWagonCount: 40, scorePoints: 30, displayOrder: 3 },
{ type: "WAGON", label: "Wagons 4150", currency: null, minWagonCount: 41, maxWagonCount: 50, scorePoints: 50, displayOrder: 4 },
// ── Payment Currency Block ──────────────────────────────────────────
{ type: "CURRENCY", label: "USD · Wagons 125", currency: "USD", minWagonCount: 1, maxWagonCount: 25, scorePoints: 15, displayOrder: 5 },
{ type: "CURRENCY", label: "USD · Wagons 2650", currency: "USD", minWagonCount: 26, maxWagonCount: 50, scorePoints: 35, displayOrder: 6 },
{ type: "CURRENCY", label: "ETB · Wagons 150", 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<string, any>,
cargoByCode: Map<string, any>,
): Promise<Rate[]> {
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" },
{ appliesTo: "OTHER", trigger: "REEFER", rateType: "REEFER_SURCHARGE", rateValue: 200, rateUnit: "FLAT" },
{ 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<string, any>,
yardByCode: Map<string, any>,
stByCode: Map<string, any>,
slByCode: Map<string, any>,
cargoByCode: Map<string, any>,
): Promise<void> {
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`);
}
}