mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 06:40:57 +00:00
Merge branch 'freight_feature/profile' of github.com:Tria-plc/edr-platform into freight_feature/profile
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
35
apps/edr-freight-api/src/modules/booking-orders/road.util.ts
Normal file
35
apps/edr-freight-api/src/modules/booking-orders/road.util.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -79,6 +79,6 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
|
||||
ContractRendererService,
|
||||
ContractPdfService,
|
||||
],
|
||||
exports: [BookingsService, BookingsRepository],
|
||||
exports: [BookingsService, BookingsRepository, BookingPricingService],
|
||||
})
|
||||
export class BookingsModule {}
|
||||
|
||||
@@ -463,6 +463,7 @@ export class BookingsService {
|
||||
containerTypeId:
|
||||
dto.freightType === 'CONTAINER' ? (r.containerTypeId ?? null) : null,
|
||||
quantity: r.quantity,
|
||||
km: r.km ?? null,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user