mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 07:51:02 +00:00
fix conflict
This commit is contained in:
@@ -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}` : ''),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ?? [];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -25,6 +25,7 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
|
||||
route: true,
|
||||
trainSet: {
|
||||
locomotive: true,
|
||||
locomotives: { locomotive: true },
|
||||
wagons: {
|
||||
wagonType: true,
|
||||
physicalWagon: true,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user