Files
edr-platform/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts

322 lines
12 KiB
TypeScript

import { Injectable, Logger } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { DataSource } from 'typeorm';
import { BookingContainer } from '../modules/bookings/entities/booking-container.entity';
import { Booking } from '../modules/bookings/entities/booking.entity';
import {
CompanyProfile,
ProfileStatus,
ProfileType,
} from '../modules/companies/entities/company-profile.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 { wagonsPerUnitForSize } from '../modules/rule-engine/container-type.util';
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_PAID_MILE';
// companies.tin is varchar(10) — an 11-char TIN 22001s the whole seeder.
const COMPANY_TIN = 'PAIDMILE01';
const COMPANY_EMAIL = 'paid-mile-demo@edr.local';
const YARDS = [
{ code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti' as const, displayOrder: 1 },
{ code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia' as const, displayOrder: 2 },
];
const CONTAINER_TYPES = [
{ code: '20FT', label: '20FT', sizeFt: 20 },
{ code: '40FT', label: '40FT', sizeFt: 40 },
];
/**
* Six paid, approved container bookings that mirror the real trucking legs:
* - EXPORT (Ethiopia -> Djibouti) carries a FIRST-MILE leg (factory -> rail terminal).
* - IMPORT (Djibouti -> Ethiopia) carries a LAST-MILE leg (dry port -> final delivery).
* Each booking is paymentStatus PAID and its single mile leg is marked paid + ready to transit.
*/
const DEMO_BOOKINGS = [
// ── IMPORT: last mile only ─────────────────────────────────────────────
{
reference: 'PAID-IMP-001',
tradeDirection: 'IMPORT',
containerCode: '40FT',
quantity: 8,
totalWeightTons: 224,
originCode: 'DJIBOUTI',
destinationCode: 'ADDIS_ABABA',
scheduledDate: '2026-07-01T08:00:00.000Z',
lastMileDeliveryAddress: 'Akaki Industrial Zone, Addis Ababa',
lastMileDeliveryLat: 8.8808,
lastMileDeliveryLng: 38.7876,
},
{
reference: 'PAID-IMP-002',
tradeDirection: 'IMPORT',
containerCode: '20FT',
quantity: 12,
totalWeightTons: 240,
originCode: 'DJIBOUTI',
destinationCode: 'ADDIS_ABABA',
scheduledDate: '2026-07-02T08:00:00.000Z',
lastMileDeliveryAddress: 'Kality Logistics Hub, Addis Ababa',
lastMileDeliveryLat: 8.9137,
lastMileDeliveryLng: 38.7815,
},
{
reference: 'PAID-IMP-003',
tradeDirection: 'IMPORT',
containerCode: '40FT',
quantity: 6,
totalWeightTons: 180,
originCode: 'DJIBOUTI',
destinationCode: 'ADDIS_ABABA',
scheduledDate: '2026-07-03T08:00:00.000Z',
lastMileDeliveryAddress: 'Bole Lemi Industrial Park, Addis Ababa',
lastMileDeliveryLat: 8.9806,
lastMileDeliveryLng: 38.8736,
},
// ── EXPORT: first mile only ────────────────────────────────────────────
{
reference: 'PAID-EXP-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,
},
{
reference: 'PAID-EXP-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,
},
{
reference: 'PAID-EXP-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,
},
] as const;
@Injectable()
export class PaidImportExportMileDemoSeeder {
private readonly logger = new Logger(PaidImportExportMileDemoSeeder.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 Paid First/Last Mile',
description: 'Demo service type for paid import/export bookings with a single mile leg',
canBeBookedAlone: true,
includesFirstMile: true,
includesLastMile: true,
includesCustoms: false,
isActive: true,
displayOrder: 11,
},
{ conflictPaths: { code: true } },
);
await manager.getRepository(ContainerType).upsert(
CONTAINER_TYPES.map((containerType, index) => ({
...containerType,
isReefer: false,
isOpenTop: false,
isActive: true,
displayOrder: index + 1,
})),
{ conflictPaths: { code: true } },
);
await manager.getRepository(Company).upsert(
{
name: 'Paid Import/Export Mile Demo Customer',
type: CompanyType.Customer,
status: CompanyStatus.Active,
tin: COMPANY_TIN,
vatNumber: COMPANY_TIN,
fanNumber: 'PMD0000000000001',
country: 'Ethiopia',
address: 'Addis Ababa',
phone: '251900000202',
email: COMPANY_EMAIL,
website: null,
contactPersonName: 'Paid Mile Demo',
contactPersonPhone: '251900000202',
generalManagerName: 'Demo Manager',
generalManagerEmail: COMPANY_EMAIL,
generalManagerPhone: '251900000202',
},
{ 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(),
]);
// bookings.company_profile_id is NOT NULL — the demo company needs an
// approved importer profile of its own (no unique key to upsert on).
const profileRepo = manager.getRepository(CompanyProfile);
const companyProfile =
(await profileRepo.findOne({
where: { companyId: company.id, type: ProfileType.importer },
})) ??
(await profileRepo.save(
profileRepo.create({
companyId: company.id,
type: ProfileType.importer,
status: ProfileStatus.Active,
businessLicense: 'PMD-LIC-0001',
}),
));
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(`paid_import_export_mile_demo_dependency_missing:${demoBooking.reference}`);
}
const isImport = demoBooking.tradeDirection === 'IMPORT';
const wagonsRequired =
Number(demoBooking.quantity) * wagonsPerUnitForSize(containerType.sizeFt);
const vgmPerUnitTons = demoBooking.totalWeightTons / demoBooking.quantity;
await manager.getRepository(Booking).upsert(
{
reference: demoBooking.reference,
companyId: company.id,
companyProfileId: companyProfile.id,
status: 'APPROVED',
scheduledDate: new Date(demoBooking.scheduledDate),
estimatedShipmentDate: new Date(demoBooking.scheduledDate),
totalAmount: demoBooking.totalWeightTons * 25,
paymentStatus: 'PAID',
contractType: 'NEW',
serviceTypeId: serviceType.id,
// Only the leg that matches the trade direction carries an address.
firstMilePickupAddress: isImport ? null : demoBooking.firstMilePickupAddress,
firstMilePickupLat: isImport ? null : demoBooking.firstMilePickupLat,
firstMilePickupLng: isImport ? null : demoBooking.firstMilePickupLng,
lastMileDeliveryAddress: isImport ? demoBooking.lastMileDeliveryAddress : null,
lastMileDeliveryLat: isImport ? demoBooking.lastMileDeliveryLat : null,
lastMileDeliveryLng: isImport ? demoBooking.lastMileDeliveryLng : null,
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,
});
// Reset any existing legs for idempotency, then create the single paid leg.
await manager.getRepository(FirstMile).delete({ bookingId: booking.id });
await manager.getRepository(LastMile).delete({ bookingId: booking.id });
const paidAmount = demoBooking.totalWeightTons * 25;
if (isImport) {
await manager.getRepository(LastMile).insert({
bookingId: booking.id,
status: 'READY_TO_TRANSIT',
advancedPayment: paidAmount,
remainingPayment: 0,
paid: true,
estimatedKm: 22,
exactKm: null,
vehicleId: null,
});
} else {
await manager.getRepository(FirstMile).insert({
bookingId: booking.id,
status: 'READY_TO_TRANSIT',
advancedPayment: paidAmount,
remainingPayment: 0,
paid: true,
estimatedKm: 18,
exactKm: null,
vehicleId: null,
});
}
}
});
this.logger.log(
'Seeded 6 paid bookings: 3 import (last-mile) + 3 export (first-mile).',
);
}
}