Merge branch 'dev' into freight/feat/invoice

This commit is contained in:
Nathnael
2026-06-29 10:58:12 +00:00
425 changed files with 46006 additions and 7705 deletions

View File

@@ -0,0 +1,376 @@
import { Injectable, Logger } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { DataSource, In } from 'typeorm';
import { BookingContainer } from '../modules/bookings/entities/booking-container.entity';
import { Booking } from '../modules/bookings/entities/booking.entity';
import {
Company,
CompanyStatus,
CompanyType,
} from '../modules/companies/entities/company.entity';
import { FirstMile } from '../modules/first-mile/entities/first-mile.entity';
import { LastMile } from '../modules/last-mile/entities/last-mile.entity';
import { ContainerType } from '../modules/rule-engine/entities/container-type.entity';
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
import { Yard } from '../modules/rule-engine/entities/yard.entity';
const SERVICE_TYPE_CODE = 'RAIL_CONTAINER_FIRST_LAST';
const COMPANY_TIN = 'FLMDEMO001';
const COMPANY_EMAIL = 'first-last-mile-demo@edr.local';
const YARDS = [
{ code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti', displayOrder: 1 },
{ code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia', displayOrder: 2 },
];
const CONTAINER_TYPES = [
{ code: '20FT', label: '20FT', sizeFt: 20 },
{ code: '40FT', label: '40FT', sizeFt: 40 },
];
const DEMO_BOOKINGS = [
{
reference: 'DEMO-IMP-FLM-001',
tradeDirection: 'IMPORT',
containerCode: '40FT',
quantity: 8,
totalWeightTons: 224,
originCode: 'DJIBOUTI',
destinationCode: 'ADDIS_ABABA',
scheduledDate: '2026-07-01T08:00:00.000Z',
firstMilePickupAddress: 'Doraleh Container Terminal, Djibouti',
firstMilePickupLat: 11.5881,
firstMilePickupLng: 43.1372,
lastMileDeliveryAddress: 'Akaki Industrial Zone, Addis Ababa',
lastMileDeliveryLat: 8.8808,
lastMileDeliveryLng: 38.7876,
},
{
reference: 'DEMO-IMP-FLM-002',
tradeDirection: 'IMPORT',
containerCode: '20FT',
quantity: 12,
totalWeightTons: 240,
originCode: 'DJIBOUTI',
destinationCode: 'ADDIS_ABABA',
scheduledDate: '2026-07-02T08:00:00.000Z',
firstMilePickupAddress: 'PK12 Dry Port, Djibouti',
firstMilePickupLat: 11.5536,
firstMilePickupLng: 43.1103,
lastMileDeliveryAddress: 'Kality Logistics Hub, Addis Ababa',
lastMileDeliveryLat: 8.9137,
lastMileDeliveryLng: 38.7815,
},
{
reference: 'DEMO-IMP-FLM-003',
tradeDirection: 'IMPORT',
containerCode: '40FT',
quantity: 6,
totalWeightTons: 180,
originCode: 'DJIBOUTI',
destinationCode: 'ADDIS_ABABA',
scheduledDate: '2026-07-03T08:00:00.000Z',
firstMilePickupAddress: 'Djibouti Free Zone Yard',
firstMilePickupLat: 11.5947,
firstMilePickupLng: 43.1471,
lastMileDeliveryAddress: 'Bole Lemi Industrial Park, Addis Ababa',
lastMileDeliveryLat: 8.9806,
lastMileDeliveryLng: 38.8736,
},
{
reference: 'DEMO-IMP-FLM-004',
tradeDirection: 'IMPORT',
containerCode: '20FT',
quantity: 10,
totalWeightTons: 210,
originCode: 'DJIBOUTI',
destinationCode: 'ADDIS_ABABA',
scheduledDate: '2026-07-04T08:00:00.000Z',
firstMilePickupAddress: 'Doraleh Multipurpose Port, Djibouti',
firstMilePickupLat: 11.6062,
firstMilePickupLng: 43.1219,
lastMileDeliveryAddress: 'Addis Ababa Freight Terminal',
lastMileDeliveryLat: 9.0101,
lastMileDeliveryLng: 38.7619,
},
{
reference: 'DEMO-IMP-FLM-005',
tradeDirection: 'IMPORT',
containerCode: '40FT',
quantity: 5,
totalWeightTons: 150,
originCode: 'DJIBOUTI',
destinationCode: 'ADDIS_ABABA',
scheduledDate: '2026-07-05T08:00:00.000Z',
firstMilePickupAddress: 'Djibouti Port Gate 3',
firstMilePickupLat: 11.5999,
firstMilePickupLng: 43.1344,
lastMileDeliveryAddress: 'Sebeta Distribution Center',
lastMileDeliveryLat: 8.9169,
lastMileDeliveryLng: 38.6177,
},
{
reference: 'DEMO-EXP-FLM-001',
tradeDirection: 'EXPORT',
containerCode: '40FT',
quantity: 7,
totalWeightTons: 196,
originCode: 'ADDIS_ABABA',
destinationCode: 'DJIBOUTI',
scheduledDate: '2026-07-01T10:00:00.000Z',
firstMilePickupAddress: 'Bole Lemi Industrial Park, Addis Ababa',
firstMilePickupLat: 8.9806,
firstMilePickupLng: 38.8736,
lastMileDeliveryAddress: 'Doraleh Container Terminal, Djibouti',
lastMileDeliveryLat: 11.5881,
lastMileDeliveryLng: 43.1372,
},
{
reference: 'DEMO-EXP-FLM-002',
tradeDirection: 'EXPORT',
containerCode: '20FT',
quantity: 11,
totalWeightTons: 220,
originCode: 'ADDIS_ABABA',
destinationCode: 'DJIBOUTI',
scheduledDate: '2026-07-02T10:00:00.000Z',
firstMilePickupAddress: 'Akaki Industrial Zone, Addis Ababa',
firstMilePickupLat: 8.8808,
firstMilePickupLng: 38.7876,
lastMileDeliveryAddress: 'Djibouti Free Zone Yard',
lastMileDeliveryLat: 11.5947,
lastMileDeliveryLng: 43.1471,
},
{
reference: 'DEMO-EXP-FLM-003',
tradeDirection: 'EXPORT',
containerCode: '40FT',
quantity: 4,
totalWeightTons: 128,
originCode: 'ADDIS_ABABA',
destinationCode: 'DJIBOUTI',
scheduledDate: '2026-07-03T10:00:00.000Z',
firstMilePickupAddress: 'Kality Logistics Hub, Addis Ababa',
firstMilePickupLat: 8.9137,
firstMilePickupLng: 38.7815,
lastMileDeliveryAddress: 'Doraleh Multipurpose Port, Djibouti',
lastMileDeliveryLat: 11.6062,
lastMileDeliveryLng: 43.1219,
},
{
reference: 'DEMO-EXP-FLM-004',
tradeDirection: 'EXPORT',
containerCode: '20FT',
quantity: 9,
totalWeightTons: 180,
originCode: 'ADDIS_ABABA',
destinationCode: 'DJIBOUTI',
scheduledDate: '2026-07-04T10:00:00.000Z',
firstMilePickupAddress: 'Addis Ababa Freight Terminal',
firstMilePickupLat: 9.0101,
firstMilePickupLng: 38.7619,
lastMileDeliveryAddress: 'PK12 Dry Port, Djibouti',
lastMileDeliveryLat: 11.5536,
lastMileDeliveryLng: 43.1103,
},
{
reference: 'DEMO-EXP-FLM-005',
tradeDirection: 'EXPORT',
containerCode: '40FT',
quantity: 6,
totalWeightTons: 168,
originCode: 'ADDIS_ABABA',
destinationCode: 'DJIBOUTI',
scheduledDate: '2026-07-05T10:00:00.000Z',
firstMilePickupAddress: 'Sebeta Distribution Center',
firstMilePickupLat: 8.9169,
firstMilePickupLng: 38.6177,
lastMileDeliveryAddress: 'Djibouti Port Gate 3',
lastMileDeliveryLat: 11.5999,
lastMileDeliveryLng: 43.1344,
},
] as const;
@Injectable()
export class ApprovedFirstLastMileDemoBookingsSeeder {
private readonly logger = new Logger(ApprovedFirstLastMileDemoBookingsSeeder.name);
constructor(private readonly dataSource: DataSource) {}
async run() {
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(Yard).upsert(
YARDS.map((yard) => ({ ...yard, isActive: true })),
{ conflictPaths: { code: true } },
);
await manager.getRepository(ServiceType).upsert(
{
code: SERVICE_TYPE_CODE,
serviceName: 'Rail Container with First and Last Mile',
description: 'Demo service type for approved first/last-mile bookings',
canBeBookedAlone: true,
includesFirstMile: true,
includesLastMile: true,
includesCustoms: false,
priorityBonusPoints: 0,
isActive: true,
displayOrder: 10,
},
{ conflictPaths: { code: true } },
);
await manager.getRepository(ContainerType).upsert(
CONTAINER_TYPES.map((containerType, index) => ({
...containerType,
wagonsPerUnit: 1,
isReefer: false,
isOpenTop: false,
isActive: true,
displayOrder: index + 1,
})),
{ conflictPaths: { code: true } },
);
await manager.getRepository(Company).upsert(
{
name: 'First and Last Mile Demo Customer',
type: CompanyType.Customer,
status: CompanyStatus.Active,
tin: COMPANY_TIN,
vatNumber: COMPANY_TIN,
fanNumber: 'FLM0000000000001',
country: 'Ethiopia',
address: 'Addis Ababa',
phone: '251900000101',
email: COMPANY_EMAIL,
website: null,
contactPersonName: 'First Last Mile Demo',
contactPersonPhone: '251900000101',
generalManagerName: 'Demo Manager',
generalManagerEmail: COMPANY_EMAIL,
generalManagerPhone: '251900000101',
},
{ conflictPaths: { tin: true } },
);
const [serviceType, company, yards, containerTypes] = await Promise.all([
manager.getRepository(ServiceType).findOneByOrFail({ code: SERVICE_TYPE_CODE }),
manager.getRepository(Company).findOneByOrFail({ tin: COMPANY_TIN }),
manager.getRepository(Yard).find(),
manager.getRepository(ContainerType).find(),
]);
const yardByCode = new Map(yards.map((yard) => [yard.code, yard]));
const containerTypeByCode = new Map(
containerTypes.map((containerType) => [containerType.code, containerType]),
);
for (const demoBooking of DEMO_BOOKINGS) {
const origin = yardByCode.get(demoBooking.originCode);
const destination = yardByCode.get(demoBooking.destinationCode);
const containerType = containerTypeByCode.get(demoBooking.containerCode);
if (!origin || !destination || !containerType) {
throw new Error(`approved_first_last_mile_demo_dependency_missing:${demoBooking.reference}`);
}
const wagonsRequired =
Number(demoBooking.quantity) * Number(containerType.wagonsPerUnit ?? 1);
const vgmPerUnitTons = demoBooking.totalWeightTons / demoBooking.quantity;
await manager.getRepository(Booking).upsert(
{
reference: demoBooking.reference,
companyId: company.id,
status: 'APPROVED',
scheduledDate: new Date(demoBooking.scheduledDate),
estimatedShipmentDate: new Date(demoBooking.scheduledDate),
totalAmount: demoBooking.totalWeightTons * 25,
paymentStatus: 'PENDING',
contractType: 'NEW',
serviceTypeId: serviceType.id,
firstMilePickupAddress: demoBooking.firstMilePickupAddress,
firstMilePickupLat: demoBooking.firstMilePickupLat,
firstMilePickupLng: demoBooking.firstMilePickupLng,
lastMileDeliveryAddress: demoBooking.lastMileDeliveryAddress,
lastMileDeliveryLat: demoBooking.lastMileDeliveryLat,
lastMileDeliveryLng: demoBooking.lastMileDeliveryLng,
equipmentReturn: 'WITHOUT_RETURN',
originYardId: origin.id,
destinationYardId: destination.id,
tradeDirection: demoBooking.tradeDirection,
freightType: 'CONTAINER',
cargoTypeId: null,
cargoFreeText: 'Demo container cargo',
shippingLineId: null,
cargoTotalWeightVgm: demoBooking.totalWeightTons,
isHazardous: false,
isReefer: false,
paymentCurrency: 'ETB',
approvedByStaffAt: new Date(),
priorityScore: 20,
wagonsRequired,
schedulingStatus: 'NOT_SCHEDULED',
versionNumber: 1,
},
{ conflictPaths: { reference: true } },
);
const booking = await manager.getRepository(Booking).findOneByOrFail({
reference: demoBooking.reference,
});
await manager.getRepository(BookingContainer).delete({ bookingId: booking.id });
await manager.getRepository(BookingContainer).insert({
id: randomUUID(),
bookingId: booking.id,
containerTypeId: containerType.id,
quantity: demoBooking.quantity,
vgmPerUnitTons,
totalVgmTons: demoBooking.totalWeightTons,
wagonsRequired,
weightLimitRuleId: null,
isOverweight: vgmPerUnitTons > 35,
overweightExcessTons: vgmPerUnitTons > 35 ? vgmPerUnitTons - 35 : null,
});
}
const bookings = await manager.getRepository(Booking).find({
where: { reference: In(DEMO_BOOKINGS.map((booking) => booking.reference)) },
select: { id: true },
});
const bookingIds = bookings.map((booking) => booking.id);
await manager.getRepository(FirstMile).delete({ bookingId: In(bookingIds) });
await manager.getRepository(LastMile).delete({ bookingId: In(bookingIds) });
await manager.getRepository(FirstMile).insert(
bookingIds.map((bookingId) => ({
bookingId,
status: 'READY_TO_TRANSIT',
advancedPayment: 0,
remainingPayment: 0,
estimatedKm: 18,
exactKm: null,
vehicleId: null,
})),
);
await manager.getRepository(LastMile).insert(
bookingIds.map((bookingId) => ({
bookingId,
status: 'READY_TO_TRANSIT',
advancedPayment: 0,
remainingPayment: 0,
estimatedKm: 22,
exactKm: null,
vehicleId: null,
})),
);
});
this.logger.log('Seeded 5 import and 5 export approved bookings with first/last-mile legs');
}
}

View File

@@ -26,7 +26,8 @@ const DEMO_STAFF_USERS = [
{ 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' },
{ email: 'gl-et@edr.local', username: 'gl_et', roleKey: 'edr_gl_ethiopia' },
{ email: 'gl-dj@edr.local', username: 'gl_dj', roleKey: 'edr_gl_djibouti' },
] as const;
/**

View File

@@ -238,9 +238,14 @@ export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [
permissionKeys: [...ROLE_PERMISSION_PRESETS.marketing],
},
{
key: "edr_global_logistics",
name: { en: "EDR Global Logistics" },
permissionKeys: [...ROLE_PERMISSION_PRESETS.globalLogistics],
key: "edr_gl_ethiopia",
name: { en: "EDR Global Logistics — Ethiopia" },
permissionKeys: [...ROLE_PERMISSION_PRESETS.glEthiopia],
},
{
key: "edr_gl_djibouti",
name: { en: "EDR Global Logistics — Djibouti" },
permissionKeys: [...ROLE_PERMISSION_PRESETS.glDjibouti],
},
{
key: "edr_org_manager",

View File

@@ -0,0 +1,202 @@
import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { Booking } from '../modules/bookings/entities/booking.entity';
import { Locomotive } from '../modules/locomotives/entities/locomotive.entity';
import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity';
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
import { Yard } from '../modules/rule-engine/entities/yard.entity';
import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity';
import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity';
import { TrainSet } from '../modules/train-sets/entities/train-set.entity';
import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity';
import { WarehouseYard } from '../modules/warehouses/entities/warehouse-yard.entity';
import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity';
import { Warehouse } from '../modules/warehouses/entities/warehouse.entity';
const SEED_FLAG = 'SEED_EXPORT_DJIBOUTI_INTERCHANGE_DEMO';
const DEMO_TRAINS = [
{
trainNumber: 'ICD-DEMO-EXP-DJ-01',
bookingRefs: ['ICD-DEMO-EXP-001', 'ICD-DEMO-EXP-002', 'ICD-DEMO-EXP-003'],
arrivalOffsetHours: 1,
},
{
trainNumber: 'ICD-DEMO-EXP-DJ-02',
bookingRefs: ['ICD-DEMO-EXP-004', 'ICD-DEMO-EXP-005', 'ICD-DEMO-EXP-006'],
arrivalOffsetHours: 2,
},
];
@Injectable()
export class ExportDjiboutiInterchangeDemoSeeder {
private readonly logger = new Logger(ExportDjiboutiInterchangeDemoSeeder.name);
constructor(private readonly dataSource: DataSource) {}
async run(): Promise<void> {
if (process.env[SEED_FLAG]?.trim().toLowerCase() !== 'true') {
this.logger.log(`Skipping export Djibouti interchange demo seed because ${SEED_FLAG} is not enabled`);
return;
}
try {
const yardRepo = this.dataSource.getRepository(Yard);
const serviceTypeRepo = this.dataSource.getRepository(ServiceType);
const cargoTypeRepo = this.dataSource.getRepository(CargoType);
const warehouseRepo = this.dataSource.getRepository(Warehouse);
const warehouseYardRepo = this.dataSource.getRepository(WarehouseYard);
const warehouseZoneRepo = this.dataSource.getRepository(WarehouseZone);
const bookingRepo = this.dataSource.getRepository(Booking);
const inventoryRepo = this.dataSource.getRepository(WarehouseInventory);
const locomotiveRepo = this.dataSource.getRepository(Locomotive);
const trainSetRepo = this.dataSource.getRepository(TrainSet);
const scheduleRepo = this.dataSource.getRepository(TrainSchedule);
const scheduleBookingRepo = this.dataSource.getRepository(TrainScheduleBooking);
const originYard =
(await yardRepo.findOne({ where: { code: 'MOJO' } })) ??
(await yardRepo.findOne({ where: { country: 'Ethiopia' } }));
const destinationYard =
(await yardRepo.findOne({ where: { code: 'DJIB_PORT' } })) ??
(await yardRepo.findOne({ where: { code: 'DJIBOUTI' } })) ??
(await yardRepo.findOne({ where: { country: 'Djibouti' } }));
const serviceType =
(await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ??
(await serviceTypeRepo.findOne({ where: { isActive: true } }));
const cargoType = await cargoTypeRepo.findOne({ where: { isActive: true } });
const warehouse = await warehouseRepo.findOne({ where: { code: 'INDODE_OPEN' } });
const warehouseYard = warehouse
? await warehouseYardRepo.findOne({ where: { warehouseId: warehouse.id } })
: null;
const warehouseZone = warehouseYard
? await warehouseZoneRepo.findOne({ where: { yardId: warehouseYard.id } })
: null;
const missing = [
!originYard ? 'Ethiopian origin yard' : '',
!destinationYard ? 'Djibouti destination yard' : '',
!serviceType ? 'service type' : '',
!warehouse ? 'INDODE_OPEN warehouse' : '',
!warehouseYard ? 'warehouse yard' : '',
!warehouseZone ? 'warehouse zone' : '',
].filter(Boolean);
if (missing.length) {
this.logger.warn(`Cannot seed export Djibouti interchange demo, missing: ${missing.join(', ')}`);
return;
}
const locomotive =
(await locomotiveRepo.findOne({ where: { code: 'ICD-DEMO-LOCO' } })) ??
(await locomotiveRepo.save(
locomotiveRepo.create({
code: 'ICD-DEMO-LOCO',
name: 'Interchange Demo Locomotive',
maxPullWeightTons: 4000,
}),
));
const now = Date.now();
let seeded = 0;
let skipped = 0;
for (const [trainIndex, demo] of DEMO_TRAINS.entries()) {
const existingSchedule = await scheduleRepo.findOne({ where: { trainNumber: demo.trainNumber } });
if (existingSchedule) {
skipped += 1;
continue;
}
const arrival = new Date(now - demo.arrivalOffsetHours * 60 * 60 * 1000);
const departure = new Date(arrival.getTime() - 5 * 60 * 60 * 1000);
const trainSet = await trainSetRepo.save(
trainSetRepo.create({
locomotiveId: locomotive.id,
totalWeightTons: 700 + trainIndex * 80,
totalLengthMeters: 360 + trainIndex * 20,
wagonCount: 12 + trainIndex,
status: 'COMPLETED',
}),
);
const schedule = await scheduleRepo.save(
scheduleRepo.create({
trainSetId: trainSet.id,
originStationId: originYard!.id,
destinationStationId: destinationYard!.id,
scheduledDepartureDate: departure,
scheduledArrivalDate: arrival,
actualArrivalAt: arrival,
status: 'ARRIVED' as TrainSchedule['status'],
trainNumber: demo.trainNumber,
direction: 'EXPORT',
}),
);
for (const [bookingIndex, reference] of demo.bookingRefs.entries()) {
const weight = 5200 + trainIndex * 600 + bookingIndex * 800;
const booking = await bookingRepo.save(
bookingRepo.create({
reference,
originYardId: originYard!.id,
destinationYardId: destinationYard!.id,
serviceTypeId: serviceType!.id,
status: 'IN_TRANSIT',
paymentStatus: 'PAID',
scheduledDate: new Date(),
contractType: 'SPOT',
equipmentReturn: 'TERMINAL',
paymentCurrency: 'ETB',
totalAmount: 0,
isGovernment: false,
tradeDirection: 'EXPORT',
freightType: bookingIndex % 2 === 0 ? 'CONTAINER' : 'BULK',
cargoTypeId: cargoType?.id ?? null,
cargoFreeText: cargoType
? null
: `Export Djibouti interchange demo cargo ${trainIndex + 1}-${bookingIndex + 1}`,
cargoTotalWeightVgm: weight,
}),
);
await inventoryRepo.save(
inventoryRepo.create({
warehouseId: warehouse!.id,
yardId: warehouseYard!.id,
zoneId: warehouseZone!.id,
bookingId: booking.id,
quantity: 1,
weight,
status: 'DISPATCHED',
inspectionStatus: 'PASSED',
arrivedAt: new Date(departure.getTime() + 2 * 60 * 60 * 1000),
inspectedAt: new Date(departure.getTime() + 3 * 60 * 60 * 1000),
readyForLoadingAt: new Date(departure.getTime() + 4 * 60 * 60 * 1000),
loadedAt: new Date(departure.getTime() + 4.5 * 60 * 60 * 1000),
dispatchedAt: departure,
notes: '[ICD-DEMO] Eligible for Djibouti export unload and interchange generation',
}),
);
await scheduleBookingRepo.save(
scheduleBookingRepo.create({
trainScheduleId: schedule.id,
bookingId: booking.id,
}),
);
}
seeded += 1;
}
this.logger.log(`Export Djibouti interchange demo seed complete: ${seeded} train(s) seeded, ${skipped} skipped`);
} catch (error) {
this.logger.error(
`ExportDjiboutiInterchangeDemoSeeder failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}

View File

@@ -352,6 +352,135 @@ const CLEARANCE_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [
},
];
// ── Contract pre-booking clearance settings (Path B) ────────────────────────
// For customs-clearance contracts the customer uploads clearance documents on
// the CONTRACT (before any booking exists). Same customer doc sets as the legacy
// booking clearance, plus the GL output sets, keyed on the contract. Resolved by
// contract-clearance.util.ts (codes: contract_clearance_{op}_{freight} and
// contract_clearance_output_{op}_container).
const CONTRACT_CLEARANCE_ENTITY = "contract_clearance";
const CONTRACT_CLEARANCE_SETTINGS: OnboardingDocumentSetting[] = [
{
code: "contract_clearance_import_container",
label: "Contract clearance documents (import container)",
entity: CONTRACT_CLEARANCE_ENTITY,
fields: IMPORT_CONTAINER_FIELDS,
},
{
code: "contract_clearance_export_container",
label: "Contract clearance documents (export container)",
entity: CONTRACT_CLEARANCE_ENTITY,
fields: EXPORT_CONTAINER_FIELDS,
},
{
code: "contract_clearance_import_bulk",
label: "Contract clearance documents (import bulk)",
entity: CONTRACT_CLEARANCE_ENTITY,
fields: IMPORT_BULK_FIELDS,
},
{
code: "contract_clearance_export_bulk",
label: "Contract clearance documents (export bulk)",
entity: CONTRACT_CLEARANCE_ENTITY,
fields: EXPORT_BULK_FIELDS,
},
// GL ET output sets uploaded during pre-booking clearance.
{
code: "contract_clearance_output_import_container",
label: "Contract customs output documents (import container)",
entity: CONTRACT_CLEARANCE_ENTITY,
fields: IMPORT_CONTAINER_OUTPUT_FIELDS,
},
{
code: "contract_clearance_output_export_container",
label: "Contract customs output documents (export container)",
entity: CONTRACT_CLEARANCE_ENTITY,
fields: EXPORT_CONTAINER_OUTPUT_FIELDS,
},
];
// ── Path A self-clearance settings (no EDR customs service) ──────────────────
// When the contract does NOT bundle customs clearance, the customer clears the
// cargo himself and uploads his OWN clearance proof on the contract. Operations
// (not GL) reviews this smaller set before the customer may create the booking.
// Resolved by contract-clearance.util.ts as contract_clearance_selfclear_{op}_{freight}.
const SELF_CLEARANCE_IMPORT_FIELDS: OnboardingField[] = [
clearanceField("customs_declaration", "Customs Declaration (IM4/IM5)", 1),
clearanceField("import_release", "Import Release Permit", 2),
clearanceField("duty_tax_receipt", "Duty & Tax Payment Receipt", 3, {
required: false,
}),
clearanceField("delivery_order", "Delivery Order", 4, { required: false }),
clearanceField("supporting_document", "Other Clearance Document", 5, {
required: false,
}),
];
const SELF_CLEARANCE_EXPORT_FIELDS: OnboardingField[] = [
clearanceField("customs_declaration", "Customs Declaration (EX3/EX8)", 1),
clearanceField("export_release", "Export Release", 2),
clearanceField("transit_document", "Transit Document (T1)", 3, {
required: false,
}),
clearanceField("supporting_document", "Other Clearance Document", 4, {
required: false,
}),
];
const SELF_CLEARANCE_SETTINGS: OnboardingDocumentSetting[] = [
{
code: "contract_clearance_selfclear_import_container",
label: "Self-clearance documents (import container)",
entity: CONTRACT_CLEARANCE_ENTITY,
fields: SELF_CLEARANCE_IMPORT_FIELDS,
},
{
code: "contract_clearance_selfclear_export_container",
label: "Self-clearance documents (export container)",
entity: CONTRACT_CLEARANCE_ENTITY,
fields: SELF_CLEARANCE_EXPORT_FIELDS,
},
{
code: "contract_clearance_selfclear_import_bulk",
label: "Self-clearance documents (import bulk)",
entity: CONTRACT_CLEARANCE_ENTITY,
fields: SELF_CLEARANCE_IMPORT_FIELDS,
},
{
code: "contract_clearance_selfclear_export_bulk",
label: "Self-clearance documents (export bulk)",
entity: CONTRACT_CLEARANCE_ENTITY,
fields: SELF_CLEARANCE_EXPORT_FIELDS,
},
];
// ── Contract intake settings ────────────────────────────────────────────────
// Commercial/framework documents attached at contract submission (wizard step 5),
// distinct from the post-sign clearance docs above.
const CONTRACT_INTAKE_ENTITY = "contract_intake";
const CONTRACT_INTAKE_FIELDS: OnboardingField[] = [
clearanceField("commercial_framework", "Commercial Framework / Agreement", 1, {
required: false,
}),
clearanceField("onboarding_attachment", "Onboarding Attachment", 2, {
required: false,
}),
clearanceField("supporting_document", "Supporting Document", 3, {
required: false,
}),
];
const CONTRACT_INTAKE_SETTINGS: OnboardingDocumentSetting[] = [
{
code: "contract_intake_documents",
label: "Contract intake documents",
entity: CONTRACT_INTAKE_ENTITY,
fields: CONTRACT_INTAKE_FIELDS,
},
];
const CLEARANCE_DESCRIPTION =
"Operation/clearance documents collected after contract execution, by operation, freight type and customs.";
@@ -377,6 +506,21 @@ export class FileUploadSettingsSeeder {
...s,
description: CLEARANCE_DESCRIPTION,
})),
...CONTRACT_CLEARANCE_SETTINGS.map((s) => ({
...s,
description:
"Pre-booking clearance documents collected on the contract (Path B), by operation and freight type.",
})),
...SELF_CLEARANCE_SETTINGS.map((s) => ({
...s,
description:
"Customer self-clearance documents (Path A, no EDR customs service), reviewed by Operations.",
})),
...CONTRACT_INTAKE_SETTINGS.map((s) => ({
...s,
description:
"Commercial/framework documents attached at contract submission.",
})),
];
for (const documentSetting of allSettings) {

View File

@@ -62,6 +62,26 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [
perm('a1000001-0001-4000-8000-000000000013', 'edr_freight_app:admin', 'Freight administration'),
];
/**
* Contract-phase permissions (contractbooking separation). Mirror the booking
* approval/sign/clearance permissions but scoped to the new contracts module.
*/
export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [
perm('a3000001-0001-4000-8000-000000000001', 'edr_freight_app:contracts:view', 'View contracts'),
perm('a3000001-0001-4000-8000-000000000002', 'edr_freight_app:contracts:staff_accept', 'Accept contract intake'),
perm('a3000001-0001-4000-8000-000000000003', 'edr_freight_app:contracts:request_changes', 'Request contract changes'),
perm('a3000001-0001-4000-8000-000000000004', 'edr_freight_app:contracts:reject', 'Reject contract'),
perm('a3000001-0001-4000-8000-000000000005', 'edr_freight_app:contracts:approve_line_staff', 'Approve contract as line staff'),
perm('a3000001-0001-4000-8000-000000000006', 'edr_freight_app:contracts:approve_director', 'Approve contract as director'),
perm('a3000001-0001-4000-8000-000000000007', 'edr_freight_app:contracts:approve_ceo', 'Approve contract as CEO'),
perm('a3000001-0001-4000-8000-000000000008', 'edr_freight_app:contracts:generate_contract', 'Generate contract document'),
perm('a3000001-0001-4000-8000-000000000009', 'edr_freight_app:contracts:sign_staff', 'Staff contract signature'),
perm('a3000001-0001-4000-8000-00000000000a', 'edr_freight_app:contracts:clearance_review', 'Review pre-booking clearance docs'),
perm('a3000001-0001-4000-8000-00000000000b', 'edr_freight_app:contracts:finalize_clearance', 'Finalize pre-booking clearance'),
perm('a3000001-0001-4000-8000-00000000000c', 'edr_freight_app:contracts:create_booking', 'GL ET create booking under contract'),
perm('a3000001-0001-4000-8000-00000000000d', 'edr_freight_app:contracts:ops_clearance_review', 'Operations review of self-clearance docs (Path A)'),
];
const RULE_ENGINE_PERMISSION_IDS: Record<RuleEngineResourceSlug, { view: string; manage: string }> = {
'cargo-types': { view: 'b2000001-0001-4000-8000-000000000001', manage: 'b2000001-0001-4000-8000-000000000002' },
'container-types': { view: 'b2000001-0001-4000-8000-000000000003', manage: 'b2000001-0001-4000-8000-000000000004' },
@@ -88,6 +108,7 @@ export const RULE_ENGINE_PERMISSIONS: FreightPermissionSeed[] = RULE_ENGINE_RESO
export const BOOKING_RULE_ENGINE_PERMISSIONS = [
...BOOKING_PERMISSIONS,
...CONTRACT_PERMISSIONS,
...RULE_ENGINE_PERMISSIONS,
];
@@ -114,6 +135,21 @@ export const FREIGHT_PERMS = {
uploadClearanceOutput: 'edr_freight_app:bookings:upload_clearance_output',
finalizeClearance: 'edr_freight_app:bookings:finalize_clearance',
},
contracts: {
view: 'edr_freight_app:contracts:view',
staffAccept: 'edr_freight_app:contracts:staff_accept',
requestChanges: 'edr_freight_app:contracts:request_changes',
reject: 'edr_freight_app:contracts:reject',
approveLineStaff: 'edr_freight_app:contracts:approve_line_staff',
approveDirector: 'edr_freight_app:contracts:approve_director',
approveCeo: 'edr_freight_app:contracts:approve_ceo',
generateContract: 'edr_freight_app:contracts:generate_contract',
signStaff: 'edr_freight_app:contracts:sign_staff',
clearanceReview: 'edr_freight_app:contracts:clearance_review',
finalizeClearance: 'edr_freight_app:contracts:finalize_clearance',
createBooking: 'edr_freight_app:contracts:create_booking',
opsClearanceReview: 'edr_freight_app:contracts:ops_clearance_review',
},
trainScheduling: {
view: 'edr_freight_app:train_scheduling:view',
manage: 'edr_freight_app:train_scheduling:manage',
@@ -146,6 +182,11 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.bookings.approveLineStaff,
FREIGHT_PERMS.bookings.rejectApproval,
FREIGHT_PERMS.bookings.cancel,
FREIGHT_PERMS.contracts.view,
FREIGHT_PERMS.contracts.staffAccept,
FREIGHT_PERMS.contracts.requestChanges,
FREIGHT_PERMS.contracts.reject,
FREIGHT_PERMS.contracts.approveLineStaff,
...allRuleEngineViewKeys(),
],
// Operations Officer: train scheduling + wagon allocation + transit/complete
@@ -157,6 +198,10 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.trainScheduling.manage,
FREIGHT_PERMS.fleet.view,
FREIGHT_PERMS.fleet.manage,
// Path A (no customs): Operations reviews the customer's self-clearance docs
// on the contract before the customer may create a shipment booking.
FREIGHT_PERMS.contracts.view,
FREIGHT_PERMS.contracts.opsClearanceReview,
...allRuleEngineViewKeys(),
],
director: [
@@ -164,12 +209,17 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.bookings.approveDirector,
FREIGHT_PERMS.bookings.rejectApproval,
FREIGHT_PERMS.bookings.generateContract,
FREIGHT_PERMS.contracts.view,
FREIGHT_PERMS.contracts.approveDirector,
FREIGHT_PERMS.contracts.generateContract,
...allRuleEngineViewKeys(),
],
ceo: [
FREIGHT_PERMS.bookings.view,
FREIGHT_PERMS.bookings.approveCeo,
FREIGHT_PERMS.bookings.rejectApproval,
FREIGHT_PERMS.contracts.view,
FREIGHT_PERMS.contracts.approveCeo,
...allRuleEngineViewKeys(),
],
finance: [FREIGHT_PERMS.bookings.view],
@@ -178,11 +228,28 @@ export const ROLE_PERMISSION_PRESETS = {
// clearance:view permission lists the clearance bookings. Reviews customer
// clearance documents, uploads customs output documents, and finalizes the
// clearance gate.
globalLogistics: [
// GL Ethiopia (edr_gl_ethiopia): reviews pre-booking clearance docs on the
// contract, uploads ET output docs, finalizes clearance, and EXCLUSIVELY creates
// the booking under a customs contract (Path B). Also runs post-booking ET
// milestones + the legacy booking-clearance permissions during migration.
glEthiopia: [
FREIGHT_PERMS.contracts.view,
FREIGHT_PERMS.contracts.clearanceReview,
FREIGHT_PERMS.contracts.finalizeClearance,
FREIGHT_PERMS.contracts.createBooking,
FREIGHT_PERMS.bookings.clearanceView,
FREIGHT_PERMS.bookings.reviewDocuments,
FREIGHT_PERMS.bookings.uploadClearanceOutput,
FREIGHT_PERMS.bookings.finalizeClearance,
FREIGHT_PERMS.bookings.operations,
],
// GL Djibouti (edr_gl_djibouti): DO/RO collection, gatepass, loading milestones,
// damage reports. Read-only on the contract; no booking creation.
glDjibouti: [
FREIGHT_PERMS.contracts.view,
FREIGHT_PERMS.bookings.clearanceView,
FREIGHT_PERMS.bookings.uploadClearanceOutput,
FREIGHT_PERMS.bookings.operations,
],
// Marketing handles intake through contract (same as line staff here) and,
// for non-customs bookings, reviews/finalizes the customer's clearance
@@ -199,6 +266,13 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.bookings.signStaff,
FREIGHT_PERMS.bookings.reviewDocuments,
FREIGHT_PERMS.bookings.finalizeClearance,
FREIGHT_PERMS.contracts.view,
FREIGHT_PERMS.contracts.staffAccept,
FREIGHT_PERMS.contracts.requestChanges,
FREIGHT_PERMS.contracts.reject,
FREIGHT_PERMS.contracts.approveLineStaff,
FREIGHT_PERMS.contracts.generateContract,
FREIGHT_PERMS.contracts.signStaff,
],
orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS],
} as const;
@@ -206,5 +280,9 @@ export const ROLE_PERMISSION_PRESETS = {
export const PERMISSIONS_CATALOG = BOOKING_RULE_ENGINE_PERMISSIONS.map((p) => ({
key: p.key,
label: p.name.en,
module: p.key.includes(':bookings:') ? 'bookings' : 'rule_engine',
module: p.key.includes(':bookings:')
? 'bookings'
: p.key.includes(':contracts:')
? 'contracts'
: 'rule_engine',
}));

View File

@@ -18,7 +18,8 @@ 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' },
{ email: 'gl-et@edr.local', username: 'gl_et', roleKey: 'edr_gl_ethiopia' },
{ email: 'gl-dj@edr.local', username: 'gl_dj', roleKey: 'edr_gl_djibouti' },
] as const;
@Injectable()