Merge branch 'freight_feature/profile' of github.com:Tria-plc/edr-platform into freight_feature/profile

This commit is contained in:
marshal
2026-06-24 04:33:15 +03:00
38 changed files with 1769 additions and 363 deletions

View File

@@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { BookingsModule } from '../bookings/bookings.module';
import { CompaniesModule } from '../companies/companies.module';
import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module';
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
import { BookingOrdersController } from './booking-orders.controller';
import { BookingOrdersRepository } from './booking-orders.repository';
@@ -18,6 +19,7 @@ import { GeneralContractService } from './general-contract.service';
BookingsModule,
CompaniesModule,
DropdownSettingsModule,
RuleEngineModule,
forwardRef(() => TrainSchedulingModule),
],
controllers: [BookingOrdersController],

View File

@@ -0,0 +1,127 @@
import { BookingOrdersService } from './booking-orders.service';
/**
* Phase-0 spine: a drawdown order spawns a PRICED, UNPAID child booking that
* waits for Marketing review (or the customs clearance gate first) — it does
* NOT auto-enter the train batch pool, and the contract is not charged.
*/
describe('BookingOrdersService — child spawn on order create', () => {
function makeService(opts: { includesCustoms: boolean; roadKm?: number | null }) {
const contract = {
id: 'c-1',
bookingType: 'GENERAL_CONTRACT',
status: 'CONTRACT_ACTIVE',
expiresAt: new Date('2030-01-01T00:00:00.000Z'),
freightType: 'BULK',
originYardId: 'o-1',
destinationYardId: 'd-1',
companyId: null,
paymentCurrency: 'ETB',
serviceType: { includesCustoms: opts.includesCustoms, code: 'RAIL_BULK' },
bookingContainers: [],
};
// Capture what status the child is created with.
const created: Record<string, unknown>[] = [];
const managerUpdates: Record<string, unknown>[] = [];
const fakeManager = {
create: (_entity: unknown, data: Record<string, unknown>) => {
created.push(data);
return { id: 'child-1', ...data };
},
save: async (row: Record<string, unknown>) => ({ id: 'child-1', ...row }),
getRepository: () => ({
findOne: async () => ({ id: 'child-1', paymentCurrency: 'ETB', bookingContainers: [] }),
update: async (_id: string, data: Record<string, unknown>) => {
managerUpdates.push(data);
},
}),
};
const dataSource = {
transaction: async (cb: (m: unknown) => Promise<unknown>) => cb(fakeManager),
getRepository: () => ({ update: jest.fn() }),
};
const ordersRepository = {
countByYear: jest.fn().mockResolvedValue(0),
findById: jest.fn().mockResolvedValue({ id: 'order-1', lines: [] }),
};
const bookingsRepository = {
findById: jest.fn().mockResolvedValue(contract),
countByYear: jest.fn().mockResolvedValue(0),
};
const generalContractService = {
isGeneralContract: () => true,
getRouteLines: jest.fn().mockResolvedValue([]),
getQuantityLines: jest
.fn()
.mockResolvedValue([
{ containerTypeId: null, remainingQuantity: 100, containerTypeName: null },
]),
isExhausted: jest.fn().mockResolvedValue(false),
};
const pricingService = {
computePriceForBooking: jest.fn().mockResolvedValue({
totalAmount: 500,
priorityScore: 10,
lineItems: [],
currency: 'ETB',
}),
};
const ratesService = { findLiveRates: jest.fn().mockResolvedValue([]) };
const trainSchedulingService = {
existsOpenScheduleOnRouteDay: jest.fn().mockResolvedValue(true),
};
const companiesService = {};
const service = new BookingOrdersService(
dataSource as never,
ordersRepository as never,
bookingsRepository as never,
companiesService as never,
generalContractService as never,
pricingService as never,
ratesService as never,
trainSchedulingService as never,
);
return { service, created, managerUpdates, pricingService };
}
const dto = {
contractBookingId: 'c-1',
scheduledDate: '2026-07-01T00:00:00.000Z',
lines: [{ quantity: 10, hazardousQuantity: 4, reeferQuantity: 0 }],
};
it('spawns the child at OPERATION_REQUEST_PENDING (no customs), priced + unpaid', async () => {
const { service, created, managerUpdates, pricingService } = makeService({
includesCustoms: false,
});
await service.create(dto as never);
const child = created.find((c) => c.bookingType === 'ONE_TIME')!;
expect(child.status).toBe('OPERATION_REQUEST_PENDING');
expect(child.paymentStatus).toBe('PENDING');
expect(child.isHazardous).toBe(true); // line has hazardousQuantity > 0
expect(pricingService.computePriceForBooking).toHaveBeenCalled();
// The computed price is persisted onto the child.
expect(managerUpdates.some((u) => u.totalAmount === 500)).toBe(true);
});
it('spawns the child at AWAITING_DOCUMENTS when the service includes customs', async () => {
const { service, created } = makeService({ includesCustoms: true });
await service.create(dto as never);
const child = created.find((c) => c.bookingType === 'ONE_TIME')!;
expect(child.status).toBe('AWAITING_DOCUMENTS');
});
it('rejects when hazardous quantity exceeds the line quantity', async () => {
const { service } = makeService({ includesCustoms: false });
await expect(
service.create({
...dto,
lines: [{ quantity: 5, hazardousQuantity: 9, reeferQuantity: 0 }],
} as never),
).rejects.toThrow(/exceed the line quantity/);
});
});

View File

@@ -8,11 +8,13 @@ import {
} from '@nestjs/common';
import { DataSource } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingPricingService } from '../bookings/booking-pricing.service';
import { clearanceCodesForBooking } from '../bookings/clearance.util';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
import { CompaniesService } from '../companies/companies.service';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { RatesService } from '../rule-engine/services/rates.service';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { eatDay } from '../train-scheduling/batch-window.util';
import { BookingOrdersRepository } from './booking-orders.repository';
@@ -20,6 +22,7 @@ import { CreateBookingOrderDto } from './dto/create-booking-order.dto';
import { BookingOrder } from './entities/booking-order.entity';
import { BookingOrderLine } from './entities/booking-order-line.entity';
import { GeneralContractService } from './general-contract.service';
import { isRoadService, roadKmPrice } from './road.util';
@Injectable()
export class BookingOrdersService {
@@ -31,8 +34,8 @@ export class BookingOrdersService {
private readonly bookingsRepository: BookingsRepository,
private readonly companiesService: CompaniesService,
private readonly generalContractService: GeneralContractService,
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatchService: BookingBatchService,
private readonly pricingService: BookingPricingService,
private readonly ratesService: RatesService,
@Inject(forwardRef(() => TrainSchedulingService))
private readonly trainSchedulingService: TrainSchedulingService,
) {}
@@ -87,6 +90,7 @@ export class BookingOrdersService {
let originYardId = contract.originYardId;
let destinationYardId = contract.destinationYardId;
let routeLineId: string | null = null;
let routeKm: number | null = null;
if (routeLines.length > 0) {
if (!dto.routeLineId) {
@@ -103,6 +107,7 @@ export class BookingOrdersService {
originYardId = chosen.originYardId;
destinationYardId = chosen.destinationYardId;
routeLineId = chosen.routeLineId;
routeKm = chosen.km ?? null;
}
// Validate the route has a departure on the chosen day.
@@ -122,6 +127,21 @@ 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.
for (const line of dto.lines) {
const haz = line.hazardousQuantity ?? 0;
const reefer = line.reeferQuantity ?? 0;
if (haz < 0 || reefer < 0) {
throw new BadRequestException('Hazardous/reefer quantities cannot be negative');
}
if (haz > line.quantity || reefer > line.quantity) {
throw new BadRequestException(
'Hazardous/reefer quantity cannot exceed the line quantity',
);
}
}
if (routeLineId) {
// Multi-route: validate against the chosen route line's remaining pool.
for (const line of dto.lines) {
@@ -167,7 +187,7 @@ export class BookingOrdersService {
const childBooking = await this.spawnChildBooking(
contract,
dto,
{ originYardId, destinationYardId },
{ originYardId, destinationYardId, km: routeKm },
manager,
);
@@ -179,7 +199,9 @@ export class BookingOrdersService {
routeLineId,
companyId: contract.companyId ?? null,
scheduledDate: new Date(dto.scheduledDate),
status: 'PAID',
// The order is a ledger row; the child booking drives the workflow
// (review → pay → allocate), so the order tracks PENDING until done.
status: 'PENDING',
schedulingStatus: 'NOT_SCHEDULED',
});
const savedOrder = await manager.save(orderRow);
@@ -189,6 +211,8 @@ export class BookingOrdersService {
orderId: savedOrder.id,
containerTypeId: isContainer ? (l.containerTypeId ?? null) : null,
quantity: l.quantity,
hazardousQuantity: l.hazardousQuantity ?? 0,
reeferQuantity: l.reeferQuantity ?? 0,
}),
);
await manager.save(lines);
@@ -196,20 +220,12 @@ export class BookingOrdersService {
return savedOrder;
});
// Feed the child booking into the day-pool batch so it allocates to a train.
try {
await this.bookingBatchService.processRouteDay({
originYardId,
destinationYardId,
day,
});
} catch (err) {
this.logger.error(
`Batch fill after order ${order.reference} failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
// The child does NOT enter the train batch pool here. It is priced and
// unpaid, awaiting Marketing review (OPERATION_REQUEST_PENDING) or customs
// clearance first; the batch enqueue happens only on accept.
// Close the contract once its pool is exhausted.
// Close the contract once its pool is exhausted (pending orders count, so
// the pool reserves quantity as soon as an order is placed).
if (await this.generalContractService.isExhausted(contract.id)) {
await this.dataSource
.getRepository(Booking)
@@ -224,16 +240,18 @@ export class BookingOrdersService {
/**
* Create the ONE_TIME child booking for an order, inheriting the contract's
* shipment context and entering the queue already PAID + FULLY_EXECUTED.
* shipment context. Unlike the contract (which is no longer paid up front),
* the child is PRICED and UNPAID and waits for Marketing review — going
* through the customs clearance gate first when the service includes customs,
* mirroring a one-time booking. It only enters the train pool on accept.
*/
private async spawnChildBooking(
contract: Booking,
dto: CreateBookingOrderDto,
route: { originYardId: string; destinationYardId: string },
route: { originYardId: string; destinationYardId: string; km: number | null },
manager: import('typeorm').EntityManager,
): Promise<Booking> {
const reference = await this.generateChildBookingReference();
const now = new Date();
const isContainer = contract.freightType === 'CONTAINER';
// Sum line quantities × the contract's per-unit weight for the child total.
@@ -251,6 +269,18 @@ export class BookingOrdersService {
totalWeight = dto.lines.reduce((sum, l) => sum + l.quantity, 0);
}
// Per-order hazardous/reefer: set the child flags from the order's line
// counts so the HAZARD_SURCHARGE / REEFER_SURCHARGE rates apply.
const hasHazardous = dto.lines.some((l) => (l.hazardousQuantity ?? 0) > 0);
const hasReefer = dto.lines.some((l) => (l.reeferQuantity ?? 0) > 0);
// Customs orders flow through the one-time clearance gate first; others go
// straight to operations review with the chosen shipment day.
const { includesCustoms } = clearanceCodesForBooking(contract);
const spawnStatus = includesCustoms
? 'AWAITING_DOCUMENTS'
: 'OPERATION_REQUEST_PENDING';
const child = manager.create(Booking, {
reference,
companyId: contract.companyId ?? null,
@@ -271,16 +301,14 @@ export class BookingOrdersService {
cargoFreeText: contract.cargoFreeText ?? null,
shippingLineId: contract.shippingLineId ?? null,
cargoTotalWeightVgm: totalWeight,
isHazardous: contract.isHazardous,
isHazardous: hasHazardous,
isReefer: hasReefer,
paymentCurrency: contract.paymentCurrency,
bookingType: 'ONE_TIME',
scheduledDate: new Date(dto.scheduledDate),
// Already covered by the contract's one-time payment: enter the pool ready
// and paid so the batch engine reserves → allocates it immediately.
status: 'FULLY_EXECUTED',
paymentStatus: 'PAID',
fullyExecutedAt: now,
customerSignedAt: now,
// Priced + unpaid: the customer pays this order on its own.
status: spawnStatus,
paymentStatus: 'PENDING',
priorityScore: contract.priorityScore,
totalAmount: 0,
schedulingStatus: 'NOT_SCHEDULED',
@@ -310,9 +338,79 @@ export class BookingOrdersService {
}
}
// Price the order: base freight for the drawn quantity + haz/reefer
// surcharges, plus a road KM charge when the service ships by road.
const roadKm = isRoadService(contract.serviceType) ? route.km : null;
await this.priceChildBooking(savedChild.id, roadKm, manager);
return savedChild;
}
/**
* Compute and persist the child order's price (base + surcharges) inside the
* order transaction. The contract is no longer paid up front, so each order
* carries its own total that the customer pays.
*/
private async priceChildBooking(
childId: string,
roadKm: number | null,
manager: import('typeorm').EntityManager,
): Promise<void> {
const child = await manager.getRepository(Booking).findOne({
where: { id: childId },
relations: { bookingContainers: true },
});
if (!child) return;
try {
const computed = await this.pricingService.computePriceForBooking(child);
const lineItems = [...computed.lineItems];
let total = computed.totalAmount;
// Road KM charge: distance × the live PER_KM rate, added as its own line.
if (roadKm && roadKm > 0) {
const perKmRate = await this.findPerKmRate(child.paymentCurrency);
const kmAmount = roadKmPrice(roadKm, perKmRate);
if (kmAmount > 0) {
lineItems.push({
code: 'ROAD_KM',
description: `Road transport (${roadKm} km)`,
amount: kmAmount,
unitAmount: perKmRate!,
unit: 'PER_KM',
quantity: roadKm,
currency: child.paymentCurrency,
});
total += kmAmount;
}
}
await manager.getRepository(Booking).update(childId, {
totalAmount: total,
priorityScore: computed.priorityScore,
pricingBreakdown: {
lineItems,
totalAmount: total,
currency: computed.currency,
generatedAt: new Date().toISOString(),
},
} as never);
} catch (err) {
this.logger.error(
`Pricing child order ${childId} failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
/** The live PER_KM rate value for road billing, in the given currency. */
private async findPerKmRate(currency: string): Promise<number | null> {
const rates = await this.ratesService.findLiveRates();
const rate = rates.find(
(r) => r.rateUnit === 'PER_KM' && r.currency === currency,
);
return rate ? Number(rate.rateValue) : null;
}
private async userOwnsContract(
userId: string,
contract: Booking,

View File

@@ -53,4 +53,7 @@ export class ContractRouteLineView {
@ApiProperty()
remainingQuantity!: number;
@ApiProperty({ nullable: true, description: 'Road distance (km); used to bill road orders' })
km!: number | null;
}

View File

@@ -0,0 +1,28 @@
import 'reflect-metadata';
import { plainToInstance } from 'class-transformer';
import { CreateBookingOrderLineDto } from './create-booking-order.dto';
/**
* Order line haz/reefer quantities arrive as JSON numbers but must default to 0
* when omitted and coerce string inputs (defensive) to numbers.
*/
describe('CreateBookingOrderLineDto — haz/reefer coercion', () => {
const toDto = (plain: Record<string, unknown>) =>
plainToInstance(CreateBookingOrderLineDto, plain, {
enableImplicitConversion: false,
exposeDefaultValues: true,
}) as unknown as CreateBookingOrderLineDto;
it('defaults hazardous/reefer quantities to 0 when omitted', () => {
const dto = toDto({ quantity: 5 });
expect(dto.hazardousQuantity).toBe(0);
expect(dto.reeferQuantity).toBe(0);
});
it('coerces provided string quantities to numbers', () => {
const dto = toDto({ quantity: '5', hazardousQuantity: '2', reeferQuantity: '3' });
expect(dto.quantity).toBe(5);
expect(dto.hazardousQuantity).toBe(2);
expect(dto.reeferQuantity).toBe(3);
});
});

View File

@@ -25,6 +25,26 @@ export class CreateBookingOrderLineDto {
@Min(0)
@Transform(({ value }) => Number(value))
quantity!: number;
@ApiPropertyOptional({
description: 'How much of this line is hazardous (≤ quantity). Defaults to 0.',
minimum: 0,
})
@IsOptional()
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value ?? 0))
hazardousQuantity?: number = 0;
@ApiPropertyOptional({
description: 'How much of this line is refrigerated (≤ quantity). Defaults to 0.',
minimum: 0,
})
@IsOptional()
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value ?? 0))
reeferQuantity?: number = 0;
}
export class CreateBookingOrderDto {

View File

@@ -27,4 +27,15 @@ export class BookingOrderLine extends BaseEntity {
/** Containers (count), tons, or items depending on the contract's freight/UoM. */
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3 })
quantity!: number;
/**
* How much of this line is hazardous / refrigerated, entered per order by the
* 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 })
hazardousQuantity!: number;
@Column({ name: 'reefer_quantity', type: 'numeric', precision: 12, scale: 3, default: 0 })
reeferQuantity!: number;
}

View File

@@ -50,4 +50,12 @@ export class ContractRouteLine extends BaseEntity {
/** Contracted quantity for this (route, container type): containers, tons, or items. */
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3 })
quantity!: number;
/**
* Road distance for this route, configured with the route. Road (truck)
* drawdown orders bill KM × the PER_KM rate from this value. Null for
* rail-only routes where KM is not billed.
*/
@Column({ name: 'km', type: 'numeric', precision: 10, scale: 2, nullable: true })
km?: number | null;
}

View File

@@ -162,6 +162,7 @@ export class GeneralContractService {
contractedQuantity: contracted,
orderedQuantity: orderedQty,
remainingQuantity: Math.max(0, contracted - orderedQty),
km: rl.km != null ? Number(rl.km) : null,
};
});
}

View File

@@ -0,0 +1,32 @@
import { isRoadService, roadKmPrice } from './road.util';
describe('road.util', () => {
describe('isRoadService', () => {
it('treats ROAD/TRUCK codes (and prefixes) as road', () => {
expect(isRoadService({ code: 'ROAD' })).toBe(true);
expect(isRoadService({ code: 'TRUCK' })).toBe(true);
expect(isRoadService({ code: 'ROAD_CONTAINER' })).toBe(true);
expect(isRoadService({ code: 'truck_forwarding' })).toBe(true);
});
it('treats rail / unknown / missing services as not road', () => {
expect(isRoadService({ code: 'RAIL_CONTAINER' })).toBe(false);
expect(isRoadService({ code: 'OFFROADING' })).toBe(false);
expect(isRoadService(null)).toBe(false);
expect(isRoadService(undefined)).toBe(false);
});
});
describe('roadKmPrice', () => {
it('multiplies distance by the per-km rate', () => {
expect(roadKmPrice(120, 5)).toBe(600);
});
it('returns 0 when km or rate is missing/non-positive', () => {
expect(roadKmPrice(null, 5)).toBe(0);
expect(roadKmPrice(120, null)).toBe(0);
expect(roadKmPrice(0, 5)).toBe(0);
expect(roadKmPrice(120, 0)).toBe(0);
});
});
});

View File

@@ -0,0 +1,35 @@
import { ServiceType } from '../rule-engine/entities/service-type.entity';
/**
* Road (truck) services are distinguished by their ServiceType.code. Rail
* services are seeded as RAIL_* and go through the train batch pool; a road
* service (code starting ROAD_ or TRUCK_, or exactly ROAD/TRUCK) instead bills
* by distance and dispatches a truck. Prefix-matching keeps this resilient to
* the exact seeded code (e.g. ROAD_CONTAINER, TRUCK_FORWARDING).
*/
export function isRoadService(
serviceType?: Pick<ServiceType, 'code'> | null,
): boolean {
const code = serviceType?.code?.toUpperCase() ?? '';
return (
code === 'ROAD' ||
code === 'TRUCK' ||
code.startsWith('ROAD_') ||
code.startsWith('TRUCK_')
);
}
/**
* Road freight charge for an order: distance (km, from the route line) × the
* per-km rate. Returns 0 when either input is missing so callers can add it to
* a total without guarding.
*/
export function roadKmPrice(
km: number | null | undefined,
perKmRate: number | null | undefined,
): number {
const distance = Number(km ?? 0);
const rate = Number(perKmRate ?? 0);
if (!(distance > 0) || !(rate > 0)) return 0;
return distance * rate;
}

View File

@@ -23,6 +23,13 @@ import { clearanceSettingCode } from './clearance.util';
import { ContractViewDto } from './dto/contract-view.dto';
import { SignContractDto } from './dto/sign-contract.dto';
import { ContractSignerRole } from './entities/booking-contract-signature.entity';
/**
* Default ordering window (months) for a general contract activated on
* counter-sign. Mirrors GeneralContractService.DEFAULT_CONTRACT_PERIOD_MONTHS;
* defined locally to avoid a circular module dependency on booking-orders.
*/
const DEFAULT_CONTRACT_PERIOD_MONTHS = 3;
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { SignaturesService } from '../signatures/signatures.service';
@@ -233,9 +240,23 @@ export class BookingContractService {
includesCustoms,
);
const isGeneralContract = booking.bookingType === 'GENERAL_CONTRACT';
if (role === 'CUSTOMER') {
updates.status = 'SIGNED_CUSTOMER';
updates.customerSignedAt = now;
} else if (isGeneralContract) {
// A general contract is NOT paid up front — each drawdown order is priced
// and paid on its own. So on counter-sign it becomes ACTIVE directly and
// opens its ordering window; orders spawn their own priced child bookings.
const expiresAt = new Date(now);
expiresAt.setMonth(expiresAt.getMonth() + DEFAULT_CONTRACT_PERIOD_MONTHS);
updates.fullyExecutedAt = now;
updates.marketingApprovedAt = now;
updates.marketingApprovedById = options.signerUserId ?? null;
updates.lockedAt = now;
updates.status = 'CONTRACT_ACTIVE';
updates.expiresAt = expiresAt;
} else {
updates.fullyExecutedAt = now;
updates.marketingApprovedAt = now;

View File

@@ -268,6 +268,10 @@ export class BookingPricingService {
tradeDirection: booking.tradeDirection,
// Coerce defensively in case the stored flag is a string ("true"/"false").
isHazardous: booking.isHazardous === true || (booking.isHazardous as unknown) === 'true',
// Booking-level reefer flag (set by contract drawdown orders that carry a
// reefer quantity) applies the REEFER surcharge even for non-reefer
// container types. ORed with per-container reefer in the engine.
isReefer: booking.isReefer === true || (booking.isReefer as unknown) === 'true',
isGovernment: booking.isGovernment,
allowConsolidation,
shippingLineId: booking.shippingLineId,

View File

@@ -0,0 +1,95 @@
import { BadRequestException } from '@nestjs/common';
import { BookingTransitionService } from './booking-transition.service';
/**
* Operation-request review for general-contract drawdown orders:
* - ACCEPT a train order → FULLY_EXECUTED and enqueued into the batch pool.
* - ACCEPT a road order → ROAD_DISPATCH_PENDING, NOT enqueued.
* - REQUEST_CHANGES requires a note → OPERATION_CHANGES_REQUESTED.
* - ADJUST_PRICE sets the adjusted total → OPERATION_PRICE_PENDING_CONFIRM.
*/
describe('BookingTransitionService — operation review', () => {
function makeService(serviceTypeCode: string) {
const booking = {
id: 'b-1',
status: 'OPERATION_REQUEST_PENDING',
originYardId: 'o-1',
destinationYardId: 'd-1',
scheduledDate: new Date('2026-07-01T00:00:00.000Z'),
serviceType: { code: serviceTypeCode },
};
const bookingsRepository = {
update: jest.fn().mockResolvedValue({ id: 'b-1' }),
createReviewNote: jest.fn().mockResolvedValue(undefined),
};
const bookingsService = {
findById: jest.fn().mockResolvedValue(booking),
};
const bookingBatchService = {
enqueueRouteDayProcessing: jest.fn(),
};
const service = new BookingTransitionService(
bookingsRepository as never,
{} as never, // ruleEngineService
{} as never, // pricingService
{} as never, // contractService
{} as never, // filesService
{} as never, // fileUploadSettingsService
bookingBatchService as never,
bookingsService as never,
);
return { service, bookingsRepository, bookingBatchService };
}
it('ACCEPT of a train order → FULLY_EXECUTED and enqueues the batch pool', async () => {
const { service, bookingsRepository, bookingBatchService } =
makeService('RAIL_CONTAINER');
await service.reviewOperationRequest('b-1', 'ACCEPT', 'staff-1');
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-1',
expect.objectContaining({ status: 'FULLY_EXECUTED' }),
);
expect(bookingBatchService.enqueueRouteDayProcessing).toHaveBeenCalledTimes(1);
});
it('ACCEPT of a road order → ROAD_DISPATCH_PENDING and does NOT enqueue', async () => {
const { service, bookingsRepository, bookingBatchService } =
makeService('ROAD_CONTAINER');
await service.reviewOperationRequest('b-1', 'ACCEPT', 'staff-1');
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-1',
expect.objectContaining({ status: 'ROAD_DISPATCH_PENDING' }),
);
expect(bookingBatchService.enqueueRouteDayProcessing).not.toHaveBeenCalled();
});
it('REQUEST_CHANGES requires a note → OPERATION_CHANGES_REQUESTED', async () => {
const { service, bookingsRepository } = makeService('RAIL_CONTAINER');
await expect(
service.reviewOperationRequest('b-1', 'REQUEST_CHANGES', 'staff-1', {}),
).rejects.toBeInstanceOf(BadRequestException);
await service.reviewOperationRequest('b-1', 'REQUEST_CHANGES', 'staff-1', {
note: 'Fix the schedule',
});
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-1',
expect.objectContaining({ status: 'OPERATION_CHANGES_REQUESTED' }),
);
});
it('ADJUST_PRICE sets the adjusted total → OPERATION_PRICE_PENDING_CONFIRM', async () => {
const { service, bookingsRepository } = makeService('RAIL_CONTAINER');
await service.reviewOperationRequest('b-1', 'ADJUST_PRICE', 'staff-1', {
amount: 1500,
});
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-1',
expect.objectContaining({
adjustedTotalAmount: 1500,
status: 'OPERATION_PRICE_PENDING_CONFIRM',
}),
);
});
});

View File

@@ -9,6 +9,7 @@ import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/curre
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { eatDay } from '../train-scheduling/batch-window.util';
import { isRoadService } from '../booking-orders/road.util';
import { RuleEngineService } from '../rule-engine/rule-engine.service';
import { FilesService } from '../files/files.service';
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
@@ -879,12 +880,27 @@ export class BookingTransitionService {
}
/**
* Move a reviewed operation request into the batch holding pool. The pool query
* (findBatchPoolByRouteDay) keys on FULLY_EXECUTED + scheduled_date, so we set
* those and kick the day-level fill immediately instead of waiting for cron.
* Move a reviewed operation request forward after Marketing accepts.
*
* - Train services enter the batch holding pool: the pool query
* (findBatchPoolByRouteDay) keys on FULLY_EXECUTED + scheduled_date, so we
* set those and kick the day-level fill immediately instead of waiting for
* cron.
* - Road (truck) services skip the train batch entirely and wait for truck
* dispatch at ROAD_DISPATCH_PENDING; they are billed by KM, not wagons.
*/
private async acceptOperationRequest(booking: Booking): Promise<Booking> {
const now = new Date();
if (isRoadService(booking.serviceType)) {
await this.bookingsRepository.update(booking.id, {
status: 'ROAD_DISPATCH_PENDING',
fullyExecutedAt: now,
lockedAt: booking.lockedAt ?? now,
} as never);
return this.bookingsService.findById(booking.id);
}
await this.bookingsRepository.update(booking.id, {
status: 'FULLY_EXECUTED',
fullyExecutedAt: now,

View File

@@ -79,6 +79,6 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
ContractRendererService,
ContractPdfService,
],
exports: [BookingsService, BookingsRepository],
exports: [BookingsService, BookingsRepository, BookingPricingService],
})
export class BookingsModule {}

View File

@@ -463,6 +463,7 @@ export class BookingsService {
containerTypeId:
dto.freightType === 'CONTAINER' ? (r.containerTypeId ?? null) : null,
quantity: r.quantity,
km: r.km ?? null,
}),
),
);

View File

@@ -77,6 +77,18 @@ export class CreateContractRouteDto {
@Min(0)
@Transform(({ value }) => Number(value))
quantity!: number;
@ApiPropertyOptional({
description: 'Road distance (km) for this route; used to bill road orders.',
minimum: 0,
})
@IsOptional()
@IsNumber()
@Min(0)
@Transform(({ value }) =>
value === undefined || value === null || value === '' ? undefined : Number(value),
)
km?: number;
}
export class CreateBookingDto {

View File

@@ -47,6 +47,9 @@ export const BOOKING_STATUSES = [
'AWAITING_DOCUMENTS',
'DOCUMENTS_UNDER_REVIEW',
'CLEARANCE_READY',
// Road (truck) drawdown orders skip the train batch pool and wait here for
// truck dispatch after Marketing accepts; billed by KM, not wagons.
'ROAD_DISPATCH_PENDING',
'OPERATION_REQUESTED',
// Operations review gate: customer picks a schedule day and submits the
// operation request; the operations team reviews capacity/docs/route before
@@ -288,6 +291,15 @@ export class Booking extends BaseEntity {
@Column({ name: 'is_hazardous', type: 'boolean', default: false })
isHazardous!: boolean;
/**
* Refrigerated cargo flag. For one-time bookings reefer is derived from the
* container type; for general-contract drawdown orders the customer enters a
* reefer quantity per order, which sets this flag on the spawned child so the
* REEFER_SURCHARGE rate applies even when the container type is not a reefer.
*/
@Column({ name: 'is_reefer', type: 'boolean', default: false })
isReefer!: boolean;
@Column({ name: 'payment_currency', type: 'varchar', length: 5 })
paymentCurrency!: string;

View File

@@ -51,6 +51,8 @@ export interface BookingEvaluationInput {
paymentCurrency: string;
tradeDirection: string;
isHazardous: boolean;
/** Booking-level reefer flag; ORed with per-container reefer. */
isReefer?: boolean;
isGovernment?: boolean;
allowConsolidation?: boolean;
shippingLineId?: string | null;
@@ -195,7 +197,8 @@ export class RuleEngineService {
shippingLineMapped = Boolean(line?.mappedToCode);
}
const hasReefer = input.containers.some((c) => c.isReefer);
const hasReefer =
input.isReefer === true || input.containers.some((c) => c.isReefer);
const hasOverweight = containerWeightResults.some((r) => r.isOverweight);
// Surcharges are now self-describing rates: any LIVE rate whose `trigger`