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 { ContractQuantityLineView } 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), }, ]; } /** 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 every contracted line is fully drawn down. */ async isExhausted(contractBookingId: string): Promise { const lines = await this.getQuantityLines(contractBookingId); return lines.every((l) => l.remainingQuantity <= 0); } }