From f931342f3173acd23b2a4796900f44bcb543cb3a Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 26 Jun 2026 23:20:20 +0000 Subject: [PATCH] add contract management types and interfaces for freight contracts --- .../booking-orders.controller.ts | 62 --- .../booking-orders/booking-orders.module.ts | 33 -- .../booking-orders.repository.ts | 41 -- .../booking-orders.service.spec.ts | 127 ----- .../booking-orders/booking-orders.service.ts | 469 ------------------ .../booking-orders/dto/contract-view.dto.ts | 50 -- .../dto/create-booking-order.dto.spec.ts | 28 -- .../dto/create-booking-order.dto.ts | 75 --- .../entities/booking-order-line.entity.ts | 65 --- .../entities/booking-order.entity.ts | 70 --- .../entities/contract-route-line.entity.ts | 61 --- .../general-contract.service.ts | 201 -------- .../modules/booking-orders/road.util.spec.ts | 32 -- .../{booking-orders => bookings}/road.util.ts | 0 14 files changed, 1314 deletions(-) delete mode 100644 apps/edr-freight-api/src/modules/booking-orders/booking-orders.controller.ts delete mode 100644 apps/edr-freight-api/src/modules/booking-orders/booking-orders.module.ts delete mode 100644 apps/edr-freight-api/src/modules/booking-orders/booking-orders.repository.ts delete mode 100644 apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.spec.ts delete mode 100644 apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts delete mode 100644 apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts delete mode 100644 apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.spec.ts delete mode 100644 apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.ts delete mode 100644 apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts delete mode 100644 apps/edr-freight-api/src/modules/booking-orders/entities/booking-order.entity.ts delete mode 100644 apps/edr-freight-api/src/modules/booking-orders/entities/contract-route-line.entity.ts delete mode 100644 apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts delete mode 100644 apps/edr-freight-api/src/modules/booking-orders/road.util.spec.ts rename apps/edr-freight-api/src/modules/{booking-orders => bookings}/road.util.ts (100%) diff --git a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.controller.ts b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.controller.ts deleted file mode 100644 index b05895407..000000000 --- a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.controller.ts +++ /dev/null @@ -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); - } -} diff --git a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.module.ts b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.module.ts deleted file mode 100644 index aafb04474..000000000 --- a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.module.ts +++ /dev/null @@ -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 {} diff --git a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.repository.ts b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.repository.ts deleted file mode 100644 index c45029b2b..000000000 --- a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.repository.ts +++ /dev/null @@ -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 { - constructor( - @InjectRepository(BookingOrder) - repository: Repository, - ) { - super(repository); - } - - /** Orders placed against a given contract, newest first, with their lines. */ - findByContract(contractBookingId: string): Promise { - return this.repository.find({ - where: { contractBookingId }, - relations: { lines: { containerType: true }, booking: true }, - order: { createdAt: 'DESC' }, - }); - } - - override findById(id: string): Promise { - 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 { - 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(); - } -} diff --git a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.spec.ts b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.spec.ts deleted file mode 100644 index b870037f4..000000000 --- a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.spec.ts +++ /dev/null @@ -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[] = []; - const managerUpdates: Record[] = []; - const fakeManager = { - create: (_entity: unknown, data: Record) => { - created.push(data); - return { id: 'child-1', ...data }; - }, - save: async (row: Record) => ({ id: 'child-1', ...row }), - getRepository: () => ({ - findOne: async () => ({ id: 'child-1', paymentCurrency: 'ETB', bookingContainers: [] }), - update: async (_id: string, data: Record) => { - managerUpdates.push(data); - }, - }), - }; - - const dataSource = { - transaction: async (cb: (m: unknown) => Promise) => 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/); - }); -}); diff --git a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts deleted file mode 100644 index 3c5bc4019..000000000 --- a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts +++ /dev/null @@ -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 { - const orders = await this.ordersRepository.findByContract(contractBookingId); - await Promise.all(orders.map((o) => this.syncOrderFromChild(o))); - return orders; - } - - async findById(id: string): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - const year = new Date().getFullYear(); - const count = await this.bookingsRepository.countByYear(year); - return `BK-${year}-${String(count + 1).padStart(6, '0')}`; - } -} diff --git a/apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts b/apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts deleted file mode 100644 index bfdcc72b8..000000000 --- a/apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts +++ /dev/null @@ -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; -} diff --git a/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.spec.ts b/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.spec.ts deleted file mode 100644 index d7d226855..000000000 --- a/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.spec.ts +++ /dev/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) => - 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); - }); -}); diff --git a/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.ts b/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.ts deleted file mode 100644 index 4e7382ec4..000000000 --- a/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.ts +++ /dev/null @@ -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[]; -} diff --git a/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts b/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts deleted file mode 100644 index d6fff951a..000000000 --- a/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts +++ /dev/null @@ -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; -} diff --git a/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order.entity.ts b/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order.entity.ts deleted file mode 100644 index 610496d79..000000000 --- a/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order.entity.ts +++ /dev/null @@ -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[]; -} diff --git a/apps/edr-freight-api/src/modules/booking-orders/entities/contract-route-line.entity.ts b/apps/edr-freight-api/src/modules/booking-orders/entities/contract-route-line.entity.ts deleted file mode 100644 index e05af758b..000000000 --- a/apps/edr-freight-api/src/modules/booking-orders/entities/contract-route-line.entity.ts +++ /dev/null @@ -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; -} diff --git a/apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts b/apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts deleted file mode 100644 index 58122bd65..000000000 --- a/apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts +++ /dev/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): boolean { - return booking.bookingType === BookingType.GeneralContract; - } - - /** The configured ordering window in months (defaults to 3). */ - async getPeriodMonths(): Promise { - 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 { - 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 { - 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 { - 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> { - 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(); - 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 { - 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 { - const lines = await this.getQuantityLines(contractBookingId); - return lines.every((l) => l.remainingQuantity <= 0); - } -} diff --git a/apps/edr-freight-api/src/modules/booking-orders/road.util.spec.ts b/apps/edr-freight-api/src/modules/booking-orders/road.util.spec.ts deleted file mode 100644 index 10258e3b4..000000000 --- a/apps/edr-freight-api/src/modules/booking-orders/road.util.spec.ts +++ /dev/null @@ -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); - }); - }); -}); diff --git a/apps/edr-freight-api/src/modules/booking-orders/road.util.ts b/apps/edr-freight-api/src/modules/bookings/road.util.ts similarity index 100% rename from apps/edr-freight-api/src/modules/booking-orders/road.util.ts rename to apps/edr-freight-api/src/modules/bookings/road.util.ts