automation of loading and unloading

This commit is contained in:
Hagernesh
2026-06-17 22:33:06 +00:00
1637 changed files with 375027 additions and 20437 deletions

View File

@@ -13,7 +13,13 @@ import { Locomotive } from "../modules/locomotives/entities/locomotive.entity";
import { ServiceType } from "../modules/rule-engine/entities/service-type.entity";
import { Yard } from "../modules/rule-engine/entities/yard.entity";
import { WagonType } from "../modules/wagon-types/entities/wagon-type.entity";
import { CargoType } from "../modules/rule-engine/entities/cargo-type.entity";
import { ContainerType } from "../modules/rule-engine/entities/container-type.entity";
import { Container } from "../modules/container-management/entities/container.entity";
import { Route } from "../modules/routes/entities/route.entity";
import { RouteMilestone } from "../modules/routes/entities/route-milestone.entity";
import { Wagon } from "../modules/wagons/entities/wagon.entity";
import { WagonStatus } from "@edr/types";
const SEED_FLAG = "SEED_DEMO_BOOKINGS";
@@ -44,7 +50,7 @@ const CONTAINER_TYPES = [
const DEMO_BOOKINGS = [
{
reference: "BKG-CONT-001",
reference: "BKG_CONT_001",
containerCode: "40FT",
quantity: 20,
totalWeightTons: 500,
@@ -55,7 +61,7 @@ const DEMO_BOOKINGS = [
paymentStatus: "PAID",
},
{
reference: "BKG-CONT-002",
reference: "BKG_ONT_02",
containerCode: "20FT",
quantity: 10,
totalWeightTons: 300,
@@ -66,7 +72,7 @@ const DEMO_BOOKINGS = [
paymentStatus: "PAID",
},
{
reference: "BKG-CONT-003",
reference: "BKG_ONT_03",
containerCode: "40FT",
quantity: 15,
totalWeightTons: 450,
@@ -77,7 +83,7 @@ const DEMO_BOOKINGS = [
paymentStatus: "PAID",
},
{
reference: "BKG-CONT-007",
reference: "BKG_ONT_07",
containerCode: "20FT",
quantity: 6,
totalWeightTons: 180,
@@ -88,7 +94,7 @@ const DEMO_BOOKINGS = [
paymentStatus: "PAID",
},
{
reference: "BKG-CONT-008",
reference: "BKG_ONT_08",
containerCode: "40FT",
quantity: 4,
totalWeightTons: 120,
@@ -99,7 +105,7 @@ const DEMO_BOOKINGS = [
paymentStatus: "PAID",
},
{
reference: "BKG-CONT-009",
reference: "BKG_ONT_09",
containerCode: "20FT",
quantity: 5,
totalWeightTons: 110,
@@ -110,7 +116,7 @@ const DEMO_BOOKINGS = [
paymentStatus: "PAID",
},
{
reference: "BKG-CONT-004",
reference: "BKG_ONT_04",
containerCode: "40FT",
quantity: 12,
totalWeightTons: 360,
@@ -118,10 +124,10 @@ const DEMO_BOOKINGS = [
destinationCode: "DIRE_DAWA",
scheduledDate: "2026-06-20T08:00:00.000Z",
status: "PAID",
paymentStatus: "PAID",
paymentStatus: "PAID"
},
{
reference: "BKG-CONT-005",
reference: "BKG_ONT_05",
containerCode: "20FT",
quantity: 8,
totalWeightTons: 160,
@@ -144,6 +150,39 @@ const DEMO_BOOKINGS = [
},
];
const DEMO_BULK_BOOKINGS = [
{
reference: "BKG-BULK-001",
cargoCode: "COFFEE",
totalWeightTons: 1200,
originCode: "DJIBOUTI",
destinationCode: "ADDIS_ABABA",
scheduledDate: "2026-06-20T08:00:00.000Z",
status: "PAID",
paymentStatus: "PAID",
},
{
reference: "BKG-BULK-002",
cargoCode: "FERTILIZER",
totalWeightTons: 800,
originCode: "DJIBOUTI",
destinationCode: "ADDIS_ABABA",
scheduledDate: "2026-06-20T08:00:00.000Z",
status: "PAID",
paymentStatus: "PAID",
},
{
reference: "BKG-BULK-003",
cargoCode: "STEEL",
totalWeightTons: 450,
originCode: "ADDIS_ABABA",
destinationCode: "DIRE_DAWA",
scheduledDate: "2026-06-20T08:00:00.000Z",
status: "PAID",
paymentStatus: "PAID",
},
];
@Injectable()
export class DemoBookingsSeeder {
private readonly logger = new Logger(DemoBookingsSeeder.name);
@@ -161,15 +200,57 @@ export class DemoBookingsSeeder {
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(WagonType).upsert(
{
code: "NW5",
name: "Flat Wagon",
capacityTons: 70,
lengthMeters: 14,
maxWagonsPerTrain: 53,
supportedLoadTypes: ["CONTAINER"],
isActive: true,
},
[
{
code: "NW5",
name: "Flat Wagon",
capacityTons: 70,
lengthMeters: 14,
maxWagonsPerTrain: 53,
supportedLoadTypes: ["CONTAINER"],
isActive: true,
equatedLengthM: 14,
tareWeightTons: 20,
supportsContainer: true,
maxContainerGrossT: 70,
},
{
code: "KW2",
name: "Covered Hopper",
capacityTons: 60,
lengthMeters: 12,
maxWagonsPerTrain: 55,
supportedLoadTypes: ["BULK"],
isActive: true,
equatedLengthM: 12,
tareWeightTons: 18,
supportsContainer: false,
},
{
code: "PW2",
name: "Powder Wagon",
capacityTons: 55,
lengthMeters: 12,
maxWagonsPerTrain: 55,
supportedLoadTypes: ["BULK"],
isActive: true,
equatedLengthM: 12,
tareWeightTons: 17,
supportsContainer: false,
},
{
code: "CW3",
name: "Open Wagon",
capacityTons: 65,
lengthMeters: 13,
maxWagonsPerTrain: 53,
supportedLoadTypes: ["BULK"],
isActive: true,
equatedLengthM: 13,
tareWeightTons: 19,
supportsContainer: false,
},
],
{ conflictPaths: { code: true } },
);
@@ -291,7 +372,7 @@ export class DemoBookingsSeeder {
companyId: company.id,
status: demoBooking.status,
scheduledDate: new Date(demoBooking.scheduledDate),
totalAmount: 0,
totalAmount: 2,
paymentStatus: demoBooking.paymentStatus,
contractType: "NEW",
serviceTypeId: serviceType.id,
@@ -305,7 +386,7 @@ export class DemoBookingsSeeder {
shippingLineId: null,
cargoTotalWeightVgm: demoBooking.totalWeightTons,
isHazardous: false,
paymentCurrency: "USD",
paymentCurrency: "ETB",
allowConsolidation: false,
priorityScore: 0,
versionNumber: 1,
@@ -320,6 +401,9 @@ export class DemoBookingsSeeder {
await manager
.getRepository(BookingContainer)
.delete({ bookingId: booking.id });
const wagonsRequired =
Number(demoBooking.quantity) * Number(containerType.wagonsPerUnit ?? 1);
await manager.getRepository(BookingContainer).insert({
id: randomUUID(),
bookingId: booking.id,
@@ -327,15 +411,149 @@ export class DemoBookingsSeeder {
quantity: demoBooking.quantity,
vgmPerUnitTons,
totalVgmTons: demoBooking.totalWeightTons,
wagonsRequired: Math.ceil(demoBooking.totalWeightTons / 70),
wagonsRequired,
weightLimitRuleId: null,
isOverweight: demoBooking.totalWeightTons > 70,
overweightExcessTons:
demoBooking.totalWeightTons > 70
? demoBooking.totalWeightTons - 70
: null,
isOverweight: vgmPerUnitTons > 35,
overweightExcessTons: vgmPerUnitTons > 35 ? vgmPerUnitTons - 35 : null,
});
}
await manager.getRepository(CargoType).upsert(
[
{ code: "COFFEE", cargoTypeName: "Coffee", isActive: true, displayOrder: 1 },
{ code: "FERTILIZER", cargoTypeName: "Fertilizer", isActive: true, displayOrder: 2 },
{ code: "STEEL", cargoTypeName: "Steel", isActive: true, displayOrder: 3 },
],
{ conflictPaths: { code: true } },
);
const cargoTypes = await manager.getRepository(CargoType).find();
const cargoByCode = new Map(cargoTypes.map((c) => [c.code, c]));
for (const demoBulk of DEMO_BULK_BOOKINGS) {
const origin = yardByCode.get(demoBulk.originCode);
const destination = yardByCode.get(demoBulk.destinationCode);
const cargoType = cargoByCode.get(demoBulk.cargoCode);
if (!origin || !destination || !cargoType) {
throw new Error(`demo_bulk_seed_dependency_missing:${demoBulk.reference}`);
}
await manager.getRepository(Booking).upsert(
{
reference: demoBulk.reference,
companyId: company.id,
status: demoBulk.status,
scheduledDate: new Date(demoBulk.scheduledDate),
totalAmount: 0,
paymentStatus: demoBulk.paymentStatus,
contractType: "NEW",
serviceTypeId: serviceType.id,
equipmentReturn: "WITHOUT_RETURN",
originYardId: origin.id,
destinationYardId: destination.id,
tradeDirection: "IMPORT",
freightType: "BULK",
cargoTypeId: cargoType.id,
cargoFreeText: demoBulk.cargoCode,
shippingLineId: null,
cargoTotalWeightVgm: demoBulk.totalWeightTons,
isHazardous: false,
paymentCurrency: "USD",
allowConsolidation: false,
priorityScore: 10,
schedulingStatus: "HOLDING",
versionNumber: 1,
},
{ conflictPaths: { reference: true } },
);
}
const djibouti = yardByCode.get("DJIBOUTI");
const addis = yardByCode.get("ADDIS_ABABA");
if (djibouti && addis) {
const routeName = "Djibouti → Addis Ababa";
let route = await manager.getRepository(Route).findOneBy({ name: routeName });
if (!route) {
route = await manager.getRepository(Route).save(
manager.getRepository(Route).create({
name: routeName,
originYardId: djibouti.id,
destinationYardId: addis.id,
isActive: true,
}),
);
await manager.getRepository(RouteMilestone).save([
manager.getRepository(RouteMilestone).create({
routeId: route.id,
yardId: djibouti.id,
sequenceNo: 1,
}),
manager.getRepository(RouteMilestone).create({
routeId: route.id,
yardId: addis.id,
sequenceNo: 2,
}),
]);
}
}
const nw5 = await manager.getRepository(WagonType).findOneBy({ code: "NW5" });
if (nw5 && djibouti && addis) {
await manager.getRepository(Wagon).upsert(
Array.from({ length: 20 }, (_, index) => ({
wagonNumber: `WGN-DEMO-${String(index + 1).padStart(3, "0")}`,
wagonTypeId: nw5.id,
trainId: null,
sequenceNumber: null,
tareWeight: 20,
maxPayloadWeight: 70,
status: WagonStatus.Available,
currentYardId: index % 2 === 0 ? djibouti.id : addis.id,
notes: "Demo wagon for train scheduling",
trainSetWagonId: null,
currentTrainScheduleId: null,
})),
{ conflictPaths: { wagonNumber: true } },
);
}
if (djibouti) {
await manager.getRepository(Locomotive).update(
{ code: "LOC-001" },
{ currentYardId: djibouti.id },
);
}
if (addis) {
await manager.getRepository(Locomotive).update(
{ code: "LOC-002" },
{ currentYardId: addis.id },
);
}
const ft20 = containerTypeByCode.get("20FT");
const ft40 = containerTypeByCode.get("40FT");
if (ft20 && ft40) {
await manager.getRepository(Container).upsert(
Array.from({ length: 30 }, (_, index) => {
const is40Ft = index % 2 === 0;
return {
containerNumber: `CONT-DEMO-${String(index + 1).padStart(3, "0")}`,
containerTypeId: is40Ft ? ft40.id : ft20.id,
wagonId: null,
position: null,
tareWeight: is40Ft ? 4.0 : 2.5,
maxGrossWeight: is40Ft ? 32.5 : 24.5,
sealNumber: null,
status: "AVAILABLE",
bookingId: null,
wagonBookingAllocationId: null,
bookingContainerId: null,
};
}),
{ conflictPaths: { containerNumber: true } },
);
}
});
this.logger.log("Seeded demo train scheduling data");

View File

@@ -0,0 +1,180 @@
import { Injectable, Logger } from '@nestjs/common';
import { WagonStatus } from '@edr/types';
import { hashPassword } from '@tria-plc/api-common/utils/argon';
import { EUserStatus } from '@tria-plc/api-common/utils/enums/user.enum';
import {
Employee,
Organization,
Role,
User,
UserCredential,
UserRole,
} from '@tria-plc/iamapi-common';
import { DataSource, EntityManager } from 'typeorm';
import { Wagon } from '../modules/wagons/entities/wagon.entity';
import { WagonType } from '../modules/wagon-types/entities/wagon-type.entity';
import { ApprovalRule } from '../modules/rule-engine/entities/approval-rule.entity';
import { DEFAULT_APPROVAL_RULE_ROWS } from '../modules/rule-engine/approval-rules.defaults';
const EDR_ORG_KEY = 'edr_freight';
const MIN_WAGONS_PER_TYPE = 100;
/** The four 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' },
] as const;
/**
* One-shot demo data: at least 100 wagons per wagon type, the default approval
* chains, and four staff users with distinct permissions. Every block guards on
* an "is it already populated?" check, so this is safe to run on every boot and
* does nothing once the data exists.
*/
@Injectable()
export class DemoFreightDataSeeder {
private readonly logger = new Logger(DemoFreightDataSeeder.name);
constructor(private readonly dataSource: DataSource) {}
async run() {
await this.dataSource.transaction(async (manager) => {
await this.seedWagons(manager);
await this.seedApprovalRules(manager);
await this.seedStaffUsers(manager);
});
}
/** Ensure every wagon type has at least MIN_WAGONS_PER_TYPE wagons. */
private async seedWagons(manager: EntityManager) {
const wagonTypeRepo = manager.getRepository(WagonType);
const wagonRepo = manager.getRepository(Wagon);
const wagonTypes = await wagonTypeRepo.find();
if (wagonTypes.length === 0) {
this.logger.warn('No wagon types found; skipping wagon seed');
return;
}
for (const type of wagonTypes) {
const existing = await wagonRepo.count({ where: { wagonTypeId: type.id } });
if (existing >= MIN_WAGONS_PER_TYPE) {
this.logger.log(
`Wagon type ${type.code} already has ${existing} wagons; skipping`,
);
continue;
}
const toCreate = MIN_WAGONS_PER_TYPE - existing;
const tare = Number(type.tareWeightTons ?? 20);
const maxPayload = Number(type.capacityTons ?? 60);
const rows = Array.from({ length: toCreate }, (_, i) => {
const seq = existing + i + 1;
return wagonRepo.create({
wagonNumber: `${type.code}-${String(seq).padStart(4, '0')}`,
wagonTypeId: type.id,
tareWeight: tare,
maxPayloadWeight: maxPayload,
status: WagonStatus.Available,
});
});
await wagonRepo.save(rows);
this.logger.log(`Seeded ${toCreate} wagons for type ${type.code}`);
}
}
/** Seed the default approval chains when the table is empty. */
private async seedApprovalRules(manager: EntityManager) {
const repo = manager.getRepository(ApprovalRule);
const count = await repo.count();
if (count > 0) {
this.logger.log(`Approval rules already populated (${count}); skipping`);
return;
}
await repo.save(DEFAULT_APPROVAL_RULE_ROWS.map((row) => repo.create(row)));
this.logger.log(`Seeded ${DEFAULT_APPROVAL_RULE_ROWS.length} approval rules`);
}
/** Create the four demo staff users with their roles (idempotent per email). */
private async seedStaffUsers(manager: EntityManager) {
const organization = await manager.getRepository(Organization).findOne({
where: { key: EDR_ORG_KEY },
select: { id: true, key: true },
});
if (!organization) {
this.logger.warn(`Missing organization ${EDR_ORG_KEY}; skipping staff users`);
return;
}
const roleRepo = manager.getRepository(Role);
const userRepo = manager.getRepository(User);
const credentialRepo = manager.getRepository(UserCredential);
const userRoleRepo = manager.getRepository(UserRole);
const employeeRepo = manager.getRepository(Employee);
const password = process.env.DEFAULT_PASSWORD?.trim() || '12345678';
const hashedPassword = await hashPassword(password);
for (const staff of DEMO_STAFF_USERS) {
const role = await roleRepo.findOne({
where: { key: staff.roleKey },
select: { id: true, key: true },
});
if (!role) {
this.logger.warn(`Missing role ${staff.roleKey}; skipping ${staff.email}`);
continue;
}
let user = await userRepo.findOne({
where: { email: staff.email },
select: { id: true, email: true },
});
if (!user) {
user = await userRepo.save(
userRepo.create({
email: staff.email,
username: staff.username,
name: { en: staff.username },
isActive: true,
hasSetPassword: true,
status: EUserStatus.ACCEPTED,
}),
);
this.logger.log(`Seeded staff user ${staff.email}`);
}
const hasCredential = await credentialRepo.exists({
where: { userId: user.id, isActive: true },
});
if (!hasCredential) {
await credentialRepo.insert({
userId: user.id,
password: hashedPassword,
isActive: true,
});
}
await userRoleRepo.upsert(
{ userId: user.id, roleId: role.id, organizationId: organization.id },
{ conflictPaths: { userId: true, roleId: true } },
);
const hasEmployee = await employeeRepo.exists({
where: { userId: user.id, organizationId: organization.id, isCurrent: true },
});
if (!hasEmployee) {
await employeeRepo.insert({
userId: user.id,
organizationId: organization.id,
isCurrent: true,
name: { en: staff.username },
});
}
}
this.logger.log('Ensured demo staff users (marketing@, operations@, director@, ceo@)');
}
}

View File

@@ -212,6 +212,11 @@ export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [
name: { en: "EDR Line Staff" },
permissionKeys: [...ROLE_PERMISSION_PRESETS.lineStaff],
},
{
key: "edr_operations_officer",
name: { en: "EDR Operations Officer" },
permissionKeys: [...ROLE_PERMISSION_PRESETS.operationsOfficer],
},
{
key: "edr_director",
name: { en: "EDR Director" },

View File

@@ -0,0 +1,48 @@
import { Injectable, Logger } from '@nestjs/common';
import { Permission } from '@tria-plc/iamapi-common';
import { DataSource } from 'typeorm';
/** Renamed rule-engine resources: old key -> new key (same permission id). */
const PERMISSION_KEY_RENAMES: ReadonlyArray<{ from: string; to: string }> = [
{
from: 'edr_freight_app:rule_engine:priority_rules:view',
to: 'edr_freight_app:rule_engine:priority_configs:view',
},
{
from: 'edr_freight_app:rule_engine:priority_rules:manage',
to: 'edr_freight_app:rule_engine:priority_configs:manage',
},
];
@Injectable()
export class FreightPermissionKeyMigrationSeeder {
private readonly logger = new Logger(FreightPermissionKeyMigrationSeeder.name);
constructor(private readonly dataSource: DataSource) {}
async run() {
const permissionRepository = this.dataSource.getRepository(Permission);
for (const { from, to } of PERMISSION_KEY_RENAMES) {
const existing = await permissionRepository.findOne({
where: { key: from },
select: { id: true, key: true },
});
if (!existing) {
continue;
}
const targetExists = await permissionRepository.existsBy({ key: to });
if (targetExists) {
this.logger.warn(
`Skipping permission key rename ${from} -> ${to}: target key already exists`,
);
continue;
}
await permissionRepository.update({ id: existing.id }, { key: to });
this.logger.log(`Renamed permission key ${from} -> ${to}`);
}
}
}

View File

@@ -16,7 +16,7 @@ export const RULE_ENGINE_RESOURCE_SLUGS = [
'shipping-lines',
'weight-limit-rules',
'surcharge-types',
'priority-rules',
'priority-configs',
'rates',
'approval-rules',
] as const;
@@ -52,6 +52,11 @@ 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-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'),
perm('a1000001-0001-4000-8000-000000000012', 'edr_freight_app:fleet:manage', 'Manage fleet'),
perm('a1000001-0001-4000-8000-000000000013', 'edr_freight_app:admin', 'Freight administration'),
];
const RULE_ENGINE_PERMISSION_IDS: Record<RuleEngineResourceSlug, { view: string; manage: string }> = {
@@ -63,7 +68,7 @@ const RULE_ENGINE_PERMISSION_IDS: Record<RuleEngineResourceSlug, { view: string;
'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-rules': { view: 'b2000001-0001-4000-8000-00000000000f', manage: 'b2000001-0001-4000-8000-000000000010' },
'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' },
};
@@ -103,6 +108,15 @@ export const FREIGHT_PERMS = {
operations: 'edr_freight_app:bookings:operations',
cancel: 'edr_freight_app:bookings:cancel',
},
trainScheduling: {
view: 'edr_freight_app:train_scheduling:view',
manage: 'edr_freight_app:train_scheduling:manage',
},
fleet: {
view: 'edr_freight_app:fleet:view',
manage: 'edr_freight_app:fleet:manage',
},
admin: 'edr_freight_app:admin',
ruleEngine: {
view: (slug: RuleEngineResourceSlug) =>
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`,
@@ -115,6 +129,9 @@ const allRuleEngineViewKeys = () =>
RULE_ENGINE_RESOURCE_SLUGS.map((s) => FREIGHT_PERMS.ruleEngine.view(s));
export const ROLE_PERMISSION_PRESETS = {
// Marketing / line staff: drives a booking from intake through line-staff
// approval and contract generation/signing — i.e. until the contract is ready
// and signed. No director/CEO approval, no scheduling, no operations.
lineStaff: [
FREIGHT_PERMS.bookings.view,
FREIGHT_PERMS.bookings.staffAccept,
@@ -125,6 +142,17 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.bookings.cancel,
...allRuleEngineViewKeys(),
],
// Operations Officer: train scheduling + wagon allocation + transit/complete
// + fleet management (wagons, trains, locomotives, routes, containers, cargo).
operationsOfficer: [
FREIGHT_PERMS.bookings.view,
FREIGHT_PERMS.bookings.operations,
FREIGHT_PERMS.trainScheduling.view,
FREIGHT_PERMS.trainScheduling.manage,
FREIGHT_PERMS.fleet.view,
FREIGHT_PERMS.fleet.manage,
...allRuleEngineViewKeys(),
],
director: [
FREIGHT_PERMS.bookings.view,
FREIGHT_PERMS.bookings.approveDirector,
@@ -139,8 +167,15 @@ export const ROLE_PERMISSION_PRESETS = {
...allRuleEngineViewKeys(),
],
finance: [FREIGHT_PERMS.bookings.view],
// Marketing handles intake through contract (same as line staff here).
marketing: [
FREIGHT_PERMS.bookings.view,
FREIGHT_PERMS.bookings.staffAccept,
FREIGHT_PERMS.bookings.requestChanges,
FREIGHT_PERMS.bookings.reject,
FREIGHT_PERMS.bookings.approveLineStaff,
FREIGHT_PERMS.bookings.rejectApproval,
FREIGHT_PERMS.bookings.cancel,
FREIGHT_PERMS.bookings.generateContract,
FREIGHT_PERMS.bookings.signStaff,
],

View File

@@ -4,12 +4,14 @@ 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 { PriorityRule } from "../modules/rule-engine/entities/priority-rule.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 { 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";
import { Yard } from "../modules/rule-engine/entities/yard.entity";
const STAFF_USER_ID = "00000000-0000-0000-0000-000000000001";
@@ -28,12 +30,13 @@ export class PricingDataSeeder {
const yRepo = manager.getRepository(Yard);
const slRepo = manager.getRepository(ShippingLine);
const wlRepo = manager.getRepository(WeightLimitRule);
const prRepo = manager.getRepository(PriorityRule);
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.seedPriorityRules(prRepo);
await this.seedPriorityConfigs(prRepo);
const containerTypes = await ctRepo.find();
const ctByCode = new Map(containerTypes.map((ct) => [ct.code, ct]));
@@ -115,7 +118,7 @@ export class PricingDataSeeder {
code: "20FT",
label: "20FT Standard",
sizeFt: 20,
wagonsPerUnit: 1,
wagonsPerUnit: 0.5,
isReefer: false,
isOpenTop: false,
isActive: true,
@@ -135,7 +138,7 @@ export class PricingDataSeeder {
code: "20FT_REEFER",
label: "20FT Reefer",
sizeFt: 20,
wagonsPerUnit: 1,
wagonsPerUnit: 0.5,
isReefer: true,
isOpenTop: false,
isActive: true,
@@ -287,64 +290,125 @@ export class PricingDataSeeder {
);
}
private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
await wlRepo.createQueryBuilder().delete().execute();
const twenty = await ctRepo.findOneByOrFail({ code: "20FT" });
const forty = await ctRepo.findOneByOrFail({ code: "40FT" });
const base = new Date("2026-01-01");
await wlRepo.insert([
{
containerTypeId: twenty.id,
tradeDirection: "IMPORT",
maxVgmTons: 26,
effectiveFrom: base,
},
{
containerTypeId: twenty.id,
tradeDirection: "EXPORT",
maxVgmTons: 26,
effectiveFrom: base,
},
{
containerTypeId: forty.id,
tradeDirection: "IMPORT",
maxVgmTons: 28,
effectiveFrom: base,
},
{
containerTypeId: forty.id,
tradeDirection: "EXPORT",
maxVgmTons: 28,
effectiveFrom: base,
},
]);
this.logger.log("Seeded weight limit rules");
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 seedPriorityRules(prRepo: any): Promise<void> {
const existing = await prRepo.find({
where: [{ code: "USD_PRIORITY" }, { code: "STANDARD_PRIORITY" }],
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,
},
});
for (const r of existing) {
await prRepo.remove(r);
if (existing) {
await wlRepo.update(existing.id, {
maxVgmTons: rule.maxVgmTons,
effectiveFrom: rule.effectiveFrom,
});
} else {
await wlRepo.insert(rule);
}
await prRepo.save([
prRepo.create({
code: "USD_PRIORITY",
label: "USD Payment Priority",
score: 200,
conditionCurrency: "USD",
isActive: true,
}),
prRepo.create({
code: "STANDARD_PRIORITY",
label: "Standard Priority",
score: 50,
conditionCurrency: null,
isActive: true,
}),
]);
this.logger.log("Seeded priority rules");
}
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).
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: 17, 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(
@@ -370,20 +434,6 @@ export class PricingDataSeeder {
rateValue: 1200,
rateUnit: "PER_CONTAINER",
},
{
rateType: "CONTAINER_IMPORT",
containerTypeId: ctByCode.get("20FT")!.id,
currency: "ETB",
rateValue: 45000,
rateUnit: "PER_CONTAINER",
},
{
rateType: "CONTAINER_IMPORT",
containerTypeId: ctByCode.get("40FT")!.id,
currency: "ETB",
rateValue: 67000,
rateUnit: "PER_CONTAINER",
},
{
rateType: "CONTAINER_EXPORT",
containerTypeId: ctByCode.get("20FT")!.id,
@@ -398,34 +448,6 @@ export class PricingDataSeeder {
rateValue: 900,
rateUnit: "PER_CONTAINER",
},
{
rateType: "CONTAINER_EXPORT",
containerTypeId: ctByCode.get("20FT")!.id,
currency: "ETB",
rateValue: 34000,
rateUnit: "PER_CONTAINER",
},
{
rateType: "CONTAINER_EXPORT",
containerTypeId: ctByCode.get("40FT")!.id,
currency: "ETB",
rateValue: 50000,
rateUnit: "PER_CONTAINER",
},
{
rateType: "INTERCITY_CONTAINER",
containerTypeId: ctByCode.get("20FT")!.id,
currency: "ETB",
rateValue: 20000,
rateUnit: "PER_CONTAINER",
},
{
rateType: "INTERCITY_CONTAINER",
containerTypeId: ctByCode.get("40FT")!.id,
currency: "ETB",
rateValue: 30000,
rateUnit: "PER_CONTAINER",
},
{
rateType: "CONTAINER_IMPORT",
containerTypeId: null,
@@ -433,13 +455,6 @@ export class PricingDataSeeder {
rateValue: 1000,
rateUnit: "PER_CONTAINER",
},
{
rateType: "CONTAINER_IMPORT",
containerTypeId: null,
currency: "ETB",
rateValue: 56000,
rateUnit: "PER_CONTAINER",
},
{
rateType: "CONTAINER_EXPORT",
containerTypeId: null,
@@ -448,19 +463,33 @@ export class PricingDataSeeder {
rateUnit: "PER_CONTAINER",
},
{
rateType: "CONTAINER_EXPORT",
containerTypeId: null,
currency: "ETB",
rateValue: 42000,
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: "ETB",
rateValue: 25000,
currency: "USD",
rateValue: 400,
rateUnit: "PER_CONTAINER",
},
{
rateType: "INTERCITY_BULK",
containerTypeId: null,
currency: "USD",
rateValue: 35,
rateUnit: "PER_TON",
},
{
rateType: "BULK_IMPORT",
containerTypeId: null,
@@ -468,13 +497,6 @@ export class PricingDataSeeder {
rateValue: 50,
rateUnit: "PER_TON",
},
{
rateType: "BULK_IMPORT",
containerTypeId: null,
currency: "ETB",
rateValue: 2800,
rateUnit: "PER_TON",
},
{
rateType: "BULK_EXPORT",
containerTypeId: null,
@@ -482,13 +504,6 @@ export class PricingDataSeeder {
rateValue: 40,
rateUnit: "PER_TON",
},
{
rateType: "BULK_EXPORT",
containerTypeId: null,
currency: "ETB",
rateValue: 2200,
rateUnit: "PER_TON",
},
{
rateType: "OVERWEIGHT_PER_TON",
containerTypeId: null,
@@ -496,13 +511,6 @@ export class PricingDataSeeder {
rateValue: 25,
rateUnit: "PER_TON",
},
{
rateType: "OVERWEIGHT_PER_TON",
containerTypeId: null,
currency: "ETB",
rateValue: 1400,
rateUnit: "PER_TON",
},
{
rateType: "HAZARD_SURCHARGE",
containerTypeId: null,
@@ -510,13 +518,6 @@ export class PricingDataSeeder {
rateValue: 150,
rateUnit: "FLAT",
},
{
rateType: "HAZARD_SURCHARGE",
containerTypeId: null,
currency: "ETB",
rateValue: 8500,
rateUnit: "FLAT",
},
{
rateType: "REEFER_SURCHARGE",
containerTypeId: null,
@@ -524,13 +525,6 @@ export class PricingDataSeeder {
rateValue: 200,
rateUnit: "FLAT",
},
{
rateType: "REEFER_SURCHARGE",
containerTypeId: null,
currency: "ETB",
rateValue: 11000,
rateUnit: "FLAT",
},
{
rateType: "DOUBLE_HANDLING",
containerTypeId: null,
@@ -538,13 +532,6 @@ export class PricingDataSeeder {
rateValue: 100,
rateUnit: "PER_CONTAINER",
},
{
rateType: "DOUBLE_HANDLING",
containerTypeId: null,
currency: "ETB",
rateValue: 5500,
rateUnit: "PER_CONTAINER",
},
{
rateType: "LASHING",
containerTypeId: null,
@@ -552,13 +539,6 @@ export class PricingDataSeeder {
rateValue: 50,
rateUnit: "PER_CONTAINER",
},
{
rateType: "LASHING",
containerTypeId: null,
currency: "ETB",
rateValue: 2800,
rateUnit: "PER_CONTAINER",
},
];
const entities = rateData.map((d) =>
@@ -588,15 +568,10 @@ export class PricingDataSeeder {
};
const hazardRateUsd = findRate("HAZARD_SURCHARGE", "USD");
const hazardRateEtb = findRate("HAZARD_SURCHARGE", "ETB");
const reeferRateUsd = findRate("REEFER_SURCHARGE", "USD");
const reeferRateEtb = findRate("REEFER_SURCHARGE", "ETB");
const overweightRateUsd = findRate("OVERWEIGHT_PER_TON", "USD");
const overweightRateEtb = findRate("OVERWEIGHT_PER_TON", "ETB");
const shipLineRateUsd = findRate("DOUBLE_HANDLING", "USD");
const shipLineRateEtb = findRate("DOUBLE_HANDLING", "ETB");
const consolidRateUsd = findRate("LASHING", "USD");
const consolidRateEtb = findRate("LASHING", "ETB");
await surRepo.createQueryBuilder().delete().execute();
await surRepo.save([
@@ -604,35 +579,35 @@ export class PricingDataSeeder {
code: "HAZARDOUS_CARGO",
label: "Hazardous Cargo",
triggerCondition: "CARGO_FLAG_HAZARDOUS",
rateId: hazardRateUsd?.id ?? hazardRateEtb?.id,
rateId: hazardRateUsd?.id,
isActive: true,
}),
surRepo.create({
code: "REEFER_CARGO",
label: "Reefer Cargo",
triggerCondition: "CARGO_FLAG_REEFER",
rateId: reeferRateUsd?.id ?? reeferRateEtb?.id,
rateId: reeferRateUsd?.id,
isActive: true,
}),
surRepo.create({
code: "OVERWEIGHT_CARGO",
label: "Overweight Cargo",
triggerCondition: "VGM_EXCEEDS_LIMIT",
rateId: overweightRateUsd?.id ?? overweightRateEtb?.id,
rateId: overweightRateUsd?.id,
isActive: true,
}),
surRepo.create({
code: "SHIPPING_LINE_FEE",
label: "Shipping Line Fee",
triggerCondition: "SHIPPING_LINE_MAPPED",
rateId: shipLineRateUsd?.id ?? shipLineRateEtb?.id,
rateId: shipLineRateUsd?.id,
isActive: true,
}),
surRepo.create({
code: "CONSOLIDATION_FEE",
label: "Consolidation Fee",
triggerCondition: "CONSOLIDATION_ENABLED",
rateId: consolidRateUsd?.id ?? consolidRateEtb?.id,
rateId: consolidRateUsd?.id,
isActive: true,
}),
]);
@@ -699,10 +674,10 @@ export class PricingDataSeeder {
},
{
reference: "BKG-PRICE-003",
description: "20FT container import + shipping line (ETB)",
description: "20FT container import + shipping line (USD)",
freightType: "CONTAINER" as const,
tradeDirection: "IMPORT",
paymentCurrency: "ETB",
paymentCurrency: "USD",
serviceTypeId: railContainer.id,
originYardId: djibouti.id,
destinationYardId: addis.id,
@@ -714,7 +689,7 @@ export class PricingDataSeeder {
containers: [
{ containerTypeId: twenty.id, quantity: 20, vgmPerUnitTons: 24 },
],
expectedBaseRate: 45000,
expectedBaseRate: 800,
expectedSurcharges: ["SHIPPING_LINE_FEE"],
},
{