mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
add contract management types and interfaces for freight contracts
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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')}`;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user