add contract management types and interfaces for freight contracts

This commit is contained in:
Marshal
2026-06-26 23:20:20 +00:00
parent f7428df664
commit f931342f31
14 changed files with 0 additions and 1314 deletions

View File

@@ -1,62 +0,0 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
Query,
} from '@nestjs/common';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingOrdersService } from './booking-orders.service';
import { CreateBookingOrderDto } from './dto/create-booking-order.dto';
import { GeneralContractService } from './general-contract.service';
@ApiTags('Booking Orders')
@Controller('booking-orders')
export class BookingOrdersController {
constructor(
private readonly ordersService: BookingOrdersService,
private readonly generalContractService: GeneralContractService,
) {}
@Post()
@ApiOperation({ summary: 'Place a drawdown order against a general contract' })
async create(
@Body() dto: CreateBookingOrderDto,
@CurrentUser() user: TCurrentUser,
) {
return this.ordersService.create(dto, user?.id);
}
@Get()
@ApiOperation({ summary: 'List orders placed against a contract' })
async list(@Query('contractBookingId', ParseUUIDPipe) contractBookingId: string) {
return this.ordersService.listByContract(contractBookingId);
}
@Get('contract/:id/pool')
@ApiOperation({
summary: 'Contracted / ordered / remaining quantities for a general contract',
})
async pool(@Param('id', ParseUUIDPipe) id: string) {
return this.generalContractService.getQuantityLines(id);
}
@Get('contract/:id/routes')
@ApiOperation({
summary:
'Per-route contracted / ordered / remaining quantities (multi-route contracts). Empty for single-route.',
})
async routes(@Param('id', ParseUUIDPipe) id: string) {
return this.generalContractService.getRouteLines(id);
}
@Get(':id')
@ApiOperation({ summary: 'Get a single booking order' })
async findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.ordersService.findById(id);
}
}

View File

@@ -1,33 +0,0 @@
import { forwardRef, Module } from '@nestjs/common';
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';
import { BookingOrdersService } from './booking-orders.service';
import { BookingOrder } from './entities/booking-order.entity';
import { BookingOrderLine } from './entities/booking-order-line.entity';
import { ContractRouteLine } from './entities/contract-route-line.entity';
import { GeneralContractService } from './general-contract.service';
@Module({
imports: [
TypeOrmModule.forFeature([BookingOrder, BookingOrderLine, ContractRouteLine]),
BookingsModule,
CompaniesModule,
DropdownSettingsModule,
RuleEngineModule,
forwardRef(() => TrainSchedulingModule),
],
controllers: [BookingOrdersController],
providers: [
BookingOrdersService,
BookingOrdersRepository,
GeneralContractService,
],
exports: [BookingOrdersService, GeneralContractService],
})
export class BookingOrdersModule {}

View File

@@ -1,41 +0,0 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BookingOrder } from './entities/booking-order.entity';
@Injectable()
export class BookingOrdersRepository extends BaseRepository<BookingOrder> {
constructor(
@InjectRepository(BookingOrder)
repository: Repository<BookingOrder>,
) {
super(repository);
}
/** Orders placed against a given contract, newest first, with their lines. */
findByContract(contractBookingId: string): Promise<BookingOrder[]> {
return this.repository.find({
where: { contractBookingId },
relations: { lines: { containerType: true }, booking: true },
order: { createdAt: 'DESC' },
});
}
override findById(id: string): Promise<BookingOrder | null> {
return this.repository.findOne({
where: { id },
relations: { lines: { containerType: true }, booking: true, contractBooking: true },
});
}
/** Count this calendar year's orders, for reference generation. */
async countByYear(year: number): Promise<number> {
const start = new Date(Date.UTC(year, 0, 1));
const end = new Date(Date.UTC(year + 1, 0, 1));
return this.repository
.createQueryBuilder('o')
.where('o.createdAt >= :start AND o.createdAt < :end', { start, end })
.getCount();
}
}

View File

@@ -1,127 +0,0 @@
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

@@ -1,469 +0,0 @@
import {
BadRequestException,
forwardRef,
Inject,
Injectable,
Logger,
NotFoundException,
} 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 { 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';
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 {
private readonly logger = new Logger(BookingOrdersService.name);
constructor(
private readonly dataSource: DataSource,
private readonly ordersRepository: BookingOrdersRepository,
private readonly bookingsRepository: BookingsRepository,
private readonly companiesService: CompaniesService,
private readonly generalContractService: GeneralContractService,
private readonly pricingService: BookingPricingService,
private readonly ratesService: RatesService,
@Inject(forwardRef(() => TrainSchedulingService))
private readonly trainSchedulingService: TrainSchedulingService,
) {}
/** Orders placed against a contract, with their lines and child booking. */
async listByContract(contractBookingId: string): Promise<BookingOrder[]> {
const orders = await this.ordersRepository.findByContract(contractBookingId);
await Promise.all(orders.map((o) => this.syncOrderFromChild(o)));
return orders;
}
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,
});
}
}
/**
* Place a drawdown order against an ACTIVE general contract.
*
* Validates the requested quantities against the remaining pool, then spawns a
* ONE_TIME child Booking (PAID + FULLY_EXECUTED, inheriting the contract's
* route/cargo/service) so it flows through the existing train-scheduling
* pipeline. The order row is the ledger entry linking contract → child booking.
*/
async create(
dto: CreateBookingOrderDto,
userId?: string,
): Promise<BookingOrder> {
const contract = await this.bookingsRepository.findById(dto.contractBookingId);
if (!contract) {
throw new NotFoundException(`Contract ${dto.contractBookingId} not found`);
}
if (!this.generalContractService.isGeneralContract(contract)) {
throw new BadRequestException('Booking is not a general contract');
}
if (contract.status !== 'CONTRACT_ACTIVE') {
throw new BadRequestException(
`Contract is ${contract.status} — orders can only be placed against an ACTIVE contract`,
);
}
if (contract.expiresAt && contract.expiresAt.getTime() <= Date.now()) {
throw new BadRequestException('Contract ordering window has expired');
}
// The customer placing the order must own the contract.
if (userId && !(await this.userOwnsContract(userId, contract))) {
throw new BadRequestException('You do not have access to this contract');
}
// Resolve the route the order ships on: a chosen contract route line for a
// multi-route contract, else the contract's own origin/destination.
const routeLines = await this.generalContractService.getRouteLines(
contract.id,
);
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) {
throw new BadRequestException(
'This contract has multiple routes — select a route to draw from',
);
}
const chosen = routeLines.find((r) => r.routeLineId === dto.routeLineId);
if (!chosen) {
throw new BadRequestException(
'Selected route is not part of this contract',
);
}
originYardId = chosen.originYardId;
destinationYardId = chosen.destinationYardId;
routeLineId = chosen.routeLineId;
routeKm = chosen.km ?? null;
}
// Validate the route has a departure on the chosen day.
const day = eatDay(new Date(dto.scheduledDate));
const hasDeparture =
await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
originYardId,
destinationYardId,
day,
);
if (!hasDeparture) {
throw new BadRequestException(
'No departures available on the selected day for this route',
);
}
const isContainer = contract.freightType === 'CONTAINER';
// 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',
);
}
}
// 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 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}` : ''),
);
}
}
// Persist the order + its child shipment booking atomically.
const order = await this.dataSource.transaction(async (manager) => {
const childBooking = await this.spawnChildBooking(
contract,
dto,
{ originYardId, destinationYardId, km: routeKm },
manager,
);
const reference = await this.generateReference();
const orderRow = manager.create(BookingOrder, {
reference,
contractBookingId: contract.id,
bookingId: childBooking.id,
routeLineId,
companyId: contract.companyId ?? null,
scheduledDate: new Date(dto.scheduledDate),
// 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);
const lines = dto.lines.map((l) =>
manager.create(BookingOrderLine, {
orderId: savedOrder.id,
containerTypeId: isContainer ? (l.containerTypeId ?? null) : null,
quantity: l.quantity,
hazardousQuantity: l.hazardousQuantity ?? 0,
reeferQuantity: l.reeferQuantity ?? 0,
}),
);
await manager.save(lines);
savedOrder.lines = lines;
return savedOrder;
});
// 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 (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)
.update(contract.id, { status: 'CONTRACT_CLOSED' });
this.logger.log(
`Contract ${contract.reference} CLOSED — quantity exhausted`,
);
}
return (await this.ordersRepository.findById(order.id)) ?? order;
}
/**
* Create the ONE_TIME child booking for an order, inheriting the contract's
* 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; km: number | null },
manager: import('typeorm').EntityManager,
): Promise<Booking> {
const reference = await this.generateChildBookingReference();
const isContainer = contract.freightType === 'CONTAINER';
// Sum line quantities × the contract's per-unit weight for the child total.
const containerByType = new Map(
(contract.bookingContainers ?? []).map((c) => [c.containerTypeId, c]),
);
let totalWeight = 0;
if (isContainer) {
for (const line of dto.lines) {
const src = containerByType.get(line.containerTypeId ?? '');
const vgmPerUnit = src ? Number(src.vgmPerUnitTons) : 0;
totalWeight += vgmPerUnit * line.quantity;
}
} else {
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,
companyProfileId: contract.companyProfileId ?? null,
isGovernment: contract.isGovernment,
governmentInstitution: contract.governmentInstitution ?? null,
contractType: contract.contractType,
previousContractId: contract.id,
serviceTypeId: contract.serviceTypeId,
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
equipmentReturn: contract.equipmentReturn,
originYardId: route.originYardId,
destinationYardId: route.destinationYardId,
tradeDirection: contract.tradeDirection,
freightType: contract.freightType,
cargoTypeId: contract.cargoTypeId ?? null,
cargoFreeText: contract.cargoFreeText ?? null,
shippingLineId: contract.shippingLineId ?? null,
cargoTotalWeightVgm: totalWeight,
isHazardous: hasHazardous,
isReefer: hasReefer,
paymentCurrency: contract.paymentCurrency,
bookingType: 'ONE_TIME',
scheduledDate: new Date(dto.scheduledDate),
// Priced + unpaid: the customer pays this order on its own.
status: spawnStatus,
paymentStatus: 'PENDING',
priorityScore: contract.priorityScore,
totalAmount: 0,
schedulingStatus: 'NOT_SCHEDULED',
});
const savedChild = await manager.save(child);
if (isContainer) {
for (const line of dto.lines) {
const src = containerByType.get(line.containerTypeId ?? '');
const ct = line.containerTypeId
? await manager.getRepository(ContainerType).findOne({
where: { id: line.containerTypeId },
})
: null;
const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1;
const vgmPerUnit = src ? Number(src.vgmPerUnitTons) : 0;
const row = manager.create(BookingContainer, {
bookingId: savedChild.id,
containerTypeId: line.containerTypeId ?? null,
quantity: line.quantity,
vgmPerUnitTons: vgmPerUnit,
totalVgmTons: vgmPerUnit * line.quantity,
wagonsRequired: Math.ceil(line.quantity * wagonsPerUnit),
isOverweight: false,
});
await manager.save(row);
}
}
// 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,
): Promise<boolean> {
if (!contract.companyId) return true; // government / staff-created
try {
const { company } = await this.companiesService.getCompanyInfoByUserId(
userId,
);
return company.id === contract.companyId;
} catch {
return false;
}
}
private async generateReference(): Promise<string> {
const year = new Date().getFullYear();
const count = await this.ordersRepository.countByYear(year);
return `ORD-${year}-${String(count + 1).padStart(6, '0')}`;
}
private async generateChildBookingReference(): Promise<string> {
const year = new Date().getFullYear();
const count = await this.bookingsRepository.countByYear(year);
return `BK-${year}-${String(count + 1).padStart(6, '0')}`;
}
}

View File

@@ -1,50 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { CargoUnitOfMeasure } from '@edr/types';
/** A single contracted/ordered/remaining pool line for a general contract. */
export class ContractQuantityLineView {
@ApiProperty({ nullable: true, description: 'Container type id (null for bulk/break-bulk)' })
containerTypeId!: string | null;
@ApiProperty({ nullable: true })
containerTypeName!: string | null;
@ApiProperty({ enum: CargoUnitOfMeasure, nullable: true })
unitOfMeasure!: CargoUnitOfMeasure | null;
@ApiProperty()
contractedQuantity!: number;
@ApiProperty()
orderedQuantity!: number;
@ApiProperty()
remainingQuantity!: number;
}
/**
* 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;
@ApiProperty()
originYardId!: string;
@ApiProperty({ nullable: true })
originYardName!: string | null;
@ApiProperty()
destinationYardId!: string;
@ApiProperty({ nullable: true })
destinationYardName!: string | null;
@ApiProperty({ nullable: true, description: 'Road distance (km); used to bill road orders' })
km!: number | null;
}

View File

@@ -1,28 +0,0 @@
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

@@ -1,75 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import {
ArrayMinSize,
IsArray,
IsDateString,
IsNumber,
IsOptional,
IsUUID,
Min,
ValidateNested,
} from 'class-validator';
export class CreateBookingOrderLineDto {
@ApiPropertyOptional({
format: 'uuid',
description: 'Container type for this line (CONTAINER contracts). Omit for bulk/break-bulk.',
})
@IsOptional()
@IsUUID()
containerTypeId?: string;
@ApiProperty({ description: 'Quantity to draw down (containers, tons, or items)', minimum: 0 })
@IsNumber()
@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 {
@ApiProperty({ format: 'uuid', description: 'The general contract to draw down from' })
@IsUUID()
contractBookingId!: string;
@ApiPropertyOptional({
format: 'uuid',
description:
'For multi-route contracts: the contract route line being drawn from. ' +
'Determines the shipment origin/destination. Omit for single-route contracts.',
})
@IsOptional()
@IsUUID()
routeLineId?: string;
@ApiProperty({ example: '2026-07-01T00:00:00.000Z', description: 'Shipment day for this order' })
@IsDateString()
scheduledDate!: string;
@ApiProperty({ type: [CreateBookingOrderLineDto] })
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => CreateBookingOrderLineDto)
lines!: CreateBookingOrderLineDto[];
}

View File

@@ -1,65 +0,0 @@
import { BaseEntity } from '@edr/api-common';
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
* single line with a null containerTypeId carries the tons/items.
*/
@Entity({ schema: 'freight', name: 'booking_order_lines' })
export class BookingOrderLine extends BaseEntity {
@Column({ name: 'order_id', type: 'uuid' })
orderId!: string;
@ManyToOne(() => BookingOrder, (order) => order.lines, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'order_id' })
order?: BookingOrder;
@Column({ name: 'container_type_id', type: 'uuid', nullable: true })
containerTypeId?: string | null;
@ManyToOne(() => ContainerType, { nullable: true })
@JoinColumn({ name: 'container_type_id' })
containerType?: ContainerType | null;
/** Containers (count), tons, or items depending on the contract's freight/UoM. */
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3, transformer: numericColumn })
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,
transformer: numericColumn,
})
hazardousQuantity!: number;
@Column({
name: 'reefer_quantity',
type: 'numeric',
precision: 12,
scale: 3,
default: 0,
transformer: numericColumn,
})
reeferQuantity!: number;
}

View File

@@ -1,70 +0,0 @@
import { BaseEntity } from '@edr/api-common';
import { SchedulingStatus } from '@edr/types';
import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { Company } from '../../companies/entities/company.entity';
import { BookingOrderLine } from './booking-order-line.entity';
/**
* A single drawdown against a general contract. Each order spawns its own
* ONE_TIME child Booking (the shipment that enters the train scheduling
* pipeline); this row is the ledger entry linking the contract to that
* shipment and recording the drawn-down quantities.
*/
@Entity({ schema: 'freight', name: 'booking_orders' })
export class BookingOrder extends BaseEntity {
@Column({ name: 'reference', type: 'varchar', length: 64, unique: true })
reference!: string;
/** The general contract (a Booking with bookingType = GENERAL_CONTRACT). */
@Column({ name: 'contract_booking_id', type: 'uuid' })
contractBookingId!: string;
@ManyToOne(() => Booking)
@JoinColumn({ name: 'contract_booking_id' })
contractBooking?: Booking;
/** The ONE_TIME child shipment booking spawned for this order. */
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
bookingId?: string | null;
@ManyToOne(() => Booking, { nullable: true })
@JoinColumn({ name: 'booking_id' })
booking?: Booking | null;
/** Denormalized from the contract for fast company-scoped filtering. */
@Column({ name: 'company_id', type: 'uuid', nullable: true })
companyId?: string | null;
@ManyToOne(() => Company, { nullable: true })
@JoinColumn({ name: 'company_id' })
company?: Company | null;
/**
* The contract route line this order drew down (multi-route general contracts).
* Null for legacy/single-route contracts that have no route lines — the order
* then uses the contract's own origin/destination.
*/
@Column({ name: 'route_line_id', type: 'uuid', nullable: true })
routeLineId?: string | null;
@Column({ name: 'scheduled_date', type: 'timestamptz' })
scheduledDate!: Date;
@Column({ name: 'status', type: 'varchar', length: 40, default: 'PAID' })
status!: string;
@Column({
name: 'scheduling_status',
type: 'varchar',
length: 30,
default: SchedulingStatus.NotScheduled,
})
schedulingStatus!: string;
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
trainScheduleId?: string | null;
@OneToMany(() => BookingOrderLine, (line) => line.order, { cascade: true })
lines?: BookingOrderLine[];
}

View File

@@ -1,61 +0,0 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { ContainerType } from '../../rule-engine/entities/container-type.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
/**
* One contracted route+quantity line of a GENERAL contract. A general contract
* may span several routes (e.g. Addis→Dire Dawa: 10, Modjo→Djibouti: 5); each
* route reserves its own quantity pool. Drawdown orders pick one of these routes
* and decrement that route's pool. One-time bookings do not use this — they keep
* the single origin/destination on the booking itself.
*/
@Entity({ schema: 'freight', name: 'contract_route_lines' })
@Index(['contractBookingId'])
export class ContractRouteLine extends BaseEntity {
/** The general contract (a Booking with bookingType = GENERAL_CONTRACT). */
@Column({ name: 'contract_booking_id', type: 'uuid' })
contractBookingId!: string;
@ManyToOne(() => Booking)
@JoinColumn({ name: 'contract_booking_id' })
contractBooking?: Booking;
@Column({ name: 'origin_yard_id', type: 'uuid' })
originYardId!: string;
@ManyToOne(() => Yard)
@JoinColumn({ name: 'origin_yard_id' })
originYard?: Yard;
@Column({ name: 'destination_yard_id', type: 'uuid' })
destinationYardId!: string;
@ManyToOne(() => Yard)
@JoinColumn({ name: 'destination_yard_id' })
destinationYard?: Yard;
/**
* Container type this route line reserves (CONTAINER contracts); null for
* BULK/BREAK_BULK, where the quantity is tons/items.
*/
@Column({ name: 'container_type_id', type: 'uuid', nullable: true })
containerTypeId?: string | null;
@ManyToOne(() => ContainerType, { nullable: true })
@JoinColumn({ name: 'container_type_id' })
containerType?: ContainerType | null;
/** 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

@@ -1,201 +0,0 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { BookingType, CargoUnitOfMeasure } from '@edr/types';
import { DataSource } from 'typeorm';
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingOrder } from './entities/booking-order.entity';
import { ContractRouteLine } from './entities/contract-route-line.entity';
import {
ContractQuantityLineView,
ContractRouteLineView,
} from './dto/contract-view.dto';
/** Setting code holding the global ordering window (in months) for general contracts. */
export const CONTRACT_PERIOD_SETTING_CODE = 'general_contract_period';
/** Fallback when the setting is missing or unparseable. */
export const DEFAULT_CONTRACT_PERIOD_MONTHS = 3;
/**
* Owns general-contract concerns that sit alongside the generic booking flow:
* the configurable ordering period, post-payment activation, and computing the
* remaining drawdown pool per contract.
*/
@Injectable()
export class GeneralContractService {
private readonly logger = new Logger(GeneralContractService.name);
constructor(
private readonly dataSource: DataSource,
private readonly dropdownSettings: DropdownSettingsService,
) {}
isGeneralContract(booking: Pick<Booking, 'bookingType'>): boolean {
return booking.bookingType === BookingType.GeneralContract;
}
/** The configured ordering window in months (defaults to 3). */
async getPeriodMonths(): Promise<number> {
try {
const setting = await this.dropdownSettings.getByCode(
CONTRACT_PERIOD_SETTING_CODE,
);
const raw = setting.children?.[0]?.value;
const months = Number(raw);
if (Number.isFinite(months) && months > 0) return months;
} catch {
// Setting not seeded yet — fall back to the default.
}
return DEFAULT_CONTRACT_PERIOD_MONTHS;
}
/**
* Called when a general contract's payment succeeds: mark it ACTIVE (instead of
* entering the train queue like a one-time booking) and stamp the ordering
* window. Idempotent.
*/
async activateAfterPayment(bookingId: string): Promise<void> {
const repo = this.dataSource.getRepository(Booking);
const booking = await repo.findOne({ where: { id: bookingId } });
if (!booking || !this.isGeneralContract(booking)) return;
if (booking.status === 'CONTRACT_ACTIVE' || booking.status === 'CONTRACT_CLOSED') {
return;
}
const months = await this.getPeriodMonths();
const expiresAt = new Date();
expiresAt.setMonth(expiresAt.getMonth() + months);
await repo.update(bookingId, {
status: 'CONTRACT_ACTIVE',
paymentStatus: 'PAID',
expiresAt,
});
this.logger.log(
`General contract ${booking.reference} ACTIVE — ordering window ${months} month(s) (expires ${expiresAt.toISOString()})`,
);
}
/**
* The drawdown pool for a contract: contracted vs. ordered vs. remaining,
* per container type for CONTAINER contracts, or a single total line for
* BULK/BREAK_BULK (keyed on a null container type).
*/
async getQuantityLines(
contractBookingId: string,
): Promise<ContractQuantityLineView[]> {
const booking = await this.dataSource.getRepository(Booking).findOne({
where: { id: contractBookingId },
relations: { bookingContainers: { containerType: true }, cargoType: true },
});
if (!booking) throw new NotFoundException(`Contract ${contractBookingId} not found`);
const ordered = await this.orderedByContainerType(contractBookingId);
if (booking.freightType === 'CONTAINER') {
return (booking.bookingContainers ?? []).map((c) => {
const orderedQty = ordered.get(c.containerTypeId ?? '') ?? 0;
const contracted = Number(c.quantity);
return {
containerTypeId: c.containerTypeId ?? null,
containerTypeName: c.containerType?.label ?? null,
unitOfMeasure: null,
contractedQuantity: contracted,
orderedQuantity: orderedQty,
remainingQuantity: Math.max(0, contracted - orderedQty),
};
});
}
// BULK / BREAK_BULK — a single pool keyed on the contracted total weight/items.
const orderedQty = ordered.get('') ?? 0;
const contracted = Number(booking.cargoTotalWeightVgm);
const uom: CargoUnitOfMeasure | null =
(booking.cargoType?.unitOfMeasure as CargoUnitOfMeasure | undefined) ??
CargoUnitOfMeasure.PerTon;
return [
{
containerTypeId: null,
containerTypeName: null,
unitOfMeasure: uom,
contractedQuantity: contracted,
orderedQuantity: orderedQty,
remainingQuantity: Math.max(0, contracted - orderedQty),
},
];
}
/**
* 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,
): Promise<ContractRouteLineView[]> {
const routeLines = await this.dataSource
.getRepository(ContractRouteLine)
.find({
where: { contractBookingId },
relations: {
originYard: true,
destinationYard: true,
},
order: { createdAt: 'ASC' },
});
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). */
private async orderedByContainerType(
contractBookingId: string,
): Promise<Map<string, number>> {
const rows = await this.dataSource
.getRepository(BookingOrder)
.createQueryBuilder('o')
.innerJoin('o.lines', 'line')
.select('COALESCE(line.container_type_id::text, :empty)', 'key')
.addSelect('SUM(line.quantity)', 'total')
.where('o.contract_booking_id = :contractBookingId', { contractBookingId })
.andWhere(`o.status NOT IN ('CANCELLED', 'REJECTED')`)
.setParameter('empty', '')
.groupBy('key')
.getRawMany<{ key: string; total: string }>();
const map = new Map<string, number>();
for (const row of rows) map.set(row.key ?? '', Number(row.total));
return map;
}
/** Convenience: how many units remain for a given container type ('' = bulk). */
async remainingFor(
contractBookingId: string,
containerTypeKey: string,
): Promise<number> {
const lines = await this.getQuantityLines(contractBookingId);
const line = lines.find(
(l) => (l.containerTypeId ?? '') === containerTypeKey,
);
return line?.remainingQuantity ?? 0;
}
/**
* 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> {
const lines = await this.getQuantityLines(contractBookingId);
return lines.every((l) => l.remainingQuantity <= 0);
}
}

View File

@@ -1,32 +0,0 @@
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);
});
});
});