mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
322 lines
12 KiB
TypeScript
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).',
|
|
);
|
|
}
|
|
}
|