mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 03:05:42 +00:00
Release Order plus Storage Allocation Rule and fee
This commit is contained in:
@@ -386,9 +386,7 @@ export class DemoBookingsSeeder {
|
||||
shippingLineId: null,
|
||||
cargoTotalWeightVgm: demoBooking.totalWeightTons,
|
||||
isHazardous: false,
|
||||
paymentCurrency: "ETB",
|
||||
allowConsolidation: false,
|
||||
priorityScore: 0,
|
||||
paymentCurrency: "ETB", priorityScore: 0,
|
||||
versionNumber: 1,
|
||||
},
|
||||
{ conflictPaths: { reference: true } },
|
||||
@@ -459,9 +457,7 @@ export class DemoBookingsSeeder {
|
||||
shippingLineId: null,
|
||||
cargoTotalWeightVgm: demoBulk.totalWeightTons,
|
||||
isHazardous: false,
|
||||
paymentCurrency: "USD",
|
||||
allowConsolidation: false,
|
||||
priorityScore: 10,
|
||||
paymentCurrency: "USD", priorityScore: 10,
|
||||
schedulingStatus: "HOLDING",
|
||||
versionNumber: 1,
|
||||
},
|
||||
|
||||
@@ -20,12 +20,13 @@ import { DEFAULT_APPROVAL_RULE_ROWS } from '../modules/rule-engine/approval-rule
|
||||
const EDR_ORG_KEY = 'edr_freight';
|
||||
const MIN_WAGONS_PER_TYPE = 100;
|
||||
|
||||
/** The four demo staff users, each mapped to a seeded freight role. */
|
||||
/** The demo staff users, each mapped to a seeded freight role. */
|
||||
const DEMO_STAFF_USERS = [
|
||||
{ email: 'marketing@edr.local', username: 'marketing', roleKey: 'edr_marketing' },
|
||||
{ email: 'operations@edr.local', username: 'operations', roleKey: 'edr_operations_officer' },
|
||||
{ email: 'director@edr.local', username: 'director', roleKey: 'edr_director' },
|
||||
{ email: 'ceo@edr.local', username: 'ceo', roleKey: 'edr_ceo' },
|
||||
{ email: 'gl@edr.local', username: 'gl', roleKey: 'edr_global_logistics' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -237,6 +237,11 @@ export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [
|
||||
name: { en: "EDR Marketing" },
|
||||
permissionKeys: [...ROLE_PERMISSION_PRESETS.marketing],
|
||||
},
|
||||
{
|
||||
key: "edr_global_logistics",
|
||||
name: { en: "EDR Global Logistics" },
|
||||
permissionKeys: [...ROLE_PERMISSION_PRESETS.globalLogistics],
|
||||
},
|
||||
{
|
||||
key: "edr_org_manager",
|
||||
name: { en: "EDR Org Manager" },
|
||||
|
||||
@@ -192,6 +192,169 @@ const COMPANY_ONBOARDING_DOCUMENTS: OnboardingDocumentSetting[] = [
|
||||
const COMPANY_ONBOARDING_DESCRIPTION =
|
||||
"Required documents for external company onboarding, by company nationality.";
|
||||
|
||||
// ── Clearance document settings ────────────────────────────────────────────
|
||||
// Operation/clearance documents collected after contract counter-sign, resolved
|
||||
// at runtime from (operationType, freightType, includesCustoms). The `entity`
|
||||
// is "booking_clearance" so the backoffice file-settings editor can filter them.
|
||||
// Two kinds of set per customs category: a CUSTOMER-INPUT set (the customer
|
||||
// uploads) and a GL-OUTPUT set (Global Logistics uploads the customs outputs).
|
||||
|
||||
const JPG_EXTENSIONS = ["jpg", "jpeg", "png", "pdf"];
|
||||
const CLEARANCE_ENTITY = "booking_clearance";
|
||||
|
||||
/** Build a clearance field with sensible defaults; `critical` marks isRequired. */
|
||||
function clearanceField(
|
||||
fileKey: string,
|
||||
fileLabel: string,
|
||||
displayOrder: number,
|
||||
opts?: { required?: boolean; help?: string; extensions?: string[] },
|
||||
): OnboardingField {
|
||||
return {
|
||||
fileKey,
|
||||
fileLabel,
|
||||
helpText: opts?.help ?? "",
|
||||
isRequired: opts?.required ?? true,
|
||||
isMultiple: false,
|
||||
maxFiles: 1,
|
||||
allowedExtensions: opts?.extensions ?? DOC_EXTENSIONS,
|
||||
maxSizeMb: 10,
|
||||
displayOrder,
|
||||
};
|
||||
}
|
||||
|
||||
/** Documents shared by every container import category (with/without customs). */
|
||||
const IMPORT_CONTAINER_FIELDS: OnboardingField[] = [
|
||||
clearanceField("commercial_invoice", "Commercial Invoice", 1),
|
||||
clearanceField("packing_list", "Packing List", 2),
|
||||
clearanceField("import_license", "Import License", 3),
|
||||
clearanceField("certificate_of_origin", "Certificate of Origin", 4),
|
||||
clearanceField(
|
||||
"external_freight_cost",
|
||||
"External Freight Cost / Checkup Documentation",
|
||||
5,
|
||||
),
|
||||
clearanceField("bill_of_lading", "Bill of Lading / Railway Bill", 6),
|
||||
clearanceField("vgm", "Verified Gross Mass (VGM)", 7, { required: true }),
|
||||
clearanceField("release_order", "Release Order", 8, { required: true }),
|
||||
];
|
||||
|
||||
/** Documents shared by every container export category (with/without customs). */
|
||||
const EXPORT_CONTAINER_FIELDS: OnboardingField[] = [
|
||||
clearanceField("booking_confirmation", "Booking Confirmation", 1),
|
||||
clearanceField("commercial_invoice", "Commercial Invoice", 2),
|
||||
clearanceField("packing_list", "Packing List", 3),
|
||||
clearanceField("shipping_instruction", "Shipping Instruction", 4),
|
||||
clearanceField("bank_permit", "Bank Permit", 5),
|
||||
clearanceField("export_license", "Export License", 6),
|
||||
clearanceField("vgm_letter", "VGM Letter", 7, { required: true }),
|
||||
clearanceField("railway_bill", "Railway Bill", 8),
|
||||
clearanceField("delegation_letter", "Delegation Letter / POA", 9, {
|
||||
required: false,
|
||||
help: "Required only if EDR manages all transit activity.",
|
||||
}),
|
||||
];
|
||||
|
||||
/** Bulk import documents (shorter, transit-focused set). */
|
||||
const IMPORT_BULK_FIELDS: OnboardingField[] = [
|
||||
clearanceField("packing_list", "Packing List", 1, { required: true }),
|
||||
clearanceField("bill_of_loading", "Bill of Loading", 2, { required: true }),
|
||||
clearanceField("port_invoice", "Port Invoice", 3),
|
||||
];
|
||||
|
||||
/** Bulk export documents (transit/customs corridor docs). */
|
||||
const EXPORT_BULK_FIELDS: OnboardingField[] = [
|
||||
clearanceField("release_order_djibouti", "Release Order (Djibouti)", 1),
|
||||
clearanceField("port_gate_pass", "Port Gate Pass", 2),
|
||||
clearanceField("port_invoice", "Port Invoice", 3),
|
||||
];
|
||||
|
||||
/** GL-uploaded customs output documents (import container). */
|
||||
const IMPORT_CONTAINER_OUTPUT_FIELDS: OnboardingField[] = [
|
||||
clearanceField("im4", "IM4 — Permanent Import Document", 1),
|
||||
clearanceField("im5", "IM5 — Temporary Import Document", 2, {
|
||||
required: false,
|
||||
}),
|
||||
clearanceField("transit_permitted", "Transit Permitted Screenshot", 3, {
|
||||
extensions: JPG_EXTENSIONS,
|
||||
}),
|
||||
];
|
||||
|
||||
/** GL-uploaded customs output documents (export container). */
|
||||
const EXPORT_CONTAINER_OUTPUT_FIELDS: OnboardingField[] = [
|
||||
clearanceField("ex3", "EX3 — Permanent Export Document", 1),
|
||||
clearanceField("ex8", "EX8 — Export Transit Document", 2),
|
||||
clearanceField("export_release", "Export Release", 3),
|
||||
clearanceField("t1", "T1 — Transport Document", 4),
|
||||
];
|
||||
|
||||
const CLEARANCE_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [
|
||||
// ── Customer-input sets ──
|
||||
{
|
||||
code: "clearance_import_container_with_customs",
|
||||
label: "Import container clearance documents (with customs)",
|
||||
entity: CLEARANCE_ENTITY,
|
||||
fields: IMPORT_CONTAINER_FIELDS,
|
||||
},
|
||||
{
|
||||
code: "clearance_import_container_without_customs",
|
||||
label: "Import container documents (without customs)",
|
||||
entity: CLEARANCE_ENTITY,
|
||||
fields: IMPORT_CONTAINER_FIELDS,
|
||||
},
|
||||
{
|
||||
code: "clearance_export_container_with_customs",
|
||||
label: "Export container clearance documents (with customs)",
|
||||
entity: CLEARANCE_ENTITY,
|
||||
fields: EXPORT_CONTAINER_FIELDS,
|
||||
},
|
||||
{
|
||||
code: "clearance_export_container_without_customs",
|
||||
label: "Export container documents (without customs)",
|
||||
entity: CLEARANCE_ENTITY,
|
||||
fields: EXPORT_CONTAINER_FIELDS,
|
||||
},
|
||||
{
|
||||
code: "clearance_import_bulk_with_customs",
|
||||
label: "Import bulk clearance documents (with customs)",
|
||||
entity: CLEARANCE_ENTITY,
|
||||
fields: IMPORT_BULK_FIELDS,
|
||||
},
|
||||
{
|
||||
code: "clearance_import_bulk_without_customs",
|
||||
label: "Import bulk documents (without customs)",
|
||||
entity: CLEARANCE_ENTITY,
|
||||
fields: IMPORT_BULK_FIELDS,
|
||||
},
|
||||
{
|
||||
code: "clearance_export_bulk_with_customs",
|
||||
label: "Export bulk clearance documents (with customs)",
|
||||
entity: CLEARANCE_ENTITY,
|
||||
fields: EXPORT_BULK_FIELDS,
|
||||
},
|
||||
{
|
||||
code: "clearance_export_bulk_without_customs",
|
||||
label: "Export bulk documents (without customs)",
|
||||
entity: CLEARANCE_ENTITY,
|
||||
fields: EXPORT_BULK_FIELDS,
|
||||
},
|
||||
// ── GL-output sets (customs only) ──
|
||||
{
|
||||
code: "clearance_output_import_container",
|
||||
label: "Customs output documents (import container)",
|
||||
entity: CLEARANCE_ENTITY,
|
||||
fields: IMPORT_CONTAINER_OUTPUT_FIELDS,
|
||||
},
|
||||
{
|
||||
code: "clearance_output_export_container",
|
||||
label: "Customs output documents (export container)",
|
||||
entity: CLEARANCE_ENTITY,
|
||||
fields: EXPORT_CONTAINER_OUTPUT_FIELDS,
|
||||
},
|
||||
];
|
||||
|
||||
const CLEARANCE_DESCRIPTION =
|
||||
"Operation/clearance documents collected after contract execution, by operation, freight type and customs.";
|
||||
|
||||
@Injectable()
|
||||
export class FileUploadSettingsSeeder {
|
||||
private readonly logger = new Logger(FileUploadSettingsSeeder.name);
|
||||
@@ -203,12 +366,25 @@ export class FileUploadSettingsSeeder {
|
||||
const settingRepository = manager.getRepository(FileUploadSetting);
|
||||
const fieldRepository = manager.getRepository(FileUploadField);
|
||||
|
||||
for (const documentSetting of COMPANY_ONBOARDING_DOCUMENTS) {
|
||||
const allSettings: Array<
|
||||
OnboardingDocumentSetting & { description: string }
|
||||
> = [
|
||||
...COMPANY_ONBOARDING_DOCUMENTS.map((s) => ({
|
||||
...s,
|
||||
description: COMPANY_ONBOARDING_DESCRIPTION,
|
||||
})),
|
||||
...CLEARANCE_DOCUMENT_SETTINGS.map((s) => ({
|
||||
...s,
|
||||
description: CLEARANCE_DESCRIPTION,
|
||||
})),
|
||||
];
|
||||
|
||||
for (const documentSetting of allSettings) {
|
||||
await settingRepository.upsert(
|
||||
{
|
||||
code: documentSetting.code,
|
||||
label: documentSetting.label,
|
||||
description: COMPANY_ONBOARDING_DESCRIPTION,
|
||||
description: documentSetting.description,
|
||||
entity: documentSetting.entity,
|
||||
},
|
||||
{
|
||||
@@ -245,7 +421,7 @@ export class FileUploadSettingsSeeder {
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
"Ensured company onboarding file upload settings for external companies",
|
||||
"Ensured company onboarding + booking clearance file upload settings",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ export const RULE_ENGINE_RESOURCE_SLUGS = [
|
||||
'yards',
|
||||
'shipping-lines',
|
||||
'weight-limit-rules',
|
||||
'surcharge-types',
|
||||
'priority-configs',
|
||||
'rates',
|
||||
'approval-rules',
|
||||
@@ -52,6 +51,9 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm('a1000001-0001-4000-8000-00000000000c', 'edr_freight_app:bookings:payment_verify', 'Verify payment'),
|
||||
perm('a1000001-0001-4000-8000-00000000000d', 'edr_freight_app:bookings:operations', 'Booking operations'),
|
||||
perm('a1000001-0001-4000-8000-00000000000e', 'edr_freight_app:bookings:cancel', 'Cancel booking'),
|
||||
perm('a1000001-0001-4000-8000-000000000020', 'edr_freight_app:bookings:review_documents', 'Review clearance documents'),
|
||||
perm('a1000001-0001-4000-8000-000000000021', 'edr_freight_app:bookings:upload_clearance_output', 'Upload customs output documents'),
|
||||
perm('a1000001-0001-4000-8000-000000000022', 'edr_freight_app:bookings:finalize_clearance', 'Finalize document clearance'),
|
||||
perm('a1000001-0001-4000-8000-00000000000f', 'edr_freight_app:train_scheduling:view', 'View train scheduling'),
|
||||
perm('a1000001-0001-4000-8000-000000000010', 'edr_freight_app:train_scheduling:manage', 'Manage train scheduling'),
|
||||
perm('a1000001-0001-4000-8000-000000000011', 'edr_freight_app:fleet:view', 'View fleet'),
|
||||
@@ -67,7 +69,6 @@ const RULE_ENGINE_PERMISSION_IDS: Record<RuleEngineResourceSlug, { view: string;
|
||||
yards: { view: 'b2000001-0001-4000-8000-000000000007', manage: 'b2000001-0001-4000-8000-000000000008' },
|
||||
'shipping-lines': { view: 'b2000001-0001-4000-8000-000000000009', manage: 'b2000001-0001-4000-8000-00000000000a' },
|
||||
'weight-limit-rules': { view: 'b2000001-0001-4000-8000-00000000000b', manage: 'b2000001-0001-4000-8000-00000000000c' },
|
||||
'surcharge-types': { view: 'b2000001-0001-4000-8000-00000000000d', manage: 'b2000001-0001-4000-8000-00000000000e' },
|
||||
'priority-configs': { view: 'b2000001-0001-4000-8000-00000000000f', manage: 'b2000001-0001-4000-8000-000000000010' },
|
||||
rates: { view: 'b2000001-0001-4000-8000-000000000011', manage: 'b2000001-0001-4000-8000-000000000012' },
|
||||
'approval-rules': { view: 'b2000001-0001-4000-8000-000000000013', manage: 'b2000001-0001-4000-8000-000000000014' },
|
||||
@@ -107,6 +108,9 @@ export const FREIGHT_PERMS = {
|
||||
signStaff: 'edr_freight_app:bookings:sign_staff',
|
||||
operations: 'edr_freight_app:bookings:operations',
|
||||
cancel: 'edr_freight_app:bookings:cancel',
|
||||
reviewDocuments: 'edr_freight_app:bookings:review_documents',
|
||||
uploadClearanceOutput: 'edr_freight_app:bookings:upload_clearance_output',
|
||||
finalizeClearance: 'edr_freight_app:bookings:finalize_clearance',
|
||||
},
|
||||
trainScheduling: {
|
||||
view: 'edr_freight_app:train_scheduling:view',
|
||||
@@ -167,6 +171,14 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
...allRuleEngineViewKeys(),
|
||||
],
|
||||
finance: [FREIGHT_PERMS.bookings.view],
|
||||
// Global Logistics: reviews post-counter-sign clearance documents, uploads
|
||||
// customs output documents, and finalizes the clearance gate.
|
||||
globalLogistics: [
|
||||
FREIGHT_PERMS.bookings.view,
|
||||
FREIGHT_PERMS.bookings.reviewDocuments,
|
||||
FREIGHT_PERMS.bookings.uploadClearanceOutput,
|
||||
FREIGHT_PERMS.bookings.finalizeClearance,
|
||||
],
|
||||
// Marketing handles intake through contract (same as line staff here).
|
||||
marketing: [
|
||||
FREIGHT_PERMS.bookings.view,
|
||||
|
||||
@@ -18,6 +18,7 @@ const STAFF_USERS = [
|
||||
{ email: 'linestaff@edr.local', username: 'linestaff', roleKey: 'edr_line_staff' },
|
||||
{ email: 'director@edr.local', username: 'director', roleKey: 'edr_director' },
|
||||
{ email: 'ceo@edr.local', username: 'ceo', roleKey: 'edr_ceo' },
|
||||
{ email: 'gl@edr.local', username: 'gl', roleKey: 'edr_global_logistics' },
|
||||
] as const;
|
||||
|
||||
@Injectable()
|
||||
@@ -122,6 +123,8 @@ export class FreightStaffUsersSeeder {
|
||||
}
|
||||
});
|
||||
|
||||
this.logger.log('Ensured freight staff users (linestaff@, director@, ceo@)');
|
||||
this.logger.log(
|
||||
'Ensured freight staff users (linestaff@, director@, ceo@, gl@)',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { PriorityConfig } from "../modules/rule-engine/entities/priority-config.
|
||||
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 { SurchargeType } from "../modules/rule-engine/entities/surcharge-type.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";
|
||||
@@ -40,15 +39,13 @@ export class PricingDataSeeder {
|
||||
const containerTypes = await ctRepo.find();
|
||||
const ctByCode = new Map(containerTypes.map((ct) => [ct.code, ct]));
|
||||
|
||||
const rates = await this.seedRates(rRepo, ctByCode);
|
||||
const ratesByType = new Map<string, Rate[]>();
|
||||
for (const r of rates) {
|
||||
const key = `${r.rateType}|${r.currency}|${r.containerTypeId ?? ""}`;
|
||||
if (!ratesByType.has(key)) ratesByType.set(key, []);
|
||||
ratesByType.get(key)!.push(r);
|
||||
}
|
||||
// Clear booking cargo modifiers up front — they reference rate snapshots
|
||||
// that get recomputed when bookings are repriced.
|
||||
await manager.getRepository(BookingCargoModifier).createQueryBuilder().delete().execute();
|
||||
|
||||
await this.seedSurchargeTypes(manager, ratesByType);
|
||||
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]));
|
||||
@@ -180,7 +177,7 @@ export class PricingDataSeeder {
|
||||
includesFirstMile: true,
|
||||
includesLastMile: true,
|
||||
includesCustoms: true,
|
||||
priorityBonusPoints: 100,
|
||||
priorityBonusPoints: 15,
|
||||
isActive: true,
|
||||
displayOrder: 2,
|
||||
},
|
||||
@@ -192,7 +189,7 @@ export class PricingDataSeeder {
|
||||
includesFirstMile: false,
|
||||
includesLastMile: false,
|
||||
includesCustoms: false,
|
||||
priorityBonusPoints: 50,
|
||||
priorityBonusPoints: 10,
|
||||
isActive: true,
|
||||
displayOrder: 3,
|
||||
},
|
||||
@@ -246,6 +243,7 @@ export class PricingDataSeeder {
|
||||
{
|
||||
code: "GRAIN",
|
||||
cargoTypeName: "Grain / Cereals",
|
||||
showFreeTextBox: false,
|
||||
requiresDirectorApproval: false,
|
||||
isActive: true,
|
||||
displayOrder: 1,
|
||||
@@ -253,6 +251,7 @@ export class PricingDataSeeder {
|
||||
{
|
||||
code: "FERTILIZER",
|
||||
cargoTypeName: "Fertilizer",
|
||||
showFreeTextBox: false,
|
||||
requiresDirectorApproval: false,
|
||||
isActive: true,
|
||||
displayOrder: 2,
|
||||
@@ -260,6 +259,7 @@ export class PricingDataSeeder {
|
||||
{
|
||||
code: "CEMENT",
|
||||
cargoTypeName: "Cement / Clinker",
|
||||
showFreeTextBox: false,
|
||||
requiresDirectorApproval: false,
|
||||
isActive: true,
|
||||
displayOrder: 3,
|
||||
@@ -267,6 +267,7 @@ export class PricingDataSeeder {
|
||||
{
|
||||
code: "STEEL",
|
||||
cargoTypeName: "Steel / Rebar",
|
||||
showFreeTextBox: false,
|
||||
requiresDirectorApproval: true,
|
||||
isActive: true,
|
||||
displayOrder: 4,
|
||||
@@ -274,6 +275,7 @@ export class PricingDataSeeder {
|
||||
{
|
||||
code: "MACHINERY",
|
||||
cargoTypeName: "Heavy Machinery",
|
||||
showFreeTextBox: false,
|
||||
requiresDirectorApproval: true,
|
||||
isActive: true,
|
||||
displayOrder: 5,
|
||||
@@ -281,6 +283,7 @@ export class PricingDataSeeder {
|
||||
{
|
||||
code: "OTHER_BULK",
|
||||
cargoTypeName: "Other Bulk Cargo",
|
||||
showFreeTextBox: false,
|
||||
requiresDirectorApproval: false,
|
||||
isActive: true,
|
||||
displayOrder: 6,
|
||||
@@ -382,9 +385,11 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
||||
this.logger.log("Seeded weight limit rules");
|
||||
}
|
||||
private async seedPriorityConfigs(prRepo: any): Promise<void> {
|
||||
// Wagon Count Block — independent, applies regardless of currency.
|
||||
// Currency Block — applies only to the matching payment currency, within the wagon range.
|
||||
// Both blocks are additive (see RuleEngineService.evaluate).
|
||||
// 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 },
|
||||
@@ -392,7 +397,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
||||
{ 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: 17, displayOrder: 5 },
|
||||
{ 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 },
|
||||
];
|
||||
@@ -414,204 +419,84 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
||||
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();
|
||||
// await rRepo.createQueryBuilder().delete().execute();
|
||||
|
||||
// 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 = [
|
||||
{
|
||||
rateType: "CONTAINER_IMPORT",
|
||||
containerTypeId: ctByCode.get("20FT")!.id,
|
||||
currency: "USD",
|
||||
rateValue: 800,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "CONTAINER_IMPORT",
|
||||
containerTypeId: ctByCode.get("40FT")!.id,
|
||||
currency: "USD",
|
||||
rateValue: 1200,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "CONTAINER_EXPORT",
|
||||
containerTypeId: ctByCode.get("20FT")!.id,
|
||||
currency: "USD",
|
||||
rateValue: 600,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "CONTAINER_EXPORT",
|
||||
containerTypeId: ctByCode.get("40FT")!.id,
|
||||
currency: "USD",
|
||||
rateValue: 900,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "CONTAINER_IMPORT",
|
||||
containerTypeId: null,
|
||||
currency: "USD",
|
||||
rateValue: 1000,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "CONTAINER_EXPORT",
|
||||
containerTypeId: null,
|
||||
currency: "USD",
|
||||
rateValue: 750,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "INTERCITY_CONTAINER",
|
||||
containerTypeId: ctByCode.get("20FT")!.id,
|
||||
currency: "USD",
|
||||
rateValue: 350,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "INTERCITY_CONTAINER",
|
||||
containerTypeId: ctByCode.get("40FT")!.id,
|
||||
currency: "USD",
|
||||
rateValue: 550,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "INTERCITY_CONTAINER",
|
||||
containerTypeId: null,
|
||||
currency: "USD",
|
||||
rateValue: 400,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "INTERCITY_BULK",
|
||||
containerTypeId: null,
|
||||
currency: "USD",
|
||||
rateValue: 35,
|
||||
rateUnit: "PER_TON",
|
||||
},
|
||||
{
|
||||
rateType: "BULK_IMPORT",
|
||||
containerTypeId: null,
|
||||
currency: "USD",
|
||||
rateValue: 50,
|
||||
rateUnit: "PER_TON",
|
||||
},
|
||||
{
|
||||
rateType: "BULK_EXPORT",
|
||||
containerTypeId: null,
|
||||
currency: "USD",
|
||||
rateValue: 40,
|
||||
rateUnit: "PER_TON",
|
||||
},
|
||||
{
|
||||
rateType: "OVERWEIGHT_PER_TON",
|
||||
containerTypeId: null,
|
||||
currency: "USD",
|
||||
rateValue: 25,
|
||||
rateUnit: "PER_TON",
|
||||
},
|
||||
{
|
||||
rateType: "HAZARD_SURCHARGE",
|
||||
containerTypeId: null,
|
||||
currency: "USD",
|
||||
rateValue: 150,
|
||||
rateUnit: "FLAT",
|
||||
},
|
||||
{
|
||||
rateType: "REEFER_SURCHARGE",
|
||||
containerTypeId: null,
|
||||
currency: "USD",
|
||||
rateValue: 200,
|
||||
rateUnit: "FLAT",
|
||||
},
|
||||
{
|
||||
rateType: "DOUBLE_HANDLING",
|
||||
containerTypeId: null,
|
||||
currency: "USD",
|
||||
rateValue: 100,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "LASHING",
|
||||
containerTypeId: null,
|
||||
currency: "USD",
|
||||
rateValue: 50,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
// ── 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" },
|
||||
];
|
||||
|
||||
const entities = rateData.map((d) =>
|
||||
rRepo.create({
|
||||
// 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",
|
||||
status: "LIVE" as const,
|
||||
proposedByStaffId: STAFF_USER_ID,
|
||||
approvedByCeoId: CEO_USER_ID,
|
||||
approvedAt: now,
|
||||
effectiveFrom,
|
||||
}),
|
||||
);
|
||||
return rRepo.save(entities);
|
||||
}
|
||||
}))
|
||||
.filter((d) => !existingBySignature.has(signature(d)));
|
||||
|
||||
private async seedSurchargeTypes(
|
||||
manager: any,
|
||||
ratesByType: Map<string, Rate[]>,
|
||||
): Promise<void> {
|
||||
const surRepo = manager.getRepository(SurchargeType);
|
||||
const bcmRepo = manager.getRepository(BookingCargoModifier);
|
||||
await bcmRepo.createQueryBuilder().delete().execute();
|
||||
const findRate = (rateType: string, currency: string) => {
|
||||
const key = `${rateType}|${currency}|`;
|
||||
const rates = ratesByType.get(key);
|
||||
return rates?.[0];
|
||||
};
|
||||
if (toCreate.length === 0) {
|
||||
this.logger.log("Rates already seeded — skipping (idempotent)");
|
||||
return existing;
|
||||
}
|
||||
|
||||
const hazardRateUsd = findRate("HAZARD_SURCHARGE", "USD");
|
||||
const reeferRateUsd = findRate("REEFER_SURCHARGE", "USD");
|
||||
const overweightRateUsd = findRate("OVERWEIGHT_PER_TON", "USD");
|
||||
const shipLineRateUsd = findRate("DOUBLE_HANDLING", "USD");
|
||||
const consolidRateUsd = findRate("LASHING", "USD");
|
||||
|
||||
await surRepo.createQueryBuilder().delete().execute();
|
||||
await surRepo.save([
|
||||
surRepo.create({
|
||||
code: "HAZARDOUS_CARGO",
|
||||
label: "Hazardous Cargo",
|
||||
triggerCondition: "CARGO_FLAG_HAZARDOUS",
|
||||
rateId: hazardRateUsd?.id,
|
||||
isActive: true,
|
||||
}),
|
||||
surRepo.create({
|
||||
code: "REEFER_CARGO",
|
||||
label: "Reefer Cargo",
|
||||
triggerCondition: "CARGO_FLAG_REEFER",
|
||||
rateId: reeferRateUsd?.id,
|
||||
isActive: true,
|
||||
}),
|
||||
surRepo.create({
|
||||
code: "OVERWEIGHT_CARGO",
|
||||
label: "Overweight Cargo",
|
||||
triggerCondition: "VGM_EXCEEDS_LIMIT",
|
||||
rateId: overweightRateUsd?.id,
|
||||
isActive: true,
|
||||
}),
|
||||
surRepo.create({
|
||||
code: "SHIPPING_LINE_FEE",
|
||||
label: "Shipping Line Fee",
|
||||
triggerCondition: "SHIPPING_LINE_MAPPED",
|
||||
rateId: shipLineRateUsd?.id,
|
||||
isActive: true,
|
||||
}),
|
||||
surRepo.create({
|
||||
code: "CONSOLIDATION_FEE",
|
||||
label: "Consolidation Fee",
|
||||
triggerCondition: "CONSOLIDATION_ENABLED",
|
||||
rateId: consolidRateUsd?.id,
|
||||
isActive: true,
|
||||
}),
|
||||
]);
|
||||
this.logger.log("Seeded surcharge types");
|
||||
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(
|
||||
@@ -621,15 +506,29 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
||||
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 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 = [
|
||||
{
|
||||
@@ -641,9 +540,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
||||
serviceTypeId: railContainer.id,
|
||||
originYardId: djibouti.id,
|
||||
destinationYardId: addis.id,
|
||||
isHazardous: false,
|
||||
allowConsolidation: false,
|
||||
shippingLineId: null,
|
||||
isHazardous: false, shippingLineId: null,
|
||||
cargoTypeId: null,
|
||||
cargoTotalWeightVgm: 250,
|
||||
containers: [
|
||||
@@ -661,9 +558,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
||||
serviceTypeId: railContainer.id,
|
||||
originYardId: djibouti.id,
|
||||
destinationYardId: addis.id,
|
||||
isHazardous: true,
|
||||
allowConsolidation: false,
|
||||
shippingLineId: null,
|
||||
isHazardous: true, shippingLineId: null,
|
||||
cargoTypeId: null,
|
||||
cargoTotalWeightVgm: 135,
|
||||
containers: [
|
||||
@@ -681,9 +576,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
||||
serviceTypeId: railContainer.id,
|
||||
originYardId: djibouti.id,
|
||||
destinationYardId: addis.id,
|
||||
isHazardous: false,
|
||||
allowConsolidation: false,
|
||||
shippingLineId: maersk.id,
|
||||
isHazardous: false, shippingLineId: maersk.id,
|
||||
cargoTypeId: null,
|
||||
cargoTotalWeightVgm: 480,
|
||||
containers: [
|
||||
@@ -701,9 +594,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
||||
serviceTypeId: railContainer.id,
|
||||
originYardId: djibouti.id,
|
||||
destinationYardId: addis.id,
|
||||
isHazardous: false,
|
||||
allowConsolidation: true,
|
||||
shippingLineId: null,
|
||||
isHazardous: false, shippingLineId: null,
|
||||
cargoTypeId: null,
|
||||
cargoTotalWeightVgm: 224,
|
||||
containers: [
|
||||
@@ -721,9 +612,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
||||
serviceTypeId: railBulk.id,
|
||||
originYardId: djibouti.id,
|
||||
destinationYardId: addis.id,
|
||||
isHazardous: false,
|
||||
allowConsolidation: false,
|
||||
shippingLineId: null,
|
||||
isHazardous: false, shippingLineId: null,
|
||||
cargoTypeId: grain.id,
|
||||
cargoTotalWeightVgm: 500,
|
||||
containers: [],
|
||||
@@ -739,9 +628,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
||||
serviceTypeId: railContainer.id,
|
||||
originYardId: djibouti.id,
|
||||
destinationYardId: addis.id,
|
||||
isHazardous: false,
|
||||
allowConsolidation: false,
|
||||
shippingLineId: null,
|
||||
isHazardous: false, shippingLineId: null,
|
||||
cargoTypeId: null,
|
||||
cargoTotalWeightVgm: 75,
|
||||
containers: [
|
||||
@@ -759,9 +646,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
||||
serviceTypeId: railContainer.id,
|
||||
originYardId: djibouti.id,
|
||||
destinationYardId: addis.id,
|
||||
isHazardous: false,
|
||||
allowConsolidation: false,
|
||||
shippingLineId: null,
|
||||
isHazardous: false, shippingLineId: null,
|
||||
cargoTypeId: null,
|
||||
cargoTotalWeightVgm: 300,
|
||||
containers: [
|
||||
|
||||
Reference in New Issue
Block a user