Files
edr-platform/apps/edr-freight-api/src/seed/pricing-data.seeder.ts
2026-07-23 14:18:24 +00:00

635 lines
26 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";
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 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 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`);
}
}