mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
635 lines
26 KiB
TypeScript
635 lines
26 KiB
TypeScript
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";
|
||
import { deriveTradeDirection } from "../common/derive-trade-direction.util";
|
||
|
||
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, 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<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,
|
||
isReefer: false,
|
||
isOpenTop: false,
|
||
isActive: true,
|
||
displayOrder: 1,
|
||
},
|
||
{
|
||
code: "40FT",
|
||
label: "40FT Standard",
|
||
sizeFt: 40,
|
||
isReefer: false,
|
||
isOpenTop: false,
|
||
isActive: true,
|
||
displayOrder: 2,
|
||
},
|
||
{
|
||
code: "20FT_REEFER",
|
||
label: "20FT Reefer",
|
||
sizeFt: 20,
|
||
isReefer: true,
|
||
isOpenTop: false,
|
||
isActive: true,
|
||
displayOrder: 3,
|
||
},
|
||
{
|
||
code: "40FT_REEFER",
|
||
label: "40FT Reefer",
|
||
sizeFt: 40,
|
||
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<void> {
|
||
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<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);
|
||
let route = await routeRepo.findOne({
|
||
where: { originYardId: addis.id, destinationYardId: direDawa.id },
|
||
});
|
||
if (!route) {
|
||
route = await routeRepo.save(
|
||
routeRepo.create({
|
||
originYardId: addis.id,
|
||
destinationYardId: direDawa.id,
|
||
status: 'AVAILABLE',
|
||
direction: deriveTradeDirection(addis, direDawa),
|
||
}),
|
||
);
|
||
await milestoneRepo.save([
|
||
milestoneRepo.create({
|
||
routeId: route.id,
|
||
yardId: addis.id,
|
||
sequenceNo: 1,
|
||
distanceKm: 0,
|
||
}),
|
||
milestoneRepo.create({
|
||
routeId: route.id,
|
||
yardId: direDawa.id,
|
||
sequenceNo: 2,
|
||
distanceKm: 445,
|
||
}),
|
||
]);
|
||
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 rules = [
|
||
{ containerTypeId: twenty.id, tradeDirection: "IMPORT", maxVgmTons: 26 },
|
||
{ containerTypeId: twenty.id, tradeDirection: "EXPORT", maxVgmTons: 26 },
|
||
{ containerTypeId: forty.id, tradeDirection: "IMPORT", maxVgmTons: 28 },
|
||
{ containerTypeId: forty.id, tradeDirection: "EXPORT", maxVgmTons: 28 },
|
||
];
|
||
|
||
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 });
|
||
} 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 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<string, any>,
|
||
cargoByCode: Map<string, any>,
|
||
): Promise<Rate[]> {
|
||
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" },
|
||
// Empty-container return service — container contracts opted in at
|
||
// creation; bills per container on WITH_RETURN bookings.
|
||
{ appliesTo: "OTHER", trigger: "WITH_RETURN", rateType: "RETURN_SURCHARGE", rateValue: 20, rateUnit: "PER_CONTAINER" },
|
||
{ appliesTo: "OTHER", trigger: "SHIPPING_LINE", rateType: "DOUBLE_HANDLING", rateValue: 100, rateUnit: "PER_CONTAINER" },
|
||
{ appliesTo: "OTHER", trigger: "CONSOLIDATION", rateType: "LASHING", rateValue: 50, rateUnit: "PER_CONTAINER" },
|
||
// Cargo-securing / lashing — bulk-only, fires when the cargo type has
|
||
// hasLashing. Sold per direction; commodity-wide catch-alls seeded here,
|
||
// commodity-specific rates are configured by the rates team.
|
||
{ appliesTo: "OTHER", trigger: "LASHING", rateType: "LASHING", tradeDirection: "IMPORT", rateValue: 40, rateUnit: "PER_TON" },
|
||
{ appliesTo: "OTHER", trigger: "LASHING", rateType: "LASHING", tradeDirection: "EXPORT", rateValue: 40, rateUnit: "PER_TON" },
|
||
// ── First/last-mile road haulage (per km) — drives the mile invoices ──
|
||
{ appliesTo: "OTHER", trigger: "ALWAYS", rateType: "FIRST_MILE", rateValue: 20, rateUnit: "PER_KM" },
|
||
{ appliesTo: "OTHER", trigger: "ALWAYS", rateType: "LAST_MILE", rateValue: 25, rateUnit: "PER_KM" },
|
||
];
|
||
|
||
// 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,
|
||
}))
|
||
.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`);
|
||
}
|
||
}
|