Files
edr-platform/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts
Hagernesh 50fab52b1c feat(warehouses): filters, pagination and charts on container returns
Returned-containers list now uses the shared DataTable + useListControls
(search, inclusive date range, status select, pagination) instead of a
hand-rolled table. Adds two charts below the list — returns per day by
truck type and returns by status — driven by the same filtered rows.
Series colors validated for CVD separation and surface contrast.
2026-08-04 09:54:15 +00:00

341 lines
13 KiB
TypeScript

import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { Booking } from '../modules/bookings/entities/booking.entity';
import { CustomerTruckAssignment } from '../modules/bookings/entities/customer-truck-assignment.entity';
import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity';
import { CompanyProfile } from '../modules/companies/entities/company-profile.entity';
import { EmptyContainerReturn } from '../modules/import-operations/entities/empty-container-return.entity';
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 { 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 { Warehouse } from '../modules/warehouses/entities/warehouse.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';
/**
* One coherent warehouse dataset so EVERY queue/tab shows representative data:
* Export → Receive Queue : PAID export bookings, not yet received
* Export → Ready To Load : EXPORT inventory READY_FOR_LOADING + inspection PASSED
* Export → Loaded/Dispatch : EXPORT inventory LOADED
* Import → Arrive Queue : an ARRIVED import train with IN_TRANSIT bookings (no inventory)
* Import → Unloaded Queue : UNLOADED import inventory
* Import → Dispatch Queue : READY_FOR_PICKUP import inventory (PASSED)
* Import → Import Trucks : a customer self-haul truck assigned to an unloaded booking
* Import → Container Returns : empty container returns at two different statuses
*
* Idempotent: guarded on a sentinel booking reference. Uses dedicated WH-DEMO-* references so it
* never collides with other seeders. To repopulate after items are walked through their lifecycle,
* delete the WH-DEMO-* bookings (cascades) and reboot.
*/
@Injectable()
export class WarehouseDemoSeeder {
private readonly logger = new Logger(WarehouseDemoSeeder.name);
private readonly SENTINEL = 'WH-DEMO-RCV-1';
constructor(private readonly dataSource: DataSource) {}
async run(): Promise<void> {
const bookingRepo = this.dataSource.getRepository(Booking);
if (await bookingRepo.findOne({ where: { reference: this.SENTINEL } })) {
this.logger.log('Warehouse demo data already seeded, skipping');
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 whYardRepo = this.dataSource.getRepository(WarehouseYard);
const whZoneRepo = this.dataSource.getRepository(WarehouseZone);
const invRepo = this.dataSource.getRepository(WarehouseInventory);
const djibYard =
(await yardRepo.findOne({ where: { code: 'DJIB_PORT' } })) ??
(await yardRepo.findOne({ where: { country: 'Djibouti' } }));
const ethYard =
(await yardRepo.findOne({ where: { code: 'MOJO' } })) ??
(await yardRepo.findOne({ where: { country: 'Ethiopia' } }));
const serviceType =
(await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ??
(await serviceTypeRepo.findOne({ where: { isActive: true } }));
const cargoType = await cargoTypeRepo.findOne({ where: { isActive: true } });
// bookings.company_id AND company_profile_id are both NOT NULL — a demo booking
// still needs an owner. Take the company from the profile so they always agree.
const companyProfile = await this.dataSource
.getRepository(CompanyProfile)
.findOne({ where: {} });
if (!djibYard || !ethYard || !serviceType || !companyProfile) {
this.logger.warn(
`Missing yards/service type/company profile (djib=${djibYard?.code}, eth=${ethYard?.code}, ` +
`svc=${serviceType?.code}, companyProfile=${companyProfile?.id ?? 'none'}); skipping`,
);
return;
}
const warehouse = await warehouseRepo.findOne({ where: { code: 'INDODE_OPEN' } });
const whYard = warehouse ? await whYardRepo.findOne({ where: { warehouseId: warehouse.id } }) : null;
const whZone = whYard ? await whZoneRepo.findOne({ where: { yardId: whYard.id } }) : null;
if (!warehouse || !whYard || !whZone) {
this.logger.warn('INDODE_OPEN warehouse/yard/zone missing; skipping warehouse demo seed');
return;
}
const now = Date.now();
const ago = (mins: number) => new Date(now - mins * 60_000);
// EXPORT booking = Ethiopia → Djibouti; IMPORT booking = Djibouti → Ethiopia.
const makeBooking = async (
reference: string,
direction: 'EXPORT' | 'IMPORT',
status: string,
weight: number,
idx: number,
): Promise<Booking> =>
bookingRepo.save(
bookingRepo.create({
...this.demoBookingDefaults(companyProfile),
reference,
originYardId: direction === 'EXPORT' ? ethYard.id : djibYard.id,
destinationYardId: direction === 'EXPORT' ? djibYard.id : ethYard.id,
serviceTypeId: serviceType.id,
status,
paymentStatus: 'PAID',
tradeDirection: direction,
freightType: idx % 2 === 0 ? 'CONTAINER' : 'BULK',
cargoTypeId: cargoType?.id ?? null,
cargoFreeText: cargoType ? null : `${direction} demo cargo ${idx}`,
cargoTotalWeightVgm: weight,
}),
);
const makeInventory = async (
booking: Booking,
status: string,
weight: number,
extra: Partial<WarehouseInventory>,
): Promise<void> => {
await invRepo.save(
invRepo.create({
warehouseId: warehouse.id,
yardId: whYard.id,
zoneId: whZone.id,
bookingId: booking.id,
quantity: 1,
weight,
status: status as WarehouseInventory['status'],
notes: '[WH-DEMO]',
...extra,
}),
);
};
let created = 0;
// 1) Export Receive Queue — 3 PAID export bookings, NO inventory.
for (let i = 1; i <= 3; i++) {
await makeBooking(`WH-DEMO-RCV-${i}`, 'EXPORT', 'PAID', 4000 + i * 500, i);
created++;
}
// 2) Export Ready To Load — EXPORT inventory READY_FOR_LOADING + PASSED.
for (let i = 1; i <= 3; i++) {
const b = await makeBooking(`WH-DEMO-RTL-${i}`, 'EXPORT', 'PAID', 6000 + i * 500, i);
await makeInventory(b, 'READY_FOR_LOADING', 6000 + i * 500, {
inspectionStatus: 'PASSED',
arrivedAt: ago(180),
inspectedAt: ago(120),
readyForLoadingAt: ago(60),
});
created++;
}
// 3) Export Loaded / Dispatch Queue — EXPORT inventory LOADED.
for (let i = 1; i <= 2; i++) {
const b = await makeBooking(`WH-DEMO-LOAD-${i}`, 'EXPORT', 'PAID', 7000 + i * 500, i);
await makeInventory(b, 'LOADED', 7000 + i * 500, {
inspectionStatus: 'PASSED',
arrivedAt: ago(240),
inspectedAt: ago(180),
readyForLoadingAt: ago(120),
loadedAt: ago(30),
});
created++;
}
// 4) Import Unloaded Queue — UNLOADED import inventory (not inspected, not stored).
let firstUnloadedBooking: Booking | null = null;
for (let i = 1; i <= 3; i++) {
const b = await makeBooking(`WH-DEMO-UNL-${i}`, 'IMPORT', 'IN_TRANSIT', 5000 + i * 500, i);
await makeInventory(b, 'UNLOADED', 5000 + i * 500, {
arrivedAt: ago(90),
unloadedAt: ago(45),
});
firstUnloadedBooking ??= b;
created++;
}
// 5) Import Dispatch Queue — READY_FOR_PICKUP import inventory (inspection PASSED).
let firstPickupBooking: Booking | null = null;
for (let i = 1; i <= 3; i++) {
const b = await makeBooking(`WH-DEMO-PKR-${i}`, 'IMPORT', 'IN_TRANSIT', 5500 + i * 500, i);
await makeInventory(b, 'READY_FOR_PICKUP', 5500 + i * 500, {
inspectionStatus: 'PASSED',
arrivedAt: ago(200),
unloadedAt: ago(160),
inspectedAt: ago(120),
readyForPickupAt: ago(60),
});
firstPickupBooking ??= b;
created++;
}
// 6) Import Trucks / booking Trucks tab — a customer self-haul truck on the unloaded booking.
if (firstUnloadedBooking) {
await this.dataSource.getRepository(CustomerTruckAssignment).save(
this.dataSource.getRepository(CustomerTruckAssignment).create({
bookingId: firstUnloadedBooking.id,
plateNumber: 'WH-DEMO-3210',
driverName: 'Demo Driver',
truckType: 'FLATBED',
assignedAt: ago(80),
arrivedAt: ago(50),
}),
);
created++;
}
// 7) Container Returns — two empty returns at different stages of the return workflow.
if (firstPickupBooking) {
const returnRepo = this.dataSource.getRepository(EmptyContainerReturn);
await returnRepo.save(
returnRepo.create({
containerNumber: 'WHDU1234561',
bookingId: firstPickupBooking.id,
returnDate: ago(20),
facility: 'Indode',
status: 'RETURNED',
returnedBy: 'CUSTOMER',
statusHistory: [],
}),
);
await returnRepo.save(
returnRepo.create({
containerNumber: 'WHDU1234562',
bookingId: firstPickupBooking.id,
returnDate: ago(90),
facility: 'Indode',
status: 'DOCUMENTATION_CLEARED',
returnedBy: 'CUSTOMER',
statusHistory: [],
}),
);
created++;
}
// 6) Import Arrive Queue — an ARRIVED import train with IN_TRANSIT bookings, no inventory yet.
await this.seedArrivedImportTrain(
djibYard,
ethYard,
serviceType,
cargoType,
companyProfile,
ago(60),
ago(360),
);
created += 1;
this.logger.log(`✅ Warehouse demo seeded: ${created} buckets populated across every queue`);
} catch (error) {
this.logger.error(
`WarehouseDemoSeeder failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/** An ARRIVED Djibouti→Ethiopia train with 3 IN_TRANSIT bookings (no inventory) for the Arrive Queue. */
private async seedArrivedImportTrain(
djibYard: Yard,
ethYard: Yard,
serviceType: ServiceType,
cargoType: CargoType | null,
owner: CompanyProfile,
arrival: Date,
departure: Date,
): Promise<void> {
const bookingRepo = this.dataSource.getRepository(Booking);
const locoRepo = this.dataSource.getRepository(Locomotive);
const trainSetRepo = this.dataSource.getRepository(TrainSet);
const scheduleRepo = this.dataSource.getRepository(TrainSchedule);
const scheduleBookingRepo = this.dataSource.getRepository(TrainScheduleBooking);
const loco =
(await locoRepo.findOne({ where: { code: 'WH-DEMO-LOCO' } })) ??
(await locoRepo.save(locoRepo.create({ code: 'WH-DEMO-LOCO', name: 'Demo Locomotive', maxPullWeightTons: 4000 })));
const trainSet = await trainSetRepo.save(
trainSetRepo.create({
locomotiveId: loco.id,
totalWeightTons: 500,
totalLengthMeters: 300,
wagonCount: 10,
status: 'COMPLETED',
}),
);
const schedule = await scheduleRepo.save(
scheduleRepo.create({
trainSetId: trainSet.id,
originStationId: djibYard.id,
destinationStationId: ethYard.id,
scheduledDepartureDate: departure,
scheduledArrivalDate: arrival,
actualArrivalAt: arrival,
status: 'ARRIVED' as TrainSchedule['status'],
trainNumber: 'WH-DEMO-IMP-TRAIN',
}),
);
for (let i = 1; i <= 3; i++) {
const b = await bookingRepo.save(
bookingRepo.create({
...this.demoBookingDefaults(owner),
reference: `WH-DEMO-ARR-${i}`,
originYardId: djibYard.id,
destinationYardId: ethYard.id,
serviceTypeId: serviceType.id,
status: 'IN_TRANSIT',
paymentStatus: 'PAID',
tradeDirection: 'IMPORT',
freightType: i % 2 === 0 ? 'CONTAINER' : 'BULK',
cargoTypeId: cargoType?.id ?? null,
cargoFreeText: cargoType ? null : `IMPORT arrive demo cargo ${i}`,
cargoTotalWeightVgm: 5000 + i * 400,
}),
);
await scheduleBookingRepo.save(
scheduleBookingRepo.create({ trainScheduleId: schedule.id, bookingId: b.id }),
);
}
}
private demoBookingDefaults(owner: CompanyProfile): Partial<Booking> {
return {
companyId: owner.companyId,
companyProfileId: owner.id,
scheduledDate: new Date(),
contractType: 'SPOT',
equipmentReturn: 'TERMINAL',
paymentCurrency: 'ETB',
totalAmount: 0,
isGovernment: false,
};
}
}