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); } }