fix conflict

This commit is contained in:
hagiye
2026-06-25 11:31:50 +03:00
168 changed files with 8445 additions and 2583 deletions

View File

@@ -41,12 +41,54 @@ export class BookingOrdersService {
) {}
/** Orders placed against a contract, with their lines and child booking. */
listByContract(contractBookingId: string): Promise<BookingOrder[]> {
return this.ordersRepository.findByContract(contractBookingId);
async listByContract(contractBookingId: string): Promise<BookingOrder[]> {
const orders = await this.ordersRepository.findByContract(contractBookingId);
await Promise.all(orders.map((o) => this.syncOrderFromChild(o)));
return orders;
}
findById(id: string): Promise<BookingOrder | null> {
return this.ordersRepository.findById(id);
async findById(id: string): Promise<BookingOrder | null> {
const order = await this.ordersRepository.findById(id);
if (order) await this.syncOrderFromChild(order);
return order;
}
/**
* The order is a ledger row; the spawned child ONE_TIME booking is what
* actually moves through the workflow (clearance → marketing/ops accept →
* pay → allocate), exactly like a one-time booking. Nothing writes the order
* row after creation, so its stored status would stay 'PENDING' forever.
*
* Mirror the child onto the order whenever it is read: copy the child's
* status, schedulingStatus and trainScheduleId onto the order (mutating the
* in-memory instance the caller gets back), and persist that snapshot when it
* has drifted so list/detail views and any stored reporting stay in sync.
*/
private async syncOrderFromChild(order: BookingOrder): Promise<void> {
const child = order.booking;
if (!child) return;
const nextStatus = child.status;
const nextScheduling = child.schedulingStatus;
const nextTrainScheduleId = child.trainScheduleId ?? null;
const drifted =
order.status !== nextStatus ||
order.schedulingStatus !== nextScheduling ||
(order.trainScheduleId ?? null) !== nextTrainScheduleId;
// Reflect the child onto the instance returned to the caller.
order.status = nextStatus;
order.schedulingStatus = nextScheduling;
order.trainScheduleId = nextTrainScheduleId;
if (drifted) {
await this.ordersRepository.update(order.id, {
status: nextStatus,
schedulingStatus: nextScheduling,
trainScheduleId: nextTrainScheduleId,
});
}
}
/**
@@ -125,7 +167,6 @@ export class BookingOrdersService {
}
const isContainer = contract.freightType === 'CONTAINER';
const orderTotal = dto.lines.reduce((sum, l) => sum + l.quantity, 0);
// Hazardous/reefer counts the customer entered cannot exceed the line they
// belong to. Validated for every order regardless of routing.
@@ -142,43 +183,31 @@ export class BookingOrdersService {
}
}
if (routeLineId) {
// Multi-route: validate against the chosen route line's remaining pool.
for (const line of dto.lines) {
if (line.quantity <= 0) {
throw new BadRequestException('Order quantities must be greater than zero');
}
// The contract has a single shared drawdown pool (per container type for
// CONTAINER, or one bulk bucket). Routes are pure lanes — the chosen route
// only fixed origin/destination/km above — so every order, routed or not,
// validates each line against the same shared pool.
const poolLines = await this.generalContractService.getQuantityLines(
contract.id,
);
for (const line of dto.lines) {
if (line.quantity <= 0) {
throw new BadRequestException('Order quantities must be greater than zero');
}
const chosen = routeLines.find((r) => r.routeLineId === routeLineId)!;
if (orderTotal > chosen.remainingQuantity) {
const key = isContainer ? (line.containerTypeId ?? '') : '';
const poolLine = poolLines.find((p) => (p.containerTypeId ?? '') === key);
if (!poolLine) {
throw new BadRequestException(
`Requested ${orderTotal} exceeds remaining ${chosen.remainingQuantity} for this route`,
isContainer
? `Container type ${line.containerTypeId} is not part of this contract`
: 'This contract has no matching quantity pool',
);
}
} else {
// Single-route: validate each line against the per-container-type pool.
const poolLines = await this.generalContractService.getQuantityLines(
contract.id,
);
for (const line of dto.lines) {
if (line.quantity <= 0) {
throw new BadRequestException('Order quantities must be greater than zero');
}
const key = isContainer ? (line.containerTypeId ?? '') : '';
const poolLine = poolLines.find((p) => (p.containerTypeId ?? '') === key);
if (!poolLine) {
throw new BadRequestException(
isContainer
? `Container type ${line.containerTypeId} is not part of this contract`
: 'This contract has no matching quantity pool',
);
}
if (line.quantity > poolLine.remainingQuantity) {
throw new BadRequestException(
`Requested ${line.quantity} exceeds remaining ${poolLine.remainingQuantity}` +
(poolLine.containerTypeName ? ` for ${poolLine.containerTypeName}` : ''),
);
}
if (line.quantity > poolLine.remainingQuantity) {
throw new BadRequestException(
`Requested ${line.quantity} exceeds remaining ${poolLine.remainingQuantity}` +
(poolLine.containerTypeName ? ` for ${poolLine.containerTypeName}` : ''),
);
}
}

View File

@@ -22,7 +22,13 @@ export class ContractQuantityLineView {
remainingQuantity!: number;
}
/** A contracted/ordered/remaining pool line for one route of a general contract. */
/**
* A contracted route (lane) of a general contract. Routes are pure
* origin→destination lanes the contract covers; they carry NO quantity. The
* contract has a single shared drawdown pool (see {@link ContractQuantityLineView}),
* and an order picks one lane (for scheduling/billing) while drawing from that
* shared pool.
*/
export class ContractRouteLineView {
@ApiProperty({ description: 'Contract route line id' })
routeLineId!: string;
@@ -39,21 +45,6 @@ export class ContractRouteLineView {
@ApiProperty({ nullable: true })
destinationYardName!: string | null;
@ApiProperty({ nullable: true, description: 'Container type id (null for bulk/break-bulk)' })
containerTypeId!: string | null;
@ApiProperty({ nullable: true })
containerTypeName!: string | null;
@ApiProperty()
contractedQuantity!: number;
@ApiProperty()
orderedQuantity!: number;
@ApiProperty()
remainingQuantity!: number;
@ApiProperty({ nullable: true, description: 'Road distance (km); used to bill road orders' })
km!: number | null;
}

View File

@@ -3,6 +3,16 @@ import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm';
import { ContainerType } from '../../rule-engine/entities/container-type.entity';
import { BookingOrder } from './booking-order.entity';
/**
* Postgres `numeric` columns are serialized to JS strings by the driver. This
* transformer hydrates them back into real numbers so consumers (and the
* `quantity: number` API type) don't have to coerce on every read.
*/
const numericColumn = {
to: (value: number) => value,
from: (value: string | null) => (value == null ? value : Number(value)),
};
/**
* One drawn-down quantity line of an order. For CONTAINER contracts there is one
* line per container type (matching the contract's pools); for BULK/BREAK_BULK a
@@ -25,7 +35,7 @@ export class BookingOrderLine extends BaseEntity {
containerType?: ContainerType | null;
/** Containers (count), tons, or items depending on the contract's freight/UoM. */
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3 })
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3, transformer: numericColumn })
quantity!: number;
/**
@@ -33,9 +43,23 @@ export class BookingOrderLine extends BaseEntity {
* customer when they toggle the flag. Drives the HAZARD_SURCHARGE /
* REEFER_SURCHARGE rates on the spawned child booking. Both ≤ quantity.
*/
@Column({ name: 'hazardous_quantity', type: 'numeric', precision: 12, scale: 3, default: 0 })
@Column({
name: 'hazardous_quantity',
type: 'numeric',
precision: 12,
scale: 3,
default: 0,
transformer: numericColumn,
})
hazardousQuantity!: number;
@Column({ name: 'reefer_quantity', type: 'numeric', precision: 12, scale: 3, default: 0 })
@Column({
name: 'reefer_quantity',
type: 'numeric',
precision: 12,
scale: 3,
default: 0,
transformer: numericColumn,
})
reeferQuantity!: number;
}

View File

@@ -125,10 +125,12 @@ export class GeneralContractService {
}
/**
* Per-route drawdown pool for a multi-route general contract: contracted vs.
* ordered vs. remaining, one entry per contracted route line. Returns [] for
* single-route contracts (no route lines) — callers fall back to
* {@link getQuantityLines}.
* The contracted routes (lanes) of a multi-route general contract — pure
* origin→destination pairs the contract covers. Routes carry NO quantity; the
* contract draws from a single shared pool ({@link getQuantityLines}). An order
* picks one lane (for scheduling + road billing) and draws from that pool.
* Returns [] for single-route contracts (no route lines) — callers then use the
* contract's own origin/destination.
*/
async getRouteLines(
contractBookingId: string,
@@ -140,52 +142,18 @@ export class GeneralContractService {
relations: {
originYard: true,
destinationYard: true,
containerType: true,
},
order: { createdAt: 'ASC' },
});
if (routeLines.length === 0) return [];
const ordered = await this.orderedByRouteLine(contractBookingId);
return routeLines.map((rl) => {
const orderedQty = ordered.get(rl.id) ?? 0;
const contracted = Number(rl.quantity);
return {
routeLineId: rl.id,
originYardId: rl.originYardId,
originYardName: rl.originYard?.label ?? null,
destinationYardId: rl.destinationYardId,
destinationYardName: rl.destinationYard?.label ?? null,
containerTypeId: rl.containerTypeId ?? null,
containerTypeName: rl.containerType?.label ?? null,
contractedQuantity: contracted,
orderedQuantity: orderedQty,
remainingQuantity: Math.max(0, contracted - orderedQty),
km: rl.km != null ? Number(rl.km) : null,
};
});
}
/** Sum of non-cancelled order quantities, keyed by route_line_id. */
private async orderedByRouteLine(
contractBookingId: string,
): Promise<Map<string, number>> {
const rows = await this.dataSource
.getRepository(BookingOrder)
.createQueryBuilder('o')
.innerJoin('o.lines', 'line')
.select('o.route_line_id', 'key')
.addSelect('SUM(line.quantity)', 'total')
.where('o.contract_booking_id = :contractBookingId', { contractBookingId })
.andWhere('o.route_line_id IS NOT NULL')
.andWhere(`o.status NOT IN ('CANCELLED', 'REJECTED')`)
.groupBy('o.route_line_id')
.getRawMany<{ key: string; total: string }>();
const map = new Map<string, number>();
for (const row of rows) if (row.key) map.set(row.key, Number(row.total));
return map;
return routeLines.map((rl) => ({
routeLineId: rl.id,
originYardId: rl.originYardId,
originYardName: rl.originYard?.label ?? null,
destinationYardId: rl.destinationYardId,
destinationYardName: rl.destinationYard?.label ?? null,
km: rl.km != null ? Number(rl.km) : null,
}));
}
/** Sum of non-cancelled order line quantities, keyed by container type id ('' = bulk). */
@@ -221,14 +189,12 @@ export class GeneralContractService {
return line?.remainingQuantity ?? 0;
}
/** True once every contracted line is fully drawn down. */
/**
* True once the contract's shared pool is fully drawn down. Routes are pure
* lanes with no quantity, so exhaustion is purely a function of the shared
* per-container-type (or bulk) pool, regardless of how many routes exist.
*/
async isExhausted(contractBookingId: string): Promise<boolean> {
// Multi-route contracts are exhausted when every route line is drawn down;
// single-route contracts fall back to the per-container-type pool.
const routeLines = await this.getRouteLines(contractBookingId);
if (routeLines.length > 0) {
return routeLines.every((l) => l.remainingQuantity <= 0);
}
const lines = await this.getQuantityLines(contractBookingId);
return lines.every((l) => l.remainingQuantity <= 0);
}

View File

@@ -276,6 +276,12 @@ export class BookingPricingService {
allowConsolidation,
shippingLineId: booking.shippingLineId,
totalWagons,
// Bulk tonnage scales PER_TON surcharges (e.g. the bulk reefer surcharge).
// Container freight carries 0 here — its surcharges scale by container count.
bulkTons:
booking.freightType === 'BULK'
? Number(booking.cargoTotalWeightVgm ?? 0)
: 0,
containers,
};
}

View File

@@ -63,7 +63,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
);
});
it('moves to CLEARANCE_READY when all required documents are APPROVED', async () => {
it('moves to CLEARANCE_READY when all required documents are APPROVED (non-customs, no output set)', async () => {
const { service, bookingsRepository } = makeService([
{ settingCode: inputSetting.code, fileKey: 'commercial_invoice', status: 'APPROVED' },
{ settingCode: inputSetting.code, fileKey: 'packing_list', status: 'APPROVED' },
@@ -75,3 +75,165 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
);
});
});
/**
* Customs bookings additionally require the GL output documents before
* finalizing — they are cleared by Global Logistics, not the customer alone.
*/
describe('BookingTransitionService — finalizeClearance customs output gate', () => {
const customsBooking = {
id: 'b-2',
status: 'DOCUMENTS_UNDER_REVIEW',
tradeDirection: 'IMPORT',
freightType: 'CONTAINER',
serviceType: { includesCustoms: true }, // input + output sets apply
};
const inputSetting = {
code: 'clearance_import_container_with_customs',
fields: [{ fileKey: 'commercial_invoice', isRequired: true }],
};
const outputSetting = {
code: 'clearance_output_import_container',
fields: [{ fileKey: 'im4', fileLabel: 'IM4 declaration', isRequired: true }],
};
function makeCustomsService(uploadedOutputCodes: string[]) {
const bookingsRepository = {
findDocumentReviews: jest.fn().mockResolvedValue([
{ settingCode: inputSetting.code, fileKey: 'commercial_invoice', status: 'APPROVED' },
]),
update: jest.fn().mockResolvedValue({ id: 'b-2' }),
};
const bookingsService = { findById: jest.fn().mockResolvedValue(customsBooking) };
const fileUploadSettingsService = {
getByCode: jest.fn((code: string) =>
Promise.resolve(code === outputSetting.code ? outputSetting : inputSetting),
),
};
const filesService = {
findByResource: jest
.fn()
.mockResolvedValue(uploadedOutputCodes.map((code) => ({ code }))),
};
const service = new BookingTransitionService(
bookingsRepository as never,
{} as never,
{} as never,
{} as never,
filesService as never,
fileUploadSettingsService as never,
{} as never,
bookingsService as never,
);
return { service, bookingsRepository };
}
it('rejects when required customs output documents are missing', async () => {
const { service } = makeCustomsService([]); // no output uploaded
await expect(service.finalizeClearance('b-2')).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('moves to CLEARANCE_READY when input is approved and output docs are present', async () => {
const { service, bookingsRepository } = makeCustomsService(['im4']);
await service.finalizeClearance('b-2');
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-2',
expect.objectContaining({ status: 'CLEARANCE_READY' }),
);
});
});
/**
* The first clearance submission (AWAITING_DOCUMENTS) must include every
* required input document; subsequent re-uploads during review only need the
* specific files being fixed, so already-uploaded required docs stay in place.
*/
describe('BookingTransitionService — submitClearanceDocuments required-fields gate', () => {
const inputSetting = {
code: 'clearance_import_container_without_customs',
fields: [
{ fileKey: 'commercial_invoice', fileLabel: 'Commercial invoice', isRequired: true },
{ fileKey: 'packing_list', fileLabel: 'Packing list', isRequired: true },
],
};
function makeService(status: string, existingCodes: string[]) {
const bookingsRepository = {
upsertDocumentReviewPending: jest.fn().mockResolvedValue(undefined),
update: jest.fn().mockResolvedValue({ id: 'b-3' }),
};
const booking = {
id: 'b-3',
status,
tradeDirection: 'IMPORT',
freightType: 'CONTAINER',
serviceType: { includesCustoms: false },
};
const bookingsService = { findById: jest.fn().mockResolvedValue(booking) };
const fileUploadSettingsService = {
getByCode: jest.fn().mockResolvedValue(inputSetting),
};
const filesService = {
findByResource: jest
.fn()
.mockResolvedValue(existingCodes.map((code) => ({ code }))),
upsertByCode: jest.fn().mockResolvedValue({ id: 'file-rec' }),
};
const service = new BookingTransitionService(
bookingsRepository as never,
{} as never,
{} as never,
{} as never,
filesService as never,
fileUploadSettingsService as never,
{} as never,
bookingsService as never,
);
return { service, bookingsRepository, filesService };
}
function fakeFile(fieldname: string): Express.Multer.File {
return { fieldname, originalname: `${fieldname}.pdf` } as Express.Multer.File;
}
it('rejects the first submission when a required document is missing', async () => {
const { service } = makeService('AWAITING_DOCUMENTS', []);
await expect(
service.submitClearanceDocuments('b-3', [fakeFile('commercial_invoice')]),
).rejects.toBeInstanceOf(BadRequestException);
});
it('accepts the first submission when every required document is provided', async () => {
const { service, bookingsRepository } = makeService('AWAITING_DOCUMENTS', []);
await service.submitClearanceDocuments('b-3', [
fakeFile('commercial_invoice'),
fakeFile('packing_list'),
]);
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-3',
expect.objectContaining({ status: 'DOCUMENTS_UNDER_REVIEW' }),
);
});
it('allows re-uploading a single queried document during review without re-sending the rest', async () => {
// packing_list was already uploaded in the first round; the customer is now
// only re-uploading the queried commercial_invoice.
const { service, bookingsRepository } = makeService(
'DOCUMENTS_UNDER_REVIEW',
['packing_list'],
);
await service.submitClearanceDocuments('b-3', [
fakeFile('commercial_invoice'),
]);
// Only the re-uploaded doc is touched — no full re-gate, no rework on the rest.
expect(bookingsRepository.upsertDocumentReviewPending).toHaveBeenCalledTimes(1);
expect(bookingsRepository.upsertDocumentReviewPending).toHaveBeenCalledWith(
expect.objectContaining({ fileKey: 'commercial_invoice' }),
);
});
});

View File

@@ -645,6 +645,14 @@ export class BookingTransitionService {
throw new BadRequestException('No documents uploaded');
}
// First submission (nothing in review yet): every required input field must
// be provided. Once review has started (DOCUMENTS_UNDER_REVIEW) the customer
// is only fixing queried/pending docs, so the already-uploaded required docs
// stay in place and we don't re-gate on the full required set.
if (booking.status === 'AWAITING_DOCUMENTS') {
await this.assertRequiredInputsPresent(bookingId, inputCode, files);
}
for (const file of files) {
const record = await this.filesService.upsertByCode({
resourceId: bookingId,
@@ -670,6 +678,41 @@ export class BookingTransitionService {
return this.bookingsService.findById(bookingId);
}
/**
* Guard for the first clearance submission: every required field of the
* booking's customer-input set must be covered, either by a file already on
* the booking or by one in this upload batch. Keeps the customer from starting
* review with required documents missing.
*/
private async assertRequiredInputsPresent(
bookingId: string,
inputCode: string,
files: Express.Multer.File[],
): Promise<void> {
let setting;
try {
setting = await this.fileUploadSettingsService.getByCode(inputCode);
} catch {
return; // setting not seeded — nothing to enforce
}
const required = (setting.fields ?? []).filter((f) => f.isRequired);
if (required.length === 0) return;
const existing = await this.filesService.findByResource(bookingId, 'bookings');
const presentKeys = new Set<string>([
...existing.map((f) => f.code),
...files.map((f) => f.fieldname),
]);
const missing = required.filter((f) => !presentKeys.has(f.fileKey));
if (missing.length > 0) {
const labels = missing.map((f) => f.fileLabel).join(', ');
throw new BadRequestException(
`Please upload all required documents before submitting: ${labels}`,
);
}
}
/** GL reviews a single document: APPROVED or QUERIED (with a note). */
async reviewDocument(
bookingId: string,
@@ -795,6 +838,20 @@ export class BookingTransitionService {
throw new BadRequestException('A valid schedule date is required');
}
// The binding shipment day must have at least one OPEN departure on the
// route — only schedule-backed days are selectable. The batch engine
// assigns the specific train within that (route, day) pool later.
const hasDeparture = await this.bookingsService.hasOpenDepartureOnDay(
booking.originYardId,
booking.destinationYardId,
eatDay(date),
);
if (!hasDeparture) {
throw new BadRequestException(
'No departures available on the selected day for this route',
);
}
await this.bookingsRepository.update(bookingId, {
status: 'OPERATION_REQUEST_PENDING',
scheduledDate: date,

View File

@@ -134,6 +134,12 @@ export class BookingsController {
if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
return this.bookingsService.findAll(filter);
}
// Global Logistics has clearance:view but NOT bookings:view — it is scoped
// to the customs document-clearance queue only and never sees the general
// booking-request list.
if (hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView)) {
return this.bookingsService.findClearanceQueue(filter);
}
const userId = user?.id;
if (!userId) throw new UnauthorizedException('Authentication required');
const companyId =
@@ -236,8 +242,12 @@ export class BookingsController {
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
// Staff see any booking; customers only their own company's.
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
// Staff see any booking; Global Logistics (clearance:view) may inspect any
// booking for the clearance gate; customers only their own company's.
if (
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
!hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView)
) {
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
booking,

View File

@@ -39,6 +39,7 @@ export interface BookingListFilterOptions {
paymentCurrency?: string;
paymentStatus?: string;
excludePaymentStatus?: string;
customsClearingEnabled?: boolean;
createdFrom?: string;
createdTo?: string;
consolidationPaired?: string;
@@ -728,6 +729,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
excludePaymentStatus: options.excludePaymentStatus,
});
}
if (options.customsClearingEnabled !== undefined) {
qb.andWhere('booking.customs_clearing_enabled = :customsClearingEnabled', {
customsClearingEnabled: options.customsClearingEnabled,
});
}
if (options.consolidationPaired === 'true') {
qb.andWhere('booking.consolidation_partner_id IS NOT NULL');
} else if (options.consolidationPaired === 'false') {

View File

@@ -26,6 +26,7 @@ import { DataSource, In } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { Yard } from '../rule-engine/entities/yard.entity';
import { ServiceType } from '../rule-engine/entities/service-type.entity';
import { ContractRouteLine } from '../booking-orders/entities/contract-route-line.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { BookingsRepository } from './bookings.repository';
@@ -119,6 +120,18 @@ export class BookingsService {
}
/** Build evaluation input from booking freight shape. */
/**
* Whether a service type bundles customs clearance. This is the single source
* of truth for a booking's `customsClearingEnabled` — the customer cannot
* diverge from it, and it decides who clears the documents (GL vs Marketing).
*/
private async resolveIncludesCustoms(serviceTypeId: string): Promise<boolean> {
const serviceType = await this.dataSource
.getRepository(ServiceType)
.findOne({ where: { id: serviceTypeId } });
return serviceType?.includesCustoms ?? false;
}
private async buildEvalInput(dto: {
freightType: FreightType;
cargoTypeId?: string | null;
@@ -126,8 +139,10 @@ export class BookingsService {
paymentCurrency: string;
tradeDirection: string;
isHazardous?: boolean;
isReefer?: boolean;
isGovernment?: boolean;
shippingLineId?: string | null;
bulkTons?: number;
containers: CreateBookingContainerDto[];
}): Promise<BookingEvaluationInput> {
const containerLines =
@@ -166,10 +181,14 @@ export class BookingsService {
paymentCurrency: dto.paymentCurrency,
tradeDirection: dto.tradeDirection,
isHazardous: dto.isHazardous ?? false,
// Bulk reefer comes from the customer toggle; container reefer is derived
// from the container type and ORed in by the engine.
isReefer: dto.freightType === 'BULK' ? (dto.isReefer ?? false) : false,
isGovernment: dto.isGovernment ?? false,
allowConsolidation,
shippingLineId: dto.shippingLineId,
totalWagons,
bulkTons: dto.freightType === 'BULK' ? Number(dto.bulkTons ?? 0) : 0,
containers,
};
}
@@ -317,12 +336,14 @@ export class BookingsService {
) {
throw new BadRequestException('Selected schedule is not on the booking route');
}
} else if (!isGeneralContract) {
// Day-level pool: the customer picked a DAY — require that the route has at
// least one OPEN departure on that EAT day. The batch engine assigns the
// train later. General contracts skip this — they have no shipment date at
// creation; each drawdown order validates its own day.
const day = eatDay(new Date(dto.scheduledDate!));
} else if (dto.scheduledDate) {
// A real (binding) scheduledDate was supplied (e.g. staff pinning a day
// directly). Require that the route has at least one OPEN departure on
// that EAT day. The booking wizard does NOT send scheduledDate at creation
// — it captures a non-binding estimatedShipmentDate instead, and the
// binding day is chosen later at the operation-request step. General
// contracts also skip this (each drawdown order validates its own day).
const day = eatDay(new Date(dto.scheduledDate));
const hasDeparture =
await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
dto.originYardId,
@@ -371,6 +392,17 @@ export class BookingsService {
tradeDirection,
fallbackType,
);
// A customer booking under their own account may only do so once the
// resolved operational profile has been approved by the backoffice. Staff-
// and government-initiated bookings (companyId supplied explicitly) bypass
// this gate.
const customerSelfBooking = !dto.companyId && !!userId;
if (customerSelfBooking && companyProfileId) {
await this.companiesService.assertCompanyProfileApprovedForBooking(
companyProfileId,
);
}
}
const needsConsolidation =
@@ -385,8 +417,10 @@ export class BookingsService {
paymentCurrency: dto.paymentCurrency,
tradeDirection,
isHazardous: dto.isHazardous,
isReefer: dto.isReefer,
isGovernment,
shippingLineId: dto.shippingLineId,
bulkTons: dto.cargoTotalWeightVgm,
containers,
});
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
@@ -394,6 +428,11 @@ export class BookingsService {
warnings.push(...ruleResult.warnings);
// Customs clearing is owned by the service type, not the customer: when the
// service includes customs, EDR/GL clears it (no external agent); otherwise
// the customer clears it themselves and may name their broker.
const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId);
const booking = await this.bookingsRepository.create({
reference,
companyId: companyId ?? null,
@@ -411,8 +450,8 @@ export class BookingsService {
lastMileDeliveryAddress: dto.lastMileDeliveryAddress,
lastMileDeliveryLat: dto.lastMileDeliveryLat ?? null,
lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null,
customsClearingEnabled: dto.customsClearingEnabled ?? false,
customsClearingAgent: dto.customsClearingAgent ?? null,
customsClearingEnabled: includesCustoms,
customsClearingAgent: includesCustoms ? null : (dto.customsClearingAgent ?? null),
equipmentReturn: dto.equipmentReturn,
originYardId: dto.originYardId,
destinationYardId: dto.destinationYardId,
@@ -423,11 +462,18 @@ export class BookingsService {
shippingLineId: dto.shippingLineId,
cargoTotalWeightVgm: dto.cargoTotalWeightVgm,
isHazardous: dto.isHazardous ?? false,
// Bulk reefer is the customer's toggle; container reefer is derived from
// the container type at pricing time, so the booking-level flag stays off
// for container freight to avoid double-counting.
isReefer: dto.freightType === 'BULK' ? (dto.isReefer ?? false) : false,
paymentCurrency: dto.paymentCurrency,
pnrCode: dto.pnrCode,
financialTerms: dto.financialTerms,
bookingType: isGeneralContract ? 'GENERAL_CONTRACT' : 'ONE_TIME',
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
estimatedShipmentDate: dto.estimatedShipmentDate
? new Date(dto.estimatedShipmentDate)
: null,
startDate: dto.startDate ? new Date(dto.startDate) : undefined,
endDate: dto.endDate ? new Date(dto.endDate) : undefined,
status: 'DRAFT',
@@ -450,8 +496,11 @@ export class BookingsService {
warnings.push(`Estimated wagons required: ${wagonCount}`);
}
// Multi-route general contracts: persist the contracted routes + quantities.
// Each drawdown order later draws from one of these route lines.
// Multi-route general contracts: persist the contracted routes (lanes). Routes
// carry NO quantity — the contract has a single shared pool (the cargo-step
// total / container quantities). Each drawdown order picks one lane for
// scheduling + road billing and draws from that shared pool. `quantity` on the
// route line is retained for legacy rows but is no longer meaningful (0).
if (isGeneralContract && dto.routes?.length) {
const routeRepo = this.dataSource.getRepository(ContractRouteLine);
await routeRepo.save(
@@ -460,9 +509,8 @@ export class BookingsService {
contractBookingId: booking.id,
originYardId: r.originYardId,
destinationYardId: r.destinationYardId,
containerTypeId:
dto.freightType === 'CONTAINER' ? (r.containerTypeId ?? null) : null,
quantity: r.quantity,
containerTypeId: null,
quantity: 0,
km: r.km ?? null,
}),
),
@@ -480,7 +528,11 @@ export class BookingsService {
// Reuse the booking profile's onboarding documents instead of asking the
// customer to re-upload. Snapshot them onto the booking now (by reference),
// so a later active-profile switch never changes this booking's documents.
if (companyProfileId) {
//
// Skip this when the customer uploaded documents for this booking — those
// per-booking files take precedence, so auto-attaching the profile snapshots
// would create duplicates.
if (companyProfileId && files.length === 0) {
try {
const onboardingFiles =
await this.companiesService.getProfileOnboardingFiles(companyProfileId);
@@ -577,7 +629,9 @@ export class BookingsService {
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
tradeDirection,
isHazardous: dto.isHazardous ?? existing.isHazardous,
isReefer: dto.isReefer ?? existing.isReefer,
shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined,
bulkTons: dto.cargoTotalWeightVgm ?? Number(existing.cargoTotalWeightVgm ?? 0),
containers,
});
@@ -597,6 +651,12 @@ export class BookingsService {
...dto,
freightType,
cargoTypeId: freightType === 'BULK' ? cargoTypeId : null,
// Booking-level reefer is only meaningful for bulk; container reefer is
// derived from the container type at pricing time.
isReefer:
freightType === 'BULK'
? (dto.isReefer ?? existing.isReefer ?? false)
: false,
priorityScore: ruleResult.priorityScore,
tradeDirection,
};
@@ -617,10 +677,22 @@ export class BookingsService {
);
}
if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate);
if (dto.estimatedShipmentDate)
updates.estimatedShipmentDate = new Date(dto.estimatedShipmentDate);
if (dto.startDate) updates.startDate = new Date(dto.startDate);
if (dto.endDate) updates.endDate = new Date(dto.endDate);
delete updates.containers;
// Customs clearing always mirrors the (possibly changed) service type — never
// the client payload — so it can't diverge from the service's customs scope.
const includesCustoms = await this.resolveIncludesCustoms(
dto.serviceTypeId ?? existing.serviceTypeId,
);
updates.customsClearingEnabled = includesCustoms;
updates.customsClearingAgent = includesCustoms
? null
: (dto.customsClearingAgent ?? existing.customsClearingAgent ?? null);
await this.bookingsRepository.update(id, updates);
if (freightType === 'CONTAINER' && dto.containers) {
@@ -692,6 +764,23 @@ export class BookingsService {
}
/** Return a paginated list of bookings matching the filter. */
/**
* Whether a route has at least one OPEN train departure on the given EAT day.
* Used to validate the binding shipment day chosen at the operation-request
* step (only days with a schedule are selectable).
*/
async hasOpenDepartureOnDay(
originYardId: string,
destinationYardId: string,
day: string,
): Promise<boolean> {
return this.trainSchedulingService.existsOpenScheduleOnRouteDay(
originYardId,
destinationYardId,
day,
);
}
async findAll(
filter: FilterBookingDto,
forceCompanyId?: string,
@@ -738,6 +827,48 @@ export class BookingsService {
'AWAITING_PAYMENT',
];
/**
* Booking statuses that belong to the customs document-clearance queue. The
* Global Logistics role is scoped to ONLY these — it never sees the general
* booking-request list.
*/
private static readonly CLEARANCE_STATUSES = [
'AWAITING_DOCUMENTS',
'DOCUMENTS_UNDER_REVIEW',
'CLEARANCE_READY',
];
/**
* List bookings in the customs document-clearance queue. Used by Global
* Logistics (clearance:view) which has no general bookings:view — so the
* status set is force-scoped to clearance statuses and can't be widened to
* arbitrary bookings by a caller-supplied status filter.
*/
async findClearanceQueue(
filter: FilterBookingDto,
): Promise<PaginatedBookings> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 100;
// Honour a caller status filter only if it's within the clearance set;
// otherwise fall back to the full clearance status list.
const requested = filter.status;
const statuses =
requested && BookingsService.CLEARANCE_STATUSES.includes(requested)
? [requested]
: BookingsService.CLEARANCE_STATUSES;
return this.bookingsRepository.findAllPaginated({
page,
pageSize,
statuses,
// Global Logistics only clears customs bookings; non-customs clearance is
// reviewed by Marketing from the booking detail, not this queue.
customsClearingEnabled: true,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
}
/**
* List the current customer's bookings that are ready for payment:
* payable status AND not yet PAID. Company scope is derived from the

View File

@@ -55,6 +55,12 @@ export class CreateBookingContainerDto {
vgmPerUnitTons!: number;
}
/**
* A contracted route (lane) of a general contract — a pure origin→destination
* pair the contract covers. Routes carry NO quantity; the contract draws from a
* single shared pool (the container quantities / bulk total on the booking). An
* order picks one lane (for scheduling + road billing) and draws from that pool.
*/
export class CreateContractRouteDto {
@ApiProperty({ format: 'uuid', description: 'FK to yards.id (origin)' })
@IsUUID()
@@ -64,20 +70,6 @@ export class CreateContractRouteDto {
@IsUUID()
destinationYardId!: string;
@ApiPropertyOptional({
format: 'uuid',
description: 'Container type for CONTAINER contracts; omit for BULK',
})
@IsOptional()
@IsUUID()
containerTypeId?: string;
@ApiProperty({ description: 'Contracted quantity for this route', minimum: 1 })
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
quantity!: number;
@ApiPropertyOptional({
description: 'Road distance (km) for this route; used to bill road orders.',
minimum: 0,
@@ -155,14 +147,24 @@ export class CreateBookingDto {
bookingType?: string;
/**
* The day the customer wants to ship (the pool day key). Required for one-time
* bookings; omitted for general contracts, which pick the date per order.
* The BINDING shipment day (the pool day key), validated against open train
* departures. Set later at the operation-request step — NOT at booking
* creation. Optional here; staff may still pin it directly.
*/
@ApiPropertyOptional({ example: '2026-06-15T00:00:00.000Z' })
@ValidateIf((o) => o.bookingType !== 'GENERAL_CONTRACT')
@IsOptional()
@IsDateString()
scheduledDate?: string;
/**
* Non-binding shipment-date estimate captured in the booking wizard. Purely
* informational — NOT validated against train departures.
*/
@ApiPropertyOptional({ example: '2026-06-15T00:00:00.000Z' })
@IsOptional()
@IsDateString()
estimatedShipmentDate?: string;
@ApiProperty({ enum: CONTRACT_TYPES })
@IsIn([...CONTRACT_TYPES])
contractType!: string;
@@ -296,6 +298,17 @@ export class CreateBookingDto {
@Transform(({ value }) => value === 'true' || value === true)
isHazardous?: boolean;
/**
* Booking-level refrigerated flag. For bulk freight this is the customer's
* reefer choice (containers derive reefer from the container type instead).
* ORed with per-container reefer when the REEFER surcharge is evaluated.
*/
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === 'true' || value === true)
isReefer?: boolean;
@ApiProperty({ enum: PAYMENT_CURRENCIES })
@IsIn([...PAYMENT_CURRENCIES])
paymentCurrency!: string;

View File

@@ -155,10 +155,23 @@ export class Booking extends BaseEntity {
/**
* Nullable: general contracts have no shipment date at creation — the date is
* chosen per drawdown order. One-time bookings always set this (the pool day key).
*
* NOTE: this is the BINDING shipment day, validated against actual open train
* departures. It is set later, when the customer requests the operation — NOT
* at booking creation. See estimatedShipmentDate for the non-binding estimate
* captured in the booking wizard.
*/
@Column({ name: 'scheduled_date', type: 'timestamptz', nullable: true })
scheduledDate?: Date | null;
/**
* Non-binding shipment-date estimate captured in the booking wizard. Purely
* informational — NOT validated against train departures. The binding
* scheduledDate is chosen later at the operation-request step.
*/
@Column({ name: 'estimated_shipment_date', type: 'timestamptz', nullable: true })
estimatedShipmentDate?: Date | null;
/**
* General contracts only: when the ordering window closes, computed from the
* global CONTRACT_PERIOD_MONTHS setting at activation. Null for one-time

View File

@@ -41,6 +41,7 @@ import { ProfileResponseDto } from "./dto/profile-response.dto";
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
import { ListCompaniesQueryDto } from "./dto/list-companies-query.dto";
import { CompanyStatsResponseDto } from "./dto/company-stats-response.dto";
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
import { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-status.dto";
import { FetchETradeDto } from "./dto/fetch-etrade.dto";
import { ETradeResponseDto } from "./dto/etrade-response.dto";
@@ -226,6 +227,17 @@ export class CompaniesController {
await this.companiesService.setOnboardingStep(user.id, dto.step);
}
@Get("onboarding/requirements")
@ApiOperation({
summary:
"What the current user's company still needs to finish onboarding (server-driven documents + outstanding items)",
})
async getOnboardingRequirements(
@CurrentUser() user: CurrentIamUser,
): Promise<OnboardingRequirementsResponseDto> {
return this.companiesService.getOnboardingRequirements(user.id);
}
@Post("onboarding/complete")
@ApiOperation({ summary: "Mark the current user's onboarding as complete" })
async completeOnboarding(

View File

@@ -2,6 +2,7 @@ import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { HttpModule } from "@nestjs/axios";
import { FilesModule } from "../files/files.module";
import { FileUploadSettingsModule } from "../file-upload-settings/file-upload-settings.module";
import { MinioModule } from "../minio/minio.module";
import { CompaniesController } from "./companies.controller";
import { CompaniesService } from "./companies.service";
@@ -20,6 +21,7 @@ import { ETradeService } from "./services/etrade.service";
TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]),
HttpModule,
FilesModule,
FileUploadSettingsModule,
MinioModule,
],
controllers: [CompaniesController],

View File

@@ -3,6 +3,7 @@ import {
NotFoundException,
ConflictException,
BadRequestException,
ForbiddenException,
} from "@nestjs/common";
import { CompaniesRepository } from "./companies.repository";
import { CompanyProfileRepository } from "./company-profile.repository";
@@ -12,7 +13,10 @@ import {
DashboardScope,
} from "./company-dashboard.repository";
import { MinioService } from "../minio/minio.service";
import { FilesService } from "../files/files.service";
import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service";
import { ETradeService } from "./services/etrade.service";
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
import { CreateCompanyDto } from "./dto/create-company.dto";
import { UpdateCompanyDto } from "./dto/update-company.dto";
@@ -53,9 +57,67 @@ export class CompaniesService {
private readonly profilesRepo: ExternalProfileRepository,
private readonly dashboardRepo: CompanyDashboardRepository,
private readonly minioService: MinioService,
private readonly filesService: FilesService,
private readonly fileUploadSettingsService: FileUploadSettingsService,
private readonly etradeService: ETradeService,
) { }
/**
* Required company-information fields that must be filled before onboarding can
* be submitted. The backend owns this list so the portal never has to know
* which fields are mandatory — it just renders what's reported outstanding.
* `get` reads the value from the company (some live in the attributes blob).
*/
private readonly REQUIRED_COMPANY_INFO: {
key: string;
label: string;
get: (company: Company) => unknown;
}[] = [
{
key: "tinNumber",
label: "Company TIN",
get: (c) => (c.tin && !c.tin.startsWith("D") ? c.tin : null),
},
{ key: "companyEmail", label: "Company email", get: (c) => c.email },
{ key: "companyPhone", label: "Company phone", get: (c) => c.phone },
{ key: "companyAddress", label: "Company address", get: (c) => c.address },
{ key: "fanNumber", label: "FAN number", get: (c) => c.fanNumber },
{
key: "contactPersonName",
label: "Contact person name",
get: (c) => c.attributes?.contactPersonName,
},
{
key: "contactPersonPhone",
label: "Contact person phone",
get: (c) => c.attributes?.contactPersonPhone,
},
{
key: "generalManagerName",
label: "General manager name",
get: (c) => c.attributes?.generalManagerName,
},
{
key: "generalManagerEmail",
label: "General manager email",
get: (c) => c.attributes?.generalManagerEmail,
},
{
key: "generalManagerPhone",
label: "General manager phone",
get: (c) => c.attributes?.generalManagerPhone,
},
];
/** The nationality-based document setting code for a company. */
private documentSettingCodeFor(
nationality: CompanyNationality | null | undefined,
): string {
return nationality === CompanyNationality.Foreign
? "company_onboarding_documents_foreign"
: "company_onboarding_documents_ethiopian";
}
async createCompany(dto: CreateCompanyDto): Promise<Company> {
const exists = await this.companiesRepo.existsByTin(dto.tin);
if (exists) {
@@ -77,10 +139,12 @@ export class CompaniesService {
}
}
const existingProfile = await this.profilesRepo.findByEmail(identity.email);
const existingProfile = await this.profilesRepo.findByUserId(
identity.userId,
);
if (existingProfile) {
throw new ConflictException(
`Profile with email ${identity.email} already exists`,
`Profile for user ${identity.userId} already exists`,
);
}
@@ -114,8 +178,6 @@ export class CompaniesService {
companyId: company.id,
firstName: identity.firstName,
lastName: identity.lastName,
email: identity.email,
phone: normalizeE164(identity.phone) ?? identity.phone,
jobTitle: dto.jobTitle ?? null,
isPrimaryContact: dto.isPrimaryContact ?? true,
activeProfileType,
@@ -134,15 +196,13 @@ export class CompaniesService {
input.type,
);
if (existing) continue;
const reference = await this.companyProfilesRepo.generateReference(
input.type,
);
// No reference yet — these profiles await backoffice approval, which
// is when the reference is minted (see setCompanyProfileStatus).
await this.companyProfilesRepo.create({
companyId: company.id,
type: input.type,
reference,
businessLicense: input.businessLicense ?? null,
status: ProfileStatus.Active,
status: ProfileStatus.Pending,
});
}
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(
@@ -191,15 +251,6 @@ export class CompaniesService {
return this.getCompanyInfoByUserId(identity.userId);
}
// A profile may exist for the same email under a different IAM id — block
// duplicates as the final create does.
const byEmail = await this.profilesRepo.findByEmail(identity.email);
if (byEmail) {
throw new ConflictException(
`Profile with email ${identity.email} already exists`,
);
}
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
const chosenTypes = roles.filter((t) => allowedTypes.includes(t));
const activeProfileType =
@@ -224,8 +275,6 @@ export class CompaniesService {
companyId: company.id,
firstName: identity.firstName,
lastName: identity.lastName,
email: identity.email,
phone: normalizeE164(identity.phone) ?? identity.phone,
isPrimaryContact: true,
activeProfileType,
onboardingStep: "company",
@@ -251,12 +300,11 @@ export class CompaniesService {
type,
);
if (existing) continue;
const reference = await this.companyProfilesRepo.generateReference(type);
// No reference yet — minted on backoffice approval (setCompanyProfileStatus).
await this.companyProfilesRepo.create({
companyId,
type,
reference,
status: ProfileStatus.Active,
status: ProfileStatus.Pending,
});
}
}
@@ -527,6 +575,8 @@ export class CompaniesService {
attrUpdates.contactPersonEmail = dto.contactPersonEmail;
if (dto.contactPersonPhone !== undefined)
attrUpdates.contactPersonPhone = normalizeE164(dto.contactPersonPhone);
if (dto.contactVerifiedPhone !== undefined)
attrUpdates.contactVerifiedPhone = normalizeE164(dto.contactVerifiedPhone);
if (dto.generalManagerName !== undefined)
attrUpdates.generalManagerName = dto.generalManagerName;
if (dto.generalManagerEmail !== undefined)
@@ -576,10 +626,10 @@ export class CompaniesService {
async createProfile(dto: CreateExternalProfileDto): Promise<ExternalProfile> {
await this.findCompanyById(dto.companyId);
const existing = await this.profilesRepo.findByEmail(dto.email);
const existing = await this.profilesRepo.findByUserId(dto.userId);
if (existing) {
throw new ConflictException(
`Profile with email ${dto.email} already exists`,
`Profile for user ${dto.userId} already exists`,
);
}
@@ -622,12 +672,33 @@ export class CompaniesService {
profileId: string,
status: ProfileStatus,
): Promise<CompanyProfile> {
const updated = await this.companyProfilesRepo.updateStatus(
profileId,
status,
);
const existing = await this.companyProfilesRepo.findById(profileId);
if (!existing)
throw new NotFoundException(`Company profile ${profileId} not found`);
// A reference number is only minted the first time a profile is approved
// (status → Active). Pending/unapproved profiles carry no reference.
const patch: Partial<CompanyProfile> = { status };
if (status === ProfileStatus.Active && !existing.reference) {
patch.reference = await this.companyProfilesRepo.generateReference(
existing.type,
);
}
const updated = await this.companyProfilesRepo.update(profileId, patch);
if (!updated)
throw new NotFoundException(`Company profile ${profileId} not found`);
// Approving any profile promotes a pending company to active, so the
// customer can start working as soon as their first profile is cleared.
if (status === ProfileStatus.Active) {
const company = await this.companiesRepo.findById(updated.companyId);
if (company && company.status === CompanyStatus.Pending) {
await this.companiesRepo.update(updated.companyId, {
status: CompanyStatus.Active,
});
}
}
return updated;
}
@@ -649,7 +720,7 @@ export class CompaniesService {
const existing = await this.companyProfilesRepo.findByType(companyId, type);
if (existing) {
throw new ConflictException(
`Company already has a ${type} profile (${existing.reference})`,
`Company already has a ${type} profile (${existing.reference ?? "pending approval"})`,
);
}
@@ -813,6 +884,100 @@ export class CompaniesService {
await this.profilesRepo.update(profile.id, { onboardingStep: step });
}
/**
* Server-driven onboarding requirements for the current user's company.
*
* The backend resolves the nationality-based document set, checks which
* company documents and per-profile licenses are already uploaded, and reports
* exactly what is still outstanding. The portal renders this list verbatim and
* relies on `isComplete` to decide when to auto-finish — it never decides for
* itself which documents apply or which fields are mandatory.
*/
async getOnboardingRequirements(
userId: string,
): Promise<OnboardingRequirementsResponseDto> {
const { profile, company } = await this.getCompanyInfoByUserId(userId);
// 1. Required company-information fields.
const missingInfo = this.REQUIRED_COMPANY_INFO.filter(
(f) => !f.get(company),
).map((f) => ({ key: f.key, label: f.label }));
// 2. Nationality-based company documents + which are already uploaded.
const documentSettingCode = this.documentSettingCodeFor(company.nationality);
const [setting, uploadedFiles] = await Promise.all([
this.fileUploadSettingsService
.getByCode(documentSettingCode)
.catch(() => null),
this.filesService.findByResource(company.id, "companies"),
]);
const uploadedCodes = new Set(uploadedFiles.map((f) => f.code));
const documents = (setting?.fields ?? [])
.slice()
.sort((a, b) => a.displayOrder - b.displayOrder)
.map((f) => ({
fileKey: f.fileKey,
fileLabel: f.fileLabel,
helpText: f.helpText ?? null,
isRequired: f.isRequired,
isMultiple: f.isMultiple,
maxFiles: f.maxFiles,
allowedExtensions: f.allowedExtensions,
maxSizeMb: f.maxSizeMb,
displayOrder: f.displayOrder,
uploaded: uploadedCodes.has(f.fileKey),
}));
const missingDocs = documents.filter((d) => d.isRequired && !d.uploaded);
// 3. Per-operational-profile business licenses.
const licenseProfiles = (company.companyProfiles ?? []).map((p) => ({
profileId: p.id,
type: p.type,
reference: p.reference ?? "",
uploaded: (p.businessLicenseFiles?.length ?? 0) > 0,
}));
const missingLicenses = licenseProfiles.filter((p) => !p.uploaded);
const outstanding = [
...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`),
...missingDocs.map((d) => `Upload your ${d.fileLabel}`),
...missingLicenses.map(
(p) =>
`Upload a business license for your ${p.type.replace(/_/g, " ")} profile`,
),
];
// Progress spans every required item the user has to satisfy: company-info
// fields, required documents and one license per operational profile.
const requiredDocCount = documents.filter((d) => d.isRequired).length;
const total =
this.REQUIRED_COMPANY_INFO.length +
requiredDocCount +
licenseProfiles.length;
const completed =
total -
(missingInfo.length + missingDocs.length + missingLicenses.length);
return new OnboardingRequirementsResponseDto({
documentSettingCode,
nationality: company.nationality ?? CompanyNationality.Ethiopian,
companyInfo: { complete: missingInfo.length === 0, missingFields: missingInfo },
documents,
licenseProfiles,
progress: { completed, total },
isComplete: outstanding.length === 0,
onboardingCompleted: profile.onboardingCompleted,
outstanding,
});
}
/**
* Submit onboarding for review. Validation is delegated entirely to
* getOnboardingRequirements (the same source of truth the portal renders), so
* the gate can never drift from what the UI shows. On success the company and
* all its operational profiles move to PENDING — the backoffice approves each
* profile before it can be used (see setCompanyProfileStatus).
*/
async markOnboardingComplete(
userId: string,
): Promise<{ profile: ExternalProfile; company: Company }> {
@@ -821,23 +986,21 @@ export class CompaniesService {
throw new NotFoundException(`Profile for user ${userId} not found`);
const companyId = profile.company?.id ?? profile.companyId;
const company = await this.findCompanyById(companyId);
// Guard against finishing on a still-draft company (TIN never filled in).
if (!company.tin || company.tin.startsWith("D")) {
const requirements = await this.getOnboardingRequirements(userId);
if (!requirements.isComplete) {
throw new BadRequestException(
"Company information is incomplete — please fill in your company details before finishing.",
requirements.outstanding[0] ??
"Your onboarding is incomplete. Please complete all required steps before submitting.",
);
}
// Every operational profile must have at least one business-license file
// (stored directly on the profile).
// Send every operational profile in for approval; the company itself becomes
// active once the backoffice approves at least one profile.
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
for (const cp of profiles) {
if (!cp.businessLicenseFiles || cp.businessLicenseFiles.length === 0) {
throw new BadRequestException(
`Please upload a business license for your ${cp.type.replace(/_/g, " ")} profile before finishing.`,
);
if (cp.status !== ProfileStatus.Pending) {
await this.companyProfilesRepo.updateStatus(cp.id, ProfileStatus.Pending);
}
}
@@ -852,6 +1015,25 @@ export class CompaniesService {
return this.getCompanyInfoByUserId(userId);
}
/**
* Block a customer from booking under a profile that isn't approved yet.
* Called from the booking-create path for self-service bookings; staff- and
* government-initiated bookings bypass this. No-op when the profile can't be
* found (defensive — resolution is best-effort upstream).
*/
async assertCompanyProfileApprovedForBooking(
companyProfileId: string,
): Promise<void> {
const profile = await this.companyProfilesRepo.findById(companyProfileId);
if (!profile) return;
if (profile.status !== ProfileStatus.Active) {
const role = profile.type.replace(/_/g, " ");
throw new ForbiddenException(
`Your ${role} profile is awaiting approval. You'll be able to create bookings once it has been approved.`,
);
}
}
/**
* Authorize and resolve a company_profile that must belong to the current
* user's company — used before accepting/returning its license files.

View File

@@ -30,7 +30,11 @@ export class CompanyProfileRepository extends BaseRepository<CompanyProfile> {
}
async generateReference(type: ProfileType): Promise<string> {
const seqName = SEQUENCE_MAP[type];
// The sequences live in the same schema as the entity (e.g. "freight"), but
// the connection's search_path is "public" — so the sequence MUST be
// schema-qualified or `nextval` fails with "relation does not exist".
const schema = this.repository.metadata.schema ?? "public";
const seqName = `"${schema}".${SEQUENCE_MAP[type]}`;
const result = await this.repository.query(
`SELECT nextval('${seqName}') AS next_id`,
);

View File

@@ -1,5 +1,4 @@
import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsUUID } from 'class-validator';
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
import { IsString, IsNotEmpty, IsOptional, MaxLength, IsBoolean, IsUUID } from 'class-validator';
export class CreateExternalProfileDto {
@IsUUID()
@@ -20,16 +19,6 @@ export class CreateExternalProfileDto {
@MaxLength(100)
lastName!: string;
@IsEmail()
@IsNotEmpty()
email!: string;
@IsOptional()
@IsString()
@MaxLength(20)
@IsValidPhone()
phone?: string;
@IsOptional()
@IsString()
@MaxLength(50)

View File

@@ -0,0 +1,78 @@
/**
* Server-driven description of what a company still needs to finish onboarding.
*
* The portal renders this verbatim instead of deciding for itself which
* documents apply or which fields are mandatory: the backend resolves the
* nationality-based document set, checks which files are already uploaded, and
* reports exactly what is outstanding. `isComplete` is the single source of
* truth the wizard uses to auto-finish.
*/
export interface OnboardingInfoField {
key: string;
label: string;
}
export interface OnboardingDocumentField {
fileKey: string;
fileLabel: string;
helpText: string | null;
isRequired: boolean;
isMultiple: boolean;
maxFiles: number;
allowedExtensions: string[];
maxSizeMb: number;
displayOrder: number;
/** True when a file with this code is already stored for the company. */
uploaded: boolean;
}
export interface OnboardingLicenseProfile {
profileId: string;
type: string;
reference: string;
/** True when at least one business-license file is stored on the profile. */
uploaded: boolean;
}
export class OnboardingRequirementsResponseDto {
/** Resolved document setting code (by nationality) the docs were drawn from. */
documentSettingCode: string;
nationality: string;
/** Required company-information fields and whether each is filled. */
companyInfo: {
complete: boolean;
missingFields: OnboardingInfoField[];
};
/** The document fields the portal should render, with upload state. */
documents: OnboardingDocumentField[];
/** Per-operational-profile business-license requirements. */
licenseProfiles: OnboardingLicenseProfile[];
/** Overall setup progress across fields + documents + licenses. */
progress: { completed: number; total: number };
/** True once every required field, document and license is satisfied. */
isComplete: boolean;
/** Whether the user has already submitted onboarding (awaiting approval). */
onboardingCompleted: boolean;
/** Human-readable list of everything still outstanding (empty when complete). */
outstanding: string[];
constructor(init: Omit<OnboardingRequirementsResponseDto, never>) {
this.documentSettingCode = init.documentSettingCode;
this.nationality = init.nationality;
this.companyInfo = init.companyInfo;
this.documents = init.documents;
this.licenseProfiles = init.licenseProfiles;
this.progress = init.progress;
this.isComplete = init.isComplete;
this.onboardingCompleted = init.onboardingCompleted;
this.outstanding = init.outstanding;
}
}

View File

@@ -34,6 +34,8 @@ export class ProfileResponseDto {
contactPersonPosition: string | null;
contactPersonEmail: string | null;
contactPersonPhone: string | null;
/** Phone that passed SMS OTP verification (drives the verify-step resume). */
contactVerifiedPhone: string | null;
generalManagerName: string | null;
generalManagerEmail: string | null;
generalManagerPhone: string | null;
@@ -81,6 +83,7 @@ export class ProfileResponseDto {
this.contactPersonPosition = attrs.contactPersonPosition ?? null;
this.contactPersonEmail = attrs.contactPersonEmail ?? null;
this.contactPersonPhone = attrs.contactPersonPhone ?? null;
this.contactVerifiedPhone = attrs.contactVerifiedPhone ?? null;
this.generalManagerName = attrs.generalManagerName ?? null;
this.generalManagerEmail = attrs.generalManagerEmail ?? null;
this.generalManagerPhone = attrs.generalManagerPhone ?? null;

View File

@@ -28,7 +28,7 @@ export class ResponseCompanyProfileDto {
this.id = profile.id;
this.companyId = profile.companyId;
this.type = profile.type;
this.reference = profile.reference;
this.reference = profile.reference ?? '';
this.status = profile.status;
this.businessLicense = profile.businessLicense;
this.licenseFiles = profile.businessLicenseFiles ?? [];

View File

@@ -10,8 +10,6 @@ export class ResponseExternalProfileDto {
companyId: string;
firstName: string;
lastName: string;
email: string;
phone?: string | null;
nationalId?: string | null;
jobTitle?: string | null;
isPrimaryContact: boolean;
@@ -34,8 +32,6 @@ export class ResponseExternalProfileDto {
this.companyId = profile.companyId;
this.firstName = profile.firstName;
this.lastName = profile.lastName;
this.email = profile.email;
this.phone = profile.phone;
this.nationalId = profile.nationalId;
this.jobTitle = profile.jobTitle;
this.isPrimaryContact = profile.isPrimaryContact;

View File

@@ -67,6 +67,16 @@ export class UpdateProfileDto {
@IsValidPhone()
contactPersonPhone?: string;
/**
* The contact-person phone that completed SMS OTP verification. Persisted so
* the onboarding "verify" step can resume its "done" state after a refresh
* (compared against the current contactPersonPhone on the client).
*/
@IsOptional()
@IsString()
@IsValidPhone()
contactVerifiedPhone?: string;
@IsOptional()
@IsString()
generalManagerName?: string;

View File

@@ -40,14 +40,19 @@ export class CompanyProfile extends BaseEntity {
@Column({ name: "type", type: "varchar", length: 32, enum: ProfileType })
type!: ProfileType;
/**
* Official profile reference (e.g. "EX-00001"). Minted only when the profile
* is approved (status → Active); pending/unapproved profiles carry NULL.
* The unique index tolerates this because Postgres treats NULLs as distinct.
* API responses surface it as "" when absent — see ResponseCompanyProfileDto.
*/
@Column({
name: "reference",
type: "varchar",
length: 20,
nullable: false,
unique: true,
nullable: true,
})
reference!: string;
reference!: string | null;
@Column({
name: "status",

View File

@@ -23,12 +23,6 @@ export class ExternalProfile extends BaseEntity {
@Column({ name: 'last_name', type: 'varchar', length: 100 })
lastName!: string;
@Column({ name: 'email', type: 'varchar', length: 150, unique: true })
email!: string;
@Column({ name: 'phone', type: 'varchar', length: 20, nullable: true })
phone?: string | null;
@Column({ name: 'national_id', type: 'varchar', length: 50, nullable: true })
nationalId?: string | null;

View File

@@ -23,8 +23,4 @@ export class ExternalProfileRepository extends BaseRepository<ExternalProfile> {
async findByCompanyId(companyId: string): Promise<ExternalProfile[]> {
return this.repository.find({ where: { companyId } as any });
}
async findByEmail(email: string): Promise<ExternalProfile | null> {
return this.repository.findOne({ where: { email } as any });
}
}

View File

@@ -59,7 +59,7 @@ export class FirstMileController {
@TrainSchedulingManage()
@ApiOperation({ summary: 'Accept a paid booking and create a first-mile leg' })
acceptBooking(@Param('reference') reference: string) {
return this.firstMileService.acceptBooking(reference);
return this.firstMileService.acceptBookingByReference(reference);
}
@Post()

View File

@@ -44,8 +44,8 @@ export class FirstMileService {
* paid before any first-mile work proceeds. Throws if the reference is
* unknown or the booking has not reached PAID status.
*/
async acceptBooking(bookingReference: string): Promise<FirstMile | null> {
const booking = await this.bookingsRepository.findById(bookingReference);
async acceptBooking(bookingId: string): Promise<FirstMile | null> {
const booking = await this.bookingsRepository.findById(bookingId);
if (!booking) {
return null;
@@ -61,6 +61,22 @@ export class FirstMileService {
});
}
async acceptBookingByReference(bookingReference: string): Promise<FirstMile | null> {
const booking = await this.bookingsRepository.findByReference(bookingReference);
if (!booking) {
return null;
}
if (booking.paymentStatus !== 'PAID') {
return null;
}
return this.create({
bookingId: booking.id,
advancedPayment: 0,
});
}
async findAll(filter: FirstMileListFilter = {}): Promise<{
data: FirstMile[];
meta: { total: number; page: number; pageSize: number; totalPages: number };

View File

@@ -59,7 +59,7 @@ export class LastMileController {
@TrainSchedulingManage()
@ApiOperation({ summary: 'Accept a paid booking and create a last-mile leg' })
acceptBooking(@Param('reference') reference: string) {
return this.lastMileService.acceptBooking(reference);
return this.lastMileService.acceptBookingByReference(reference);
}
@Post()

View File

@@ -2,13 +2,22 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BookingsModule } from '../bookings/bookings.module';
import { DriversModule } from '../drivers/drivers.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { VehiclesModule } from '../vehicles/vehicles.module';
import { LastMile } from './entities/last-mile.entity';
import { LastMileController } from './last-mile.controller';
import { LastMileRepository } from './last-mile.repository';
import { LastMileService } from './last-mile.service';
@Module({
imports: [TypeOrmModule.forFeature([LastMile]), BookingsModule],
imports: [
TypeOrmModule.forFeature([LastMile]),
BookingsModule,
VehiclesModule,
DriversModule,
NotificationsModule,
],
controllers: [LastMileController],
providers: [LastMileRepository, LastMileService],
exports: [LastMileRepository, LastMileService],

View File

@@ -1,7 +1,10 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource, FindOptionsWhere } from 'typeorm';
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { FindOptionsWhere } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository';
import { DriversService } from '../drivers/drivers.service';
import { NotificationsService } from '../notifications/notifications.service';
import { VehiclesService } from '../vehicles/vehicles.service';
import { CreateLastMileDto } from './dto/create-last-mile.dto';
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
import { LastMile, LastMileStatus } from './entities/last-mile.entity';
@@ -26,10 +29,14 @@ const SORTABLE_FIELDS: (keyof LastMile)[] = [
@Injectable()
export class LastMileService {
private readonly logger = new Logger(LastMileService.name);
constructor(
private readonly lastMileRepository: LastMileRepository,
private readonly bookingsRepository: BookingsRepository,
private readonly dataSource: DataSource,
private readonly vehiclesService: VehiclesService,
private readonly driversService: DriversService,
private readonly notificationsService: NotificationsService,
) {}
async acceptBooking(bookingReference: string): Promise<LastMile> {
@@ -82,7 +89,26 @@ export class LastMileService {
return this.create({
bookingId: booking.id,
advancedPayment: booking.totalAmount,
advancedPayment: 0,
});
}
async acceptBookingByReference(bookingReference: string): Promise<LastMile> {
const booking = await this.bookingsRepository.findByReference(bookingReference);
if (!booking) {
throw new NotFoundException(`Booking ${bookingReference} not found`);
}
if (booking.paymentStatus !== 'PAID') {
throw new BadRequestException(
`Booking ${bookingReference} is not paid (payment status: ${booking.paymentStatus})`,
);
}
return this.create({
bookingId: booking.id,
advancedPayment: 0,
});
}
@@ -152,7 +178,7 @@ export class LastMileService {
}
async update(id: string, dto: UpdateLastMileDto): Promise<LastMile> {
await this.findById(id);
const existing = await this.findById(id);
const updated = await this.lastMileRepository.update(id, {
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
@@ -168,9 +194,50 @@ export class LastMileService {
throw new NotFoundException(`Last-mile record ${id} not found`);
}
// Notify assigned driver on every explicit vehicle assignment or reassignment
if (dto.vehicleId) {
void this.notifyDriverAssignment(dto.vehicleId, existing);
}
return updated;
}
private async notifyDriverAssignment(vehicleId: string, record: LastMile): Promise<void> {
try {
const vehicle = await this.vehiclesService.findById(vehicleId);
if (!vehicle.assignedDriverId) {
this.logger.warn(`Vehicle ${vehicleId} has no assigned driver — skipping SMS`);
return;
}
const driver = await this.driversService.findById(vehicle.assignedDriverId);
if (!driver.phoneNumber) {
this.logger.warn(`Driver ${vehicle.assignedDriverId} has no phone number — skipping SMS`);
return;
}
type BookingWithYards = {
reference?: string;
lastMileDeliveryAddress?: string | null;
destinationYard?: { label?: string } | null;
};
const booking = (record as LastMile & { booking?: BookingWithYards }).booking;
await this.notificationsService.notifyDriverVehicleAssignment({
driverPhone: driver.phoneNumber,
driverName: `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(),
vehiclePlateNumber: vehicle.plateNumber ?? vehicleId,
bookingReference: booking?.reference ?? record.bookingId,
pickupAddress: booking?.destinationYard?.label,
destinationYard: booking?.lastMileDeliveryAddress,
});
this.logger.log(`SMS sent to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`);
} catch (err) {
this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`);
}
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.lastMileRepository.softDelete(id);

View File

@@ -0,0 +1,46 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsArray, IsNotEmpty, IsOptional, IsString, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class SendMessage {
@ApiProperty()
@IsNotEmpty()
@IsString()
to!: string;
@ApiProperty()
@IsNotEmpty()
@IsString()
message!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
from?: string;
}
export class SingleMessageDto {
@ApiProperty({
description: 'Recipient phone number',
example: '+1234567890',
})
@IsString()
@IsNotEmpty()
to!: string;
@ApiProperty({
description: 'Message content',
example: 'Test Single SMS from',
})
@IsString()
@IsNotEmpty()
message!: string;
}
export class BulkMessagesDto {
@ApiProperty({ type: [SendMessage] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => SendMessage)
messages!: SendMessage[];
}

View File

@@ -1,14 +1,29 @@
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { ClientsModule, Transport } from "@nestjs/microservices";
import { NotificationsService } from "./notifications.service";
import { SmsClientService } from "./sms-client.service";
import { EmailNotificationStrategy } from "./strategies/notification.email.strategy";
import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy";
@Module({
imports: [ConfigModule],
imports: [
ConfigModule,
ClientsModule.register([
{
name: "SMS_SERVICE",
transport: Transport.RMQ,
options: {
urls: [process.env.RABBITMQ_URL as string],
queue: process.env.SMS_QUEUE ?? "sms_queue",
queueOptions: { durable: true },
},
},
]),
],
controllers: [],
providers: [EmailNotificationStrategy, SmsNotificationStrategy, NotificationsService],
exports: [NotificationsService],
providers: [EmailNotificationStrategy, SmsNotificationStrategy, NotificationsService, SmsClientService],
exports: [NotificationsService, SmsClientService],
})
export class NotificationsModule {}

View File

@@ -0,0 +1,68 @@
import {
Inject,
Injectable,
Logger,
OnApplicationBootstrap,
} from "@nestjs/common";
import { ClientProxy } from "@nestjs/microservices";
import { BulkMessagesDto, SingleMessageDto } from "./dtos/sms.dto";
@Injectable()
export class SmsClientService implements OnApplicationBootstrap {
private readonly logger = new Logger(SmsClientService.name);
constructor(
@Inject("SMS_SERVICE")
private smsClient: ClientProxy,
) {}
private readonly enabled = process.env.RABBITMQ_ENABLED !== "false";
async onApplicationBootstrap() {
if (!this.enabled) return;
this.smsClient
.connect()
.then(() => {
this.logger.log("connected to SMS service");
})
.catch((err) => {
console.error("Error happened at SMS service", err);
});
}
async sendSms(dto: SingleMessageDto): Promise<{ queued: boolean }> {
if (!this.enabled) {
this.logger.warn(`RABBITMQ disabled — skipped SMS`);
return { queued: false };
}
this.smsClient.emit("send-sms", {
to: dto.to,
text: dto.message,
appKey: "IFHCRS-LICENSE-MANAGEMENT",
});
// Fire-and-forget enqueue: confirms hand-off to RabbitMQ, NOT delivery.
this.logger.log(
`SMS queued to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='send-sms'`,
);
// Recipient + content are PII — debug only.
this.logger.debug(`SMS payload to=${dto.to} text="${dto.message}"`);
return { queued: true };
}
async sendBulkMessages(dto: BulkMessagesDto): Promise<{ queued: boolean }> {
if (!this.enabled) {
this.logger.warn(`RABBITMQ disabled — skipped BULK SMS (${dto.messages?.length ?? 0} messages)`);
return { queued: false };
}
const messages = (dto.messages ?? []).map((m) => ({ to: m.to, text: m.message, from: m.from }));
this.smsClient.emit("ozeking-bulk-sms", {
messages,
appKey: "IFHCRS-LICENSE-MANAGEMENT",
});
this.logger.log(
`BULK SMS queued to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='ozeking-bulk-sms' count=${messages.length}`,
);
this.logger.debug(`BULK SMS payload messages=${JSON.stringify(messages)}`);
return { queued: true };
}
}

View File

@@ -24,13 +24,9 @@ export class OtpController {
@Post("send")
async sendOtp(
@Body("phone")
phone: string,
@Body("otp")
otp: string
phone: string
) {
return this.otpService.sendOtp(
phone,otp
);
return this.otpService.sendOtp(phone);
}
// ---------------------------------------------------------------------------

View File

@@ -12,11 +12,14 @@ import { OtpService } from "./otp.service";
import { OtpRepository } from "./otp.repository";
import { NotificationsModule } from "../notifications/notifications.module";
@Module({
imports: [
TypeOrmModule.forFeature([
OtpVerification,
]),
NotificationsModule,
],
controllers: [OtpController],

View File

@@ -5,14 +5,15 @@ import {
Injectable,
} from "@nestjs/common";
import axios from "axios";
import { OtpRepository } from "./otp.repository";
import { SmsClientService } from "../notifications/sms-client.service";
@Injectable()
export class OtpService {
constructor(
private readonly otpRepository: OtpRepository
private readonly otpRepository: OtpRepository,
private readonly smsClient: SmsClientService
) {}
// ---------------------------------------------------------------------------
@@ -29,11 +30,12 @@ export class OtpService {
// Send OTP
// ---------------------------------------------------------------------------
async sendOtp(phone: string, otp: string) {
async sendOtp(phone: string) {
try {
// generate otp
// const otp =
// this.generateOtp();
// The verification code is generated server-side — never supplied by the
// caller — so the OTP stays a secret known only to the server and the
// recipient of the SMS.
const otp = this.generateOtp();
// find existing phone
const existingPhone =
@@ -55,33 +57,11 @@ export class OtpService {
);
}
// send sms
await axios.post(
"https://notification-dev.license.aafda.gov.et/api/sms-services/ozeking/sms",
{
to: phone,
sourceId: "EDR",
sourceName:
"EDR Freight",
appKey:
"YOUR_APP_KEY",
text: `Your verification code is ${otp}`,
callbackUrl: "",
},
{
headers: {
accept: "*/*",
"Content-Type":
"application/json",
},
}
);
// send sms (queued to RabbitMQ via the shared SMS service)
await this.smsClient.sendSms({
to: phone,
message: `Your verification code is ${otp}`,
});
return {
success: true,

View File

@@ -57,6 +57,12 @@ export interface BookingEvaluationInput {
allowConsolidation?: boolean;
shippingLineId?: string | null;
totalWagons: number;
/**
* Total bulk tonnage on the booking (cargoTotalWeightVgm). Used to scale
* PER_TON surcharges (e.g. the bulk reefer surcharge). 0/undefined for
* container freight, which is scaled by container count instead.
*/
bulkTons?: number;
containers: BookingContainerEvalInput[];
}
@@ -224,16 +230,46 @@ export class RuleEngineService {
});
if (!triggered) continue;
let triggerValue: number | null = null;
let calculatedAmount = Number(rate.rateValue);
// Surcharges scale by their own rateUnit, so the same trigger can bill the
// right way per freight shape — e.g. a PER_TON reefer rate multiplies the
// bulk tonnage, while a PER_CONTAINER reefer rate multiplies the container
// count. triggerValue records the quantity billed (shown on the breakdown).
const rateValue = Number(rate.rateValue);
const containerCount = input.containers.reduce(
(sum, c) => sum + Number(c.quantity || 0),
0,
);
const overweightExcessTons = containerWeightResults.reduce(
(sum, r) => sum + (r.overweightExcessTons ?? 0),
0,
);
// Per-ton surcharges (typically OVERWEIGHT) bill against the excess tons.
if (rate.rateUnit === 'PER_TON' && rate.trigger === 'OVERWEIGHT') {
triggerValue = containerWeightResults.reduce(
(sum, r) => sum + (r.overweightExcessTons ?? 0),
0,
);
calculatedAmount = triggerValue * Number(rate.rateValue);
let triggerValue: number | null = null;
let calculatedAmount: number;
switch (rate.rateUnit) {
case 'PER_TON':
// OVERWEIGHT bills the excess tons; every other PER_TON surcharge
// (e.g. bulk reefer) bills the full bulk tonnage.
triggerValue =
rate.trigger === 'OVERWEIGHT'
? overweightExcessTons
: Number(input.bulkTons ?? 0);
calculatedAmount = triggerValue * rateValue;
break;
case 'PER_CONTAINER':
triggerValue = containerCount;
calculatedAmount = triggerValue * rateValue;
break;
case 'PER_WAGON':
triggerValue = input.totalWagons;
calculatedAmount = triggerValue * rateValue;
break;
case 'FLAT':
default:
// FLAT (and any unknown unit) bills once.
calculatedAmount = rateValue;
break;
}
// Safety guard: never include a surcharge with a non-positive amount (a

View File

@@ -25,6 +25,7 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
route: true,
trainSet: {
locomotive: true,
locomotives: { locomotive: true },
wagons: {
wagonType: true,
physicalWagon: true,

View File

@@ -1,6 +1,15 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsDateString, IsInt, IsNumber, IsOptional, IsUUID, Min } from 'class-validator';
import {
ArrayMinSize,
IsArray,
IsDateString,
IsInt,
IsNumber,
IsOptional,
IsUUID,
Min,
} from 'class-validator';
export class CreateContainerTrainScheduleDto {
@ApiProperty({ format: 'uuid' })
@@ -11,9 +20,15 @@ export class CreateContainerTrainScheduleDto {
@IsDateString()
scheduleDate!: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
locomotiveId!: string;
@ApiProperty({
type: [String],
format: 'uuid',
description: 'Locomotives pulling the train (minimum 2 — front and back)',
})
@IsArray()
@ArrayMinSize(2, { message: 'A train must be pulled by at least two locomotives' })
@IsUUID('all', { each: true })
locomotiveIds!: string[];
@ApiPropertyOptional({ description: 'Maximum total booking weight allowed on this train' })
@IsOptional()

View File

@@ -69,6 +69,25 @@ export function deriveTrainCapacityFromLocomotive(
export const MAX_FALLBACK_WEIGHT = 3500;
export const MAX_FALLBACK_LENGTH = 760;
/**
* Effective pull limits for a train set with multiple locomotives: the weakest
* locomotive caps the train, so take the minimum pull weight and minimum length
* across all assigned locomotives. Returns null when no locomotives are given.
*/
export function minLocomotiveLimits(
locomotives: Array<Pick<LocomotiveLimits, 'maxPullWeightTons' | 'maxTrainLengthMeters'>>,
): LocomotiveLimits | null {
if (!locomotives.length) return null;
return {
maxPullWeightTons: Math.min(
...locomotives.map((l) => Number(l.maxPullWeightTons) || Infinity),
),
maxTrainLengthMeters: Math.min(
...locomotives.map((l) => Number(l.maxTrainLengthMeters) || Infinity),
),
};
}
/** Per-booking train length from wagon count and freight-specific wagon type length. */
export function bookingTrainLengthMeters(
freightType: string | null | undefined,

View File

@@ -0,0 +1,52 @@
import {
BULK_IMPORT_NUMBERS,
CONTAINER_EXPORT_NUMBERS,
CONTAINER_IMPORT_NUMBERS,
pickLowestFreeNumber,
pickTrainNumberPool,
} from './train-number.util';
describe('train-number.util', () => {
describe('pickTrainNumberPool', () => {
it('picks container export (odd) when container wagons dominate and direction is EXPORT', () => {
const pool = pickTrainNumberPool(5, 2, 'EXPORT');
expect(pool.cargo).toBe('CONTAINER');
expect(pool.direction).toBe('EXPORT');
expect(pool.numbers).toEqual(CONTAINER_EXPORT_NUMBERS);
});
it('picks container import (even) when container wagons dominate and direction is IMPORT', () => {
const pool = pickTrainNumberPool(5, 2, 'IMPORT');
expect(pool.numbers).toEqual(CONTAINER_IMPORT_NUMBERS);
});
it('picks bulk when bulk wagons dominate', () => {
const pool = pickTrainNumberPool(1, 9, 'IMPORT');
expect(pool.cargo).toBe('BULK');
expect(pool.numbers).toEqual(BULK_IMPORT_NUMBERS);
});
it('treats a tie as container', () => {
expect(pickTrainNumberPool(3, 3, 'EXPORT').cargo).toBe('CONTAINER');
});
it('defaults DOMESTIC to the export/odd pool', () => {
expect(pickTrainNumberPool(5, 0, 'DOMESTIC').direction).toBe('EXPORT');
expect(pickTrainNumberPool(5, 0, null).direction).toBe('EXPORT');
});
});
describe('pickLowestFreeNumber', () => {
it('returns the lowest unused number', () => {
expect(pickLowestFreeNumber(CONTAINER_EXPORT_NUMBERS, ['8001'])).toBe('8101');
});
it('returns the first number when none are used', () => {
expect(pickLowestFreeNumber(CONTAINER_EXPORT_NUMBERS, [])).toBe('8001');
});
it('returns null when the pool is exhausted', () => {
expect(pickLowestFreeNumber(BULK_IMPORT_NUMBERS, [...BULK_IMPORT_NUMBERS])).toBeNull();
});
});
});

View File

@@ -0,0 +1,68 @@
/**
* Fixed train-number pools assigned to a train on dispatch.
*
* The prefix encodes cargo type (8 = container, 1 = bulk) and the parity encodes
* trade direction (odd = export, even = import). Numbers are finite and recycle:
* a number is "in use" only while its train is DISPATCHED and not yet ARRIVED.
*/
export const CONTAINER_EXPORT_NUMBERS = [
'8001', '8101', '8201', '8301', '8401', '8501', '8601', '8701', '8801', '8901',
] as const;
export const CONTAINER_IMPORT_NUMBERS = [
'8002', '8102', '8202', '8302', '8402', '8502', '8602', '8702', '8802', '8902',
] as const;
export const BULK_EXPORT_NUMBERS = ['1101', '1103', '1105', '1107'] as const;
export const BULK_IMPORT_NUMBERS = ['1002', '1004', '1006', '1008'] as const;
export type CargoKind = 'CONTAINER' | 'BULK';
export type PoolDirection = 'IMPORT' | 'EXPORT';
export interface TrainNumberPool {
cargo: CargoKind;
/** EXPORT = odd numbers, IMPORT = even numbers. */
direction: PoolDirection;
numbers: readonly string[];
}
/**
* Resolve which fixed pool a train draws from.
*
* - Cargo: container vs bulk by dominant wagon count; ties resolve to container.
* - Direction: EXPORT → odd pool, IMPORT → even pool. DOMESTIC (neither end is
* Djibouti) has no dedicated pool, so it defaults to the export/odd pool.
*/
export function pickTrainNumberPool(
containerWagons: number,
bulkWagons: number,
direction: 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null | undefined,
): TrainNumberPool {
const cargo: CargoKind = bulkWagons > containerWagons ? 'BULK' : 'CONTAINER';
const poolDirection: PoolDirection = direction === 'IMPORT' ? 'IMPORT' : 'EXPORT';
const numbers =
cargo === 'CONTAINER'
? poolDirection === 'IMPORT'
? CONTAINER_IMPORT_NUMBERS
: CONTAINER_EXPORT_NUMBERS
: poolDirection === 'IMPORT'
? BULK_IMPORT_NUMBERS
: BULK_EXPORT_NUMBERS;
return { cargo, direction: poolDirection, numbers };
}
/** Lowest pool number not currently in use, or null when the pool is exhausted. */
export function pickLowestFreeNumber(
pool: readonly string[],
usedNumbers: Iterable<string>,
): string | null {
const used = new Set(usedNumbers);
for (const number of pool) {
if (!used.has(number)) return number;
}
return null;
}

View File

@@ -7,6 +7,7 @@ import { LocomotivesModule } from '../locomotives/locomotives.module';
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { Route } from '../routes/entities/route.entity';
import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity';
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
import { TrainSet } from '../train-sets/entities/train-set.entity';
import { TrainSetsModule } from '../train-sets/train-sets.module';
@@ -30,6 +31,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
WagonType,
TrainSet,
TrainSetWagon,
TrainSetLocomotive,
Route,
Wagon,
Container,

View File

@@ -389,8 +389,12 @@ describe('TrainSchedulingService', () => {
isActive: true,
};
const locomotive2 = { ...locomotive, id: 'loc-2', code: 'LOC-002' };
const lockedLocomotiveRepo = {
findOne: jest.fn().mockResolvedValue(locomotive),
findOne: jest
.fn()
.mockResolvedValueOnce(locomotive)
.mockResolvedValueOnce(locomotive2),
update: jest.fn().mockResolvedValue(undefined),
};
const trainScheduleRepo = {
@@ -401,6 +405,10 @@ describe('TrainSchedulingService', () => {
create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue({ id: 'train-set-1' }),
};
const trainSetLocomotiveRepo = {
create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue(undefined),
};
const manager = {
getRepository: jest.fn((entity: { name?: string }) => {
switch (entity?.name) {
@@ -410,13 +418,14 @@ describe('TrainSchedulingService', () => {
return trainScheduleRepo;
case 'TrainSet':
return trainSetRepo;
case 'TrainSetLocomotive':
return trainSetLocomotiveRepo;
default:
throw new Error(`Unexpected transaction repository ${entity?.name}`);
}
}),
};
jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never);
dataSource.getRepository.mockImplementation((entity: unknown) => {
if ((entity as { name?: string })?.name === 'Route') {
return { findOne: jest.fn().mockResolvedValue(route) };
@@ -437,12 +446,16 @@ describe('TrainSchedulingService', () => {
const result = await service.createContainerTrainSchedule({
routeId: 'route-1',
scheduleDate: '2026-06-20T08:00:00.000Z',
locomotiveId: 'loc-1',
locomotiveIds: ['loc-1', 'loc-2'],
});
expect(trainSetRepo.save).toHaveBeenCalled();
expect(trainScheduleRepo.save).toHaveBeenCalled();
expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith('loc-1', { status: 'ASSIGNED' });
expect(trainSetLocomotiveRepo.save).toHaveBeenCalled();
expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith(
{ id: expect.objectContaining({ _type: 'in', _value: ['loc-1', 'loc-2'] }) },
{ status: 'ASSIGNED' },
);
expect(result.id).toBe('schedule-1');
});
@@ -508,7 +521,6 @@ describe('TrainSchedulingService', () => {
})),
};
jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never);
dataSource.getRepository.mockImplementation((entity: { name?: string }) => {
if (entity?.name === 'Route') {
return {
@@ -531,7 +543,7 @@ describe('TrainSchedulingService', () => {
service.createContainerTrainSchedule({
routeId: 'route-1',
scheduleDate: '2026-06-20T08:00:00.000Z',
locomotiveId: 'loc-1',
locomotiveIds: ['loc-1', 'loc-2'],
}),
).rejects.toBeInstanceOf(ConflictException);
});

View File

@@ -22,6 +22,7 @@ import { Container } from '../container-management/entities/container.entity';
import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { LocomotivesRepository } from '../locomotives/locomotives.repository';
import { Route } from '../routes/entities/route.entity';
import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity';
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
import { TrainSet } from '../train-sets/entities/train-set.entity';
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
@@ -79,8 +80,10 @@ import {
pickBulkWagonType,
} from './wagon-type-resolver.util';
import { deriveScheduleDirection } from './derive-schedule-direction.util';
import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util';
import {
deriveTrainCapacityFromLocomotive,
minLocomotiveLimits,
wagonTypeDimensionsFromEntity,
} from './train-capacity.util';
import {
@@ -288,31 +291,42 @@ export class TrainSchedulingService {
async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) {
const route = await this.getActiveRoute(dto.routeId);
const locomotive = await this.selectOrValidateLocomotive(dto.locomotiveId, 0, 0);
const locomotiveIds = [...new Set(dto.locomotiveIds)];
if (locomotiveIds.length < 2) {
throw new BadRequestException('A train must be pulled by at least two locomotives');
}
const createdScheduleId = await this.dataSource.transaction(async (manager) => {
const lockedLocomotive = await manager.getRepository(Locomotive).findOne({
where: { id: locomotive.id },
lock: { mode: 'pessimistic_write' },
});
if (!lockedLocomotive) {
throw new NotFoundException(`Locomotive ${locomotive.id} not found`);
}
if (lockedLocomotive.status !== 'AVAILABLE') {
throw new ConflictException(`Locomotive ${lockedLocomotive.code} is not available`);
// Lock and validate every locomotive: all must be AVAILABLE and at the origin yard.
const lockedLocomotives: Locomotive[] = [];
for (const locomotiveId of locomotiveIds) {
const locked = await manager.getRepository(Locomotive).findOne({
where: { id: locomotiveId },
lock: { mode: 'pessimistic_write' },
});
if (!locked) {
throw new NotFoundException(`Locomotive ${locomotiveId} not found`);
}
if (locked.status !== 'AVAILABLE') {
throw new ConflictException(`Locomotive ${locked.code} is not available`);
}
if (locked.currentYardId !== route.originYardId) {
throw new ConflictException(
`Locomotive ${locked.code} is at yard ${locked.currentYardId} but schedule originates from ${route.originYardId}`,
);
}
lockedLocomotives.push(locked);
}
const direction = deriveScheduleDirection(
route.originYard ?? { country: null },
route.destinationYard ?? { country: null },
);
if (lockedLocomotive.currentYardId !== route.originYardId) {
throw new ConflictException(
`Locomotive ${lockedLocomotive.code} is at yard ${lockedLocomotive.currentYardId} but schedule originates from ${route.originYardId}`,
);
}
const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotive);
const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotives);
// Effective capacity is capped by the weakest locomotive in the set.
const limitLoco = minLocomotiveLimits(lockedLocomotives) ?? undefined;
const schedule = manager.getRepository(TrainSchedule).create({
trainSetId: trainSet.id,
routeId: route.id,
@@ -322,11 +336,14 @@ export class TrainSchedulingService {
status: TrainScheduleStatusEnum.Draft,
direction,
maxWagons: (
await this.resolveTrainLimitConfig(dto, lockedLocomotive)
await this.resolveTrainLimitConfig(dto, limitLoco)
).maxWagonsPerTrain,
});
const saved = await manager.getRepository(TrainSchedule).save(schedule);
await manager.getRepository(Locomotive).update(lockedLocomotive.id, { status: 'ASSIGNED' });
await manager.getRepository(Locomotive).update(
{ id: In(lockedLocomotives.map((l) => l.id)) },
{ status: 'ASSIGNED' },
);
return saved.id;
});
@@ -375,8 +392,9 @@ export class TrainSchedulingService {
maxWagonsPerTrain: dto.maxWagonsPerTrain,
};
const locomotive = schedule.trainSet.locomotive;
const limits = await this.resolveTrainLimitConfig(previewDto, locomotive ?? undefined);
const setLocomotives = this.locomotivesOfTrainSet(schedule.trainSet);
const limitLoco = minLocomotiveLimits(setLocomotives) ?? undefined;
const limits = await this.resolveTrainLimitConfig(previewDto, limitLoco);
const validation = await this.validateBookingsForScheduling(
previewDto,
freightType ?? null,
@@ -408,17 +426,17 @@ export class TrainSchedulingService {
const totalWeightTons = validation.summary.totalWeightTons;
const totalLengthMeters = validation.summary.totalLengthMeters;
if (!locomotive) {
throw new BadRequestException('Schedule train set has no locomotive');
if (!limitLoco) {
throw new BadRequestException('Schedule train set has no locomotives');
}
if (Number(locomotive.maxPullWeightTons) < totalWeightTons) {
if (limitLoco.maxPullWeightTons < totalWeightTons) {
throw new BadRequestException(
`Locomotive ${locomotive.code} cannot pull ${totalWeightTons}T`,
`Train set locomotives cannot pull ${totalWeightTons}T`,
);
}
if (Number(locomotive.maxTrainLengthMeters) < totalLengthMeters) {
if (limitLoco.maxTrainLengthMeters < totalLengthMeters) {
throw new BadRequestException(
`Locomotive ${locomotive.code} cannot support ${totalLengthMeters}m`,
`Train set locomotives cannot support ${totalLengthMeters}m`,
);
}
@@ -681,10 +699,12 @@ export class TrainSchedulingService {
const now = new Date();
await this.dataSource.transaction(async (manager) => {
const trainNumber = await this.assignTrainNumber(manager, schedule);
await this.trainSchedulesRepository.updateStatus(
scheduleId,
TrainScheduleStatusEnum.Dispatched,
{ actualDepartureAt: now },
{ actualDepartureAt: now, trainNumber },
manager,
);
if (schedule.trainSetId) {
@@ -718,6 +738,60 @@ export class TrainSchedulingService {
return this.getTrainScheduleById(scheduleId);
}
/**
* Assign a fixed train number on dispatch. The number is drawn from the pool
* for the train's dominant cargo type (container vs bulk) and trade direction
* (export = odd, import = even). Numbers recycle once a train ARRIVES, so the
* "used" set is every still-DISPATCHED schedule's number. Locked FOR UPDATE so
* concurrent dispatches can't grab the same number. Throws when the pool is
* exhausted. Idempotent: returns the existing number if already assigned.
*/
private async assignTrainNumber(
manager: EntityManager,
schedule: TrainSchedule,
): Promise<string> {
if (schedule.trainNumber) return schedule.trainNumber;
// Count container vs bulk wagons from the planned allocations.
let containerWagons = 0;
let bulkWagons = 0;
for (const wagon of schedule.trainSet?.wagons ?? []) {
const isBulk = (wagon.allocations ?? []).some((a) => a.loadType === 'BULK');
if (isBulk) bulkWagons += 1;
else containerWagons += 1;
}
const direction =
(schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ??
(schedule.originStation && schedule.destinationStation
? deriveScheduleDirection(schedule.originStation, schedule.destinationStation)
: null);
const pool = pickTrainNumberPool(containerWagons, bulkWagons, direction);
// Lock the set of currently-active numbered schedules so two concurrent
// dispatches serialize and can't both claim the same lowest-free number.
const activeNumbered = await manager
.getRepository(TrainSchedule)
.createQueryBuilder('schedule')
.setLock('pessimistic_write')
.where('schedule.status = :status', { status: TrainScheduleStatusEnum.Dispatched })
.andWhere('schedule.train_number IS NOT NULL')
.getMany();
const usedNumbers = activeNumbered
.map((s) => s.trainNumber)
.filter((n): n is string => Boolean(n));
const number = pickLowestFreeNumber(pool.numbers, usedNumbers);
if (!number) {
throw new ConflictException(
`No free ${pool.cargo.toLowerCase()} ${pool.direction.toLowerCase()} train number available; a train must arrive to free one`,
);
}
return number;
}
/** Open or close a schedule's booking window (staff override). */
async setBookingWindow(scheduleId: string, status: 'OPEN' | 'CLOSED'): Promise<void> {
await this.dataSource
@@ -995,7 +1069,7 @@ export class TrainSchedulingService {
async getContainerTrainSchedules() {
const schedules = await this.trainSchedulesRepository.findAll({
relations: {
trainSet: { locomotive: true },
trainSet: { locomotive: true, locomotives: { locomotive: true } },
route: true,
originStation: true,
destinationStation: true,
@@ -1026,10 +1100,11 @@ export class TrainSchedulingService {
if (schedule.trainSetId) {
await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'CANCELLED' });
}
if (schedule.trainSet?.locomotiveId) {
await manager.getRepository(Locomotive).update(schedule.trainSet.locomotiveId, {
status: 'AVAILABLE',
});
const cancelledLocoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
if (cancelledLocoIds.length) {
await manager
.getRepository(Locomotive)
.update({ id: In(cancelledLocoIds) }, { status: 'AVAILABLE' });
}
for (const wagon of schedule.trainSet?.wagons ?? []) {
if (wagon.physicalWagonId) {
@@ -1264,24 +1339,29 @@ export class TrainSchedulingService {
}
}
let assignedLocomotive: Locomotive | null = null;
let assignedLocomotives: Locomotive[] = [];
if (targetScheduleId) {
const targetSchedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(targetScheduleId);
assignedLocomotive = targetSchedule?.trainSet?.locomotive ?? null;
assignedLocomotives = this.locomotivesOfTrainSet(targetSchedule?.trainSet);
}
if (assignedLocomotive) {
if (assignedLocomotive.currentYardId !== originYardId) {
if (assignedLocomotives.length) {
// Every locomotive of the set must sit at the origin yard, and the weakest
// one must still be able to pull the train (min limits across the set).
const offYard = assignedLocomotives.find((l) => l.currentYardId !== originYardId);
const setLimits = minLocomotiveLimits(assignedLocomotives);
if (offYard) {
violations.push(
`Locomotive ${assignedLocomotive.code} is not at the schedule origin yard`,
`Locomotive ${offYard.code} is not at the schedule origin yard`,
);
} else if (
Number(assignedLocomotive.maxPullWeightTons) < totalWeightTons ||
Number(assignedLocomotive.maxTrainLengthMeters) < totalLengthMeters
setLimits &&
(setLimits.maxPullWeightTons < totalWeightTons ||
setLimits.maxTrainLengthMeters < totalLengthMeters)
) {
violations.push(
'Assigned locomotive cannot support the total train weight and length',
'Assigned locomotives cannot support the total train weight and length',
);
}
} else {
@@ -1831,6 +1911,22 @@ export class TrainSchedulingService {
}
}
/**
* All locomotives attached to a loaded train set. Prefers the `locomotives`
* link rows; falls back to the legacy single `locomotive` for train sets
* created before multi-loco support.
*/
private locomotivesOfTrainSet(
trainSet: TrainSet | null | undefined,
): Locomotive[] {
if (!trainSet) return [];
const linked = (trainSet.locomotives ?? [])
.map((link) => link.locomotive)
.filter((loco): loco is Locomotive => Boolean(loco));
if (linked.length) return linked;
return trainSet.locomotive ? [trainSet.locomotive] : [];
}
async selectOrValidateLocomotive(
locomotiveId: string,
totalWeightTons: number,
@@ -1854,15 +1950,28 @@ export class TrainSchedulingService {
return locomotive;
}
private async buildEmptyTrainSet(manager: EntityManager, locomotive: Locomotive) {
private async buildEmptyTrainSet(manager: EntityManager, locomotives: Locomotive[]) {
const [primary] = locomotives;
const trainSet = manager.getRepository(TrainSet).create({
locomotiveId: locomotive.id,
// `locomotiveId` retained as the primary locomotive for single-loco read paths.
locomotiveId: primary.id,
totalWeightTons: 0,
totalLengthMeters: 0,
wagonCount: 0,
status: 'DRAFT',
});
return manager.getRepository(TrainSet).save(trainSet);
const saved = await manager.getRepository(TrainSet).save(trainSet);
const links = locomotives.map((loco, index) =>
manager.getRepository(TrainSetLocomotive).create({
trainSetId: saved.id,
locomotiveId: loco.id,
sequenceNo: index,
}),
);
await manager.getRepository(TrainSetLocomotive).save(links);
return saved;
}
private async getActiveRoute(routeId: string) {
@@ -1928,6 +2037,12 @@ export class TrainSchedulingService {
currentYardId: schedule.trainSet.locomotive.currentYardId ?? null,
}
: null,
locomotives: this.locomotivesOfTrainSet(schedule.trainSet).map((loco) => ({
id: loco.id,
code: loco.code,
name: loco.name ?? null,
currentYardId: loco.currentYardId ?? null,
})),
wagonCount: schedule.trainSet?.wagonCount ?? 0,
totalWeightTons: roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0)),
totalLengthMeters: roundTons(Number(schedule.trainSet?.totalLengthMeters ?? 0)),
@@ -1965,7 +2080,7 @@ export class TrainSchedulingService {
bookingWindowStatus: 'OPEN',
},
relations: {
trainSet: { locomotive: true },
trainSet: { locomotive: true, locomotives: { locomotive: true } },
route: { milestones: true },
originStation: true,
destinationStation: true,
@@ -2112,6 +2227,15 @@ export class TrainSchedulingService {
),
}
: null,
locomotives: this.locomotivesOfTrainSet(schedule.trainSet).map((loco) => ({
id: loco.id,
code: loco.code,
name: loco.name ?? null,
status: loco.status,
currentYardId: loco.currentYardId ?? null,
maxPullWeightTons: roundTons(Number(loco.maxPullWeightTons)),
maxTrainLengthMeters: roundTons(Number(loco.maxTrainLengthMeters)),
})),
wagons: [...(schedule.trainSet.wagons ?? [])]
.sort((a, b) => a.sequenceNo - b.sequenceNo)
.map((wagon) => ({

View File

@@ -0,0 +1,31 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Locomotive } from '../../locomotives/entities/locomotive.entity';
import { TrainSet } from './train-set.entity';
/**
* Link row joining a train set to one of its locomotives. A train set must be
* pulled by at least two locomotives (front + back); `sequenceNo` is a plain
* order index — no front/rear semantics are modelled yet.
*/
@Entity({ schema: 'freight', name: 'train_set_locomotives' })
@Index(['trainSetId', 'locomotiveId'], { unique: true })
export class TrainSetLocomotive extends BaseEntity {
@Column({ name: 'train_set_id', type: 'uuid' })
trainSetId!: string;
@ManyToOne(() => TrainSet, (trainSet) => trainSet.locomotives, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'train_set_id' })
trainSet?: TrainSet;
@Column({ name: 'locomotive_id', type: 'uuid' })
locomotiveId!: string;
@ManyToOne(() => Locomotive)
@JoinColumn({ name: 'locomotive_id' })
locomotive?: Locomotive;
@Column({ name: 'sequence_no', type: 'int', default: 0 })
sequenceNo!: number;
}

View File

@@ -3,6 +3,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } fro
import { Locomotive } from '../../locomotives/entities/locomotive.entity';
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
import { TrainSetLocomotive } from './train-set-locomotive.entity';
import { TrainSetWagon } from './train-set-wagon.entity';
export const TRAIN_SET_STATUSES = [
@@ -19,6 +20,7 @@ export type TrainSetStatus = (typeof TRAIN_SET_STATUSES)[number];
@Index(['locomotiveId'])
@Index(['status'])
export class TrainSet extends BaseEntity {
/** Primary locomotive (first of the set). Kept for back-compat with single-loco read paths. */
@Column({ name: 'locomotive_id', type: 'uuid' })
locomotiveId!: string;
@@ -26,6 +28,10 @@ export class TrainSet extends BaseEntity {
@JoinColumn({ name: 'locomotive_id' })
locomotive?: Locomotive;
/** All locomotives pulling this train set (minimum 2). */
@OneToMany(() => TrainSetLocomotive, (link) => link.trainSet)
locomotives?: TrainSetLocomotive[];
@Column({ name: 'total_weight_tons', type: 'numeric', precision: 10, scale: 3 })
totalWeightTons!: number;

View File

@@ -2,12 +2,13 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TrainSet } from './entities/train-set.entity';
import { TrainSetLocomotive } from './entities/train-set-locomotive.entity';
import { TrainSetWagon } from './entities/train-set-wagon.entity';
import { TrainSetWagonsRepository } from './train-set-wagons.repository';
import { TrainSetsRepository } from './train-sets.repository';
@Module({
imports: [TypeOrmModule.forFeature([TrainSet, TrainSetWagon])],
imports: [TypeOrmModule.forFeature([TrainSet, TrainSetWagon, TrainSetLocomotive])],
providers: [TrainSetsRepository, TrainSetWagonsRepository],
exports: [TrainSetsRepository, TrainSetWagonsRepository],
})