import { BaseRepository } from '@edr/api-common'; import { SchedulingStatus } from '@edr/types'; import { ConflictException, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, DeepPartial, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder, } from 'typeorm'; import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; import { Contract } from '../contracts/entities/contract.entity'; import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity'; import { ContractRoute } from '../contracts/entities/contract-route.entity'; import { applyDirectionScope } from '../user-trade-access/trade-scope.util'; import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; import { BookingDocumentReview, DocumentReviewStatus, } from './entities/booking-document-review.entity'; import { BookingContainer } from './entities/booking-container.entity'; import { BookingContainerUnit } from './entities/booking-container-unit.entity'; import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity'; import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity'; import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; import { Booking } from './entities/booking.entity'; import { BookingContractSignature, ContractSignerRole, } from './entities/booking-contract-signature.entity'; import { FileRecord } from '../files/entities/file.entity'; import { ContainerWeightResult } from '../rule-engine/rule-engine.service'; /** A booking is ready for a batch: commercial = signed, government = approved/paid. */ const BATCH_POOL_READY = `((booking.is_government = false AND booking.status = 'FULLY_EXECUTED') OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`; /** * Suspending a contract freezes its bookings, so they drop out of every * scheduling pool. Filtering here (rather than letting the write guard throw) * keeps the batch crons quiet — a frozen contract simply stops being a * candidate until the suspension is lifted. */ const NOT_ON_SUSPENDED_CONTRACT = `(booking.contract_id IS NULL OR NOT EXISTS ( SELECT 1 FROM freight.contracts c WHERE c.id = booking.contract_id AND c.status = 'SUSPENDED' ))`; export interface BookingListFilterOptions { statuses?: string[]; status?: string; schedulingStatuses?: string[]; assignedToSchedule?: 'true' | 'false'; companyId?: string; companyProfileId?: string; contractType?: string; serviceTypeId?: string; cargoTypeId?: string; freightType?: string; bookingType?: string; tradeDirection?: string; /** Per-user trade-direction scope — `[]` matches nothing. */ tradeDirections?: string[]; paymentCurrency?: string; paymentStatus?: string; excludePaymentStatus?: string; customsClearingEnabled?: boolean; createdFrom?: string; createdTo?: string; scheduledFrom?: string; scheduledTo?: string; originYardId?: string; destinationYardId?: string; isGovernment?: 'true' | 'false'; consolidationPaired?: string; } @Injectable() export class BookingsRepository extends BaseRepository { constructor( @InjectRepository(Booking) repository: Repository, private readonly dataSource: DataSource, ) { super(repository); } /** Find a booking by its human-readable reference number. */ findByReference(reference: string): Promise { return this.repository.findOne({ where: { reference } }); } /** * Suspending a contract freezes its bookings too, so the single write path * every booking mutation funnels through is the place to enforce it — one * guard instead of one per transition method. * * The batch/scheduling pools filter suspended contracts out up front * (see {@link excludeSuspendedContract}), so the engine and its crons never * reach a frozen booking and this only ever fires on a user-initiated action. * * ponytail: the seven `manager.getRepository(Booking)` writes inside * train-scheduling transactions bypass this — they only run on bookings the * pool already handed out, which the filter above has excluded. Move them onto * this repository if that ever stops holding. */ private async assertContractNotSuspended(id: string): Promise { const row = await this.repository .createQueryBuilder('booking') .select('contract.status', 'status') .innerJoin(Contract, 'contract', 'contract.id = booking.contract_id') .where('booking.id = :id', { id }) .getRawOne<{ status: string }>(); if (row?.status === 'SUSPENDED') { throw new ConflictException( 'This shipment belongs to a suspended contract. EDR must lift the suspension before it can move.', ); } } override async update( id: string, data: DeepPartial, ): Promise { await this.assertContractNotSuspended(id); return super.update(id, data); } /** * Highest NNNNNN sequence already issued for `BK--…` references. * Includes soft-deleted bookings so the next number clears references that * still occupy the unique index. (A created-at count drifts below the issued * sequence after any delete and then collides forever.) */ async maxReferenceSequence(year: number): Promise { const row = await this.repository .createQueryBuilder('booking') .withDeleted() .select( "COALESCE(MAX(CAST(SUBSTRING(booking.reference FROM '[0-9]+$') AS int)), 0)", 'max', ) .where('booking.reference LIKE :prefix', { prefix: `BK-${year}-%` }) .getRawOne<{ max: string | number | null }>(); return Number(row?.max ?? 0); } /** Find a booking by reference with files and relations. */ async findByReferenceWithFiles(reference: string): Promise { return this.findByIdWithFiles( ( await this.repository.findOne({ where: { reference }, select: ['id'] }) )?.id ?? '', ); } /** Find a booking by ID with files, containers, and config relations. */ async findByIdWithFiles(id: string): Promise { if (!id) return null; const booking = await this.repository .createQueryBuilder('booking') .leftJoinAndSelect('booking.bookingContainers', 'bc') .leftJoinAndSelect('bc.containerType', 'ct') .leftJoinAndSelect('bc.units', 'bcu') .leftJoinAndSelect('booking.company', 'company') // .leftJoinAndSelect('booking.customer', 'customer') .leftJoinAndSelect('booking.train', 'train') .leftJoinAndSelect('booking.serviceType', 'st') .leftJoinAndSelect('booking.cargoType', 'cargo') .leftJoinAndSelect('booking.originYard', 'oy') .leftJoinAndSelect('booking.destinationYard', 'dy') .leftJoinAndSelect('booking.shippingLine', 'sl') .leftJoinAndSelect('booking.rateSnapshots', 'snapshots') .leftJoinAndSelect('booking.cargoModifiers', 'modifiers') .leftJoinAndSelect('booking.reviewNotes', 'reviewNotes') .leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner') .where('booking.id = :id', { id }) .addOrderBy('bcu.sort_order', 'ASC') .leftJoinAndMapMany( 'booking.files', FileRecord, 'file', // Superseded versions are soft-deleted, not dropped — keep them out of // the live file list (a manual join condition is not filtered for us). "file.resource_id = booking.id AND file.resource = 'bookings' AND file.deleted_at IS NULL", ) .getOne(); return booking ?? null; } /** Persist booking container rows with weight rule results. */ async createContainers( bookingId: string, containers: Array<{ containerTypeId: string; quantity: number; vgmPerUnitTons: number; hazardousQuantity?: number; reeferQuantity?: number; containerNumbers?: string[]; weightResult: ContainerWeightResult; }>, ): Promise { const containerRepo = this.dataSource.getRepository(BookingContainer); const unitRepo = this.dataSource.getRepository(BookingContainerUnit); const typeRepo = this.dataSource.getRepository(ContainerType); const saved: BookingContainer[] = []; for (const item of containers) { const ct = await typeRepo.findOne({ where: { id: item.containerTypeId } }); const wagonsPerUnit = wagonsPerUnitForSize(ct?.sizeFt); const totalVgm = item.quantity * item.vgmPerUnitTons; const wagonsRequired = Math.ceil(item.quantity * wagonsPerUnit); // A per-line breakdown can never exceed the line's own quantity. const clamp = (v?: number) => Math.max(0, Math.min(item.quantity, Math.floor(Number(v ?? 0)) || 0)); const row = containerRepo.create({ bookingId, containerTypeId: item.containerTypeId, quantity: item.quantity, hazardousQuantity: clamp(item.hazardousQuantity), reeferQuantity: clamp(item.reeferQuantity), vgmPerUnitTons: item.vgmPerUnitTons, totalVgmTons: totalVgm, wagonsRequired, weightLimitRuleId: item.weightResult.weightLimitRuleId, isOverweight: item.weightResult.isOverweight, overweightExcessTons: item.weightResult.overweightExcessTons, }); const savedRow = await containerRepo.save(row); saved.push(savedRow); // Physical container numbers, one unit row each (capped to the line // quantity; blanks skipped). Optional — units can also be entered later. const numbers = (item.containerNumbers ?? []) .map((n) => n.trim()) .filter(Boolean) .slice(0, item.quantity); let sortOrder = 0; for (const containerNumber of numbers) { await unitRepo.save( unitRepo.create({ bookingContainerId: savedRow.id, containerNumber, vgmTons: item.vgmPerUnitTons, sortOrder: sortOrder++, }), ); } } return saved; } /** SQL aggregate wagon count for a booking. */ async calculateWagonCount(bookingId: string): Promise { const result = await this.dataSource .createQueryBuilder() .select( 'CEILING(SUM(bc.quantity * CASE WHEN ct.size_ft >= 40 THEN 1 WHEN ct.size_ft > 0 THEN 0.5 ELSE 1 END))', 'total', ) .from(BookingContainer, 'bc') .innerJoin(ContainerType, 'ct', 'ct.id = bc.container_type_id') .where('bc.booking_id = :bookingId', { bookingId }) .getRawOne<{ total: string }>(); return Number(result?.total ?? 0); } /** * The road billing distance (km) of a booking's contract route, used to price * per-km first/last-mile trucking. Returns 0 when there is no route or no km * recorded (rail-only lanes) so a PER_KM rate bills nothing. */ async getContractRouteKm(contractRouteId: string | null | undefined): Promise { if (!contractRouteId) return 0; const route = await this.dataSource .getRepository(ContractRoute) .findOne({ where: { id: contractRouteId }, select: { id: true, km: true } }); return Number(route?.km ?? 0); } /** * Frozen contract unit-rate snapshots for a contract (H15). A booking created * under a contract prices from these agreed, frozen rates rather than the live * rate of the day; the pricing service matches them by rate code. */ findContractRateSnapshots( contractId: string, ): Promise { return this.dataSource .getRepository(ContractRateSnapshot) .find({ where: { contractId } }); } /** * Find another booking whose container quantity complements this one to fill whole wagon(s) * (same route, same container type, partial wagon on both sides). Only 20ft lines ever * reach here — 40ft has perWagon=1 so `quantity % 1 == 0` is never partial. * * Partners must also ride the SAME booking day: consolidation shares one physical wagon, * and the window/batch pool is keyed on the EAT departure day, so a pair that can't board * the same train is useless. The day filter is applied only when THIS booking already has * a scheduled_date (draft bookings without a date match on route/type alone until they pick one). */ async findComplementaryConsolidationPartner( booking: Booking, slot: { containerTypeId: string; quantity: number; containersPerWagon: number; }, manager?: EntityManager, ): Promise { const { containerTypeId, quantity, containersPerWagon: perWagon } = slot; const repo = manager ? manager.getRepository(Booking) : this.repository; const qb = repo .createQueryBuilder('b') .innerJoinAndSelect('b.bookingContainers', 'bc') .innerJoin('bc.containerType', 'ct') .where('b.id != :bookingId', { bookingId: booking.id }) .andWhere('b.consolidationPartnerId IS NULL') // Only pair bookings the customer has committed (SUBMITTED) or that are // already waiting (PENDING_CONSOLIDATION). DRAFT bookings are excluded so // pairing never prematurely submits an unfinished/unpriced draft. .andWhere('b.status IN (:...statuses)', { statuses: ['SUBMITTED', 'PENDING_CONSOLIDATION'], }) .andWhere('b.originYardId = :originYardId', { originYardId: booking.originYardId, }) .andWhere('b.destinationYardId = :destinationYardId', { destinationYardId: booking.destinationYardId, }) .andWhere('b.tradeDirection = :tradeDirection', { tradeDirection: booking.tradeDirection, }) .andWhere('bc.containerTypeId = :containerTypeId', { containerTypeId }) .andWhere('(bc.quantity % :perWagon) > 0', { perWagon }) .andWhere('((:quantity + bc.quantity) % :perWagon) = 0', { quantity, perWagon, }); // Same EAT booking day, so the pair can share a wagon on one train. Skip only // when this booking has no date yet (matched again once it picks its day). if (booking.scheduledDate) { qb.andWhere( `DATE(b.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = DATE(:bookingDate AT TIME ZONE 'Africa/Addis_Ababa')`, { bookingDate: booking.scheduledDate }, ); } qb.orderBy('b.createdAt', 'ASC'); // H9: under the caller's transaction, take a write lock on the matched // partner booking row (FOR UPDATE OF b — booking rows only, not the joined // reference tables) so a concurrent consolidation cannot claim the same // partner between this find and the pair write. Only when a transaction // manager is supplied — a pessimistic lock requires an open transaction. if (manager) { qb.setLock('pessimistic_write', undefined, ['b']); } return qb.getOne(); } /** Try each partial-wagon line until a complementary partner booking is found. */ async findConsolidationPartner( booking: Booking, slots: Array<{ containerTypeId: string; quantity: number; containersPerWagon: number; }>, manager?: EntityManager, ): Promise { for (const slot of slots) { const partner = await this.findComplementaryConsolidationPartner( booking, slot, manager, ); if (partner) return partner; } return null; } /** * Pair two bookings for consolidation. Each returns to its own resume status — * SUBMITTED for a direct customer booking (so staff can accept it into the * approval chain) or the stored consolidationResumeStatus for a contract * drawdown (OPERATION_REQUEST_PENDING / AWAITING_DOCUMENTS). The link itself * (consolidationPartnerId) marks them as consolidated in the UI. The resume * status is cleared once used, so a later un-pair re-parks cleanly. */ async pairConsolidation(bookingId: string, partnerId: string): Promise { const [booking, partner] = await Promise.all([ this.repository.findOne({ where: { id: bookingId }, select: { id: true, consolidationResumeStatus: true }, }), this.repository.findOne({ where: { id: partnerId }, select: { id: true, consolidationResumeStatus: true }, }), ]); await this.repository.update(bookingId, { consolidationPartnerId: partnerId, status: booking?.consolidationResumeStatus ?? 'SUBMITTED', consolidationResumeStatus: null, } as never); await this.repository.update(partnerId, { consolidationPartnerId: bookingId, status: partner?.consolidationResumeStatus ?? 'SUBMITTED', consolidationResumeStatus: null, } as never); } /** * Race-safe pairing (H9): the transactional counterpart of * {@link pairConsolidation}. Must run inside the caller's transaction * (`manager`), which should already hold the partner-row write lock taken by * {@link findComplementaryConsolidationPartner}. Re-reads both rows and * re-asserts `consolidationPartnerId IS NULL` on each before writing; returns * `false` (no write) when either booking was already paired by a concurrent * flow, so the caller can fall back to parking. */ async pairConsolidationIfUnpaired( bookingId: string, partnerId: string, manager: EntityManager, ): Promise { const repo = manager.getRepository(Booking); // Sequential (one connection per transaction) — never Promise.all here. const booking = await repo.findOne({ where: { id: bookingId }, select: { id: true, consolidationPartnerId: true, consolidationResumeStatus: true, }, }); const partner = await repo.findOne({ where: { id: partnerId }, select: { id: true, consolidationPartnerId: true, consolidationResumeStatus: true, }, }); // Re-assert both are still unpaired before writing (the partner row is held // under the finder's write lock, so its state is stable here). if ( !booking || !partner || booking.consolidationPartnerId != null || partner.consolidationPartnerId != null ) { return false; } await repo.update(bookingId, { consolidationPartnerId: partnerId, status: booking.consolidationResumeStatus ?? 'SUBMITTED', consolidationResumeStatus: null, } as never); await repo.update(partnerId, { consolidationPartnerId: bookingId, status: partner.consolidationResumeStatus ?? 'SUBMITTED', consolidationResumeStatus: null, } as never); return true; } /** * Park a booking that needs consolidation but has no partner yet. The optional * resumeStatus is where the booking returns once it pairs — pass it for a * contract drawdown so pairing resumes the contract-booking flow rather than * the direct-booking SUBMITTED default. */ async parkForConsolidation( bookingId: string, resumeStatus?: string | null, ): Promise { await this.repository.update(bookingId, { consolidationPartnerId: null, status: 'PENDING_CONSOLIDATION', consolidationResumeStatus: resumeStatus ?? null, } as never); } /** Un-pair a consolidation. */ async unpairConsolidation(bookingId: string, partnerId: string): Promise { await this.repository.update(bookingId, { consolidationPartnerId: null, status: 'PENDING_CONSOLIDATION', } as never); await this.repository.update(partnerId, { consolidationPartnerId: null, status: 'PENDING_CONSOLIDATION', } as never); } /** Delete all containers for a booking (used on draft update). */ async deleteContainers(bookingId: string): Promise { await this.dataSource.getRepository(BookingContainer).delete({ bookingId }); } // ── Clearance document reviews ──────────────────────────────────────────── findDocumentReviews(bookingId: string): Promise { return this.dataSource.getRepository(BookingDocumentReview).find({ where: { bookingId }, order: { createdAt: 'ASC' }, }); } findDocumentReview( bookingId: string, settingCode: string, fileKey: string, ): Promise { return this.dataSource.getRepository(BookingDocumentReview).findOne({ where: { bookingId, settingCode, fileKey }, }); } /** * Upsert a document-review row to PENDING for a freshly uploaded file. Resets * any prior QUERIED/APPROVED state so the GL re-reviews the new upload. */ async upsertDocumentReviewPending(input: { bookingId: string; settingCode: string; fileKey: string; fileRecordId: string; }): Promise { const repo = this.dataSource.getRepository(BookingDocumentReview); const existing = await repo.findOne({ where: { bookingId: input.bookingId, settingCode: input.settingCode, fileKey: input.fileKey, }, }); if (existing) { await repo.update(existing.id, { fileRecordId: input.fileRecordId, status: 'PENDING', note: null, reviewedByStaffId: null, reviewedAt: null, }); return; } await repo.save(repo.create({ ...input, status: 'PENDING' })); } /** GL marks a document APPROVED or QUERIED (with an optional note). */ async setDocumentReviewStatus( bookingId: string, settingCode: string, fileKey: string, status: DocumentReviewStatus, staffId: string, note?: string, ): Promise { const repo = this.dataSource.getRepository(BookingDocumentReview); const existing = await repo.findOne({ where: { bookingId, settingCode, fileKey }, }); const patch = { status, note: note ?? null, reviewedByStaffId: staffId, reviewedAt: new Date(), }; if (existing) { await repo.update(existing.id, patch); return; } await repo.save(repo.create({ bookingId, settingCode, fileKey, ...patch })); } /** Persist cargo modifiers linked to rate snapshots. */ async createCargoModifiers( rows: Array<{ bookingId: string; rateId: string; triggerValue: number | null; calculatedAmount: number; rateSnapshotId: string; }>, ): Promise { const repo = this.dataSource.getRepository(BookingCargoModifier); const saved: BookingCargoModifier[] = []; for (const row of rows) { saved.push(await repo.save(repo.create(row))); } return saved; } /** Find rate snapshot by rate id for a booking. */ async findRateSnapshotByRateId( bookingId: string, rateId: string, ): Promise { return this.dataSource.getRepository(BookingRateSnapshot).findOne({ where: { bookingId, rateId }, }); } async createReviewNote( bookingId: string, note: string, type: ReviewNoteType, authorId?: string, ): Promise { const repo = this.dataSource.getRepository(BookingReviewNote); return repo.save( repo.create({ bookingId, note, type, authorId: authorId ?? null }), ); } /** Review notes of one type, newest first — the duty advice/dispute rounds. */ async findReviewNotes( bookingId: string, type: ReviewNoteType, ): Promise { return this.dataSource.getRepository(BookingReviewNote).find({ where: { bookingId, type }, order: { createdAt: 'DESC' }, }); } async findLatestReviewNote( bookingId: string, type?: ReviewNoteType, ): Promise { const repo = this.dataSource.getRepository(BookingReviewNote); return repo.findOne({ where: type ? { bookingId, type } : { bookingId }, order: { createdAt: 'DESC' }, }); } async clearPricingArtifacts(bookingId: string): Promise { await this.dataSource.getRepository(BookingCargoModifier).delete({ bookingId }); await this.dataSource.getRepository(BookingRateSnapshot).delete({ bookingId }); } async hasPricingArtifacts(bookingId: string): Promise { const snapshotCount = await this.dataSource .getRepository(BookingRateSnapshot) .count({ where: { bookingId } }); const modifierCount = await this.dataSource .getRepository(BookingCargoModifier) .count({ where: { bookingId } }); return snapshotCount > 0 || modifierCount > 0; } async invalidatePricingPreview(bookingId: string): Promise { if (await this.hasPricingArtifacts(bookingId)) { await this.clearPricingArtifacts(bookingId); } await this.update(bookingId, { totalAmount: 0, pricingBreakdown: null, } as never); } /** Bookings in any of the given statuses (clearance queue helpers). */ async findByStatuses(statuses: string[]): Promise { if (!statuses.length) return []; return this.repository.find({ where: { status: In(statuses) }, relations: { company: true, originYard: true, destinationYard: true, }, order: { createdAt: 'DESC' }, }); } /** Queue listing with optional bulk exclusion for LINE_STAFF. */ async findQueue(options: { status: string | string[]; page?: number; pageSize?: number; excludeBulk?: boolean; sortBy?: string; sortOrder?: 'ASC' | 'DESC'; }): Promise<{ items: Booking[]; total: number }> { const page = options.page ?? 1; const pageSize = options.pageSize ?? 20; const statuses = Array.isArray(options.status) ? options.status : [options.status]; const qb = this.repository .createQueryBuilder('booking') .leftJoinAndSelect('booking.company', 'company') .leftJoinAndSelect('booking.originYard', 'originYard') .leftJoinAndSelect('booking.destinationYard', 'destinationYard') .leftJoinAndSelect('booking.cargoType', 'cargo') .leftJoinAndSelect('booking.serviceType', 'serviceType') .where('booking.status IN (:...statuses)', { statuses }); if (options.excludeBulk) { qb.andWhere("booking.freight_type = 'CONTAINER'"); } const sortField = options.sortBy === 'priorityScore' ? 'booking.priorityScore' : 'booking.createdAt'; qb.orderBy(sortField, options.sortOrder ?? 'DESC'); const [items, total] = await qb .skip((page - 1) * pageSize) .take(pageSize) .getManyAndCount(); return { items, total }; } /** Paginated list with optional multi-status filter (API tab queues). */ async findAllPaginated(options: BookingListFilterOptions & { page: number; pageSize: number; search?: string; sortBy?: string; sortOrder?: 'ASC' | 'DESC'; }): Promise<{ items: Booking[]; total: number; meta: { page: number; pageSize: number; total: number; totalPages: number; hasNextPage: boolean; hasPreviousPage: boolean; }; }> { const page = options.page; const pageSize = options.pageSize; const qb = this.repository .createQueryBuilder('booking') .leftJoinAndSelect('booking.company', 'company') .leftJoinAndSelect('booking.originYard', 'originYard') .leftJoinAndSelect('booking.destinationYard', 'destinationYard') .leftJoinAndSelect('booking.serviceType', 'serviceType') .leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner') // Contract reference for the list column + search (no entity relation on // Booking → contract, so join the entity by id and select just the // reference — a schema-qualified table string is parsed as alias.relation // by TypeORM and crashes). .leftJoin(Contract, 'contract', 'contract.id = booking.contract_id') .addSelect('contract.reference', 'contract_reference') .where('booking.deleted_at IS NULL'); this.applyListFilters(qb, options); // Free-text search spans joined columns (company, contract) that only this // list query joins — so it lives here, not in applyListFilters (shared // with getListSummaryMetrics, whose query builder has no joins). if (options.search) { qb.andWhere( '(booking.reference ILIKE :search OR company.name ILIKE :search OR contract.reference ILIKE :search)', { search: `%${options.search}%` }, ); } if (options.sortBy === 'isGovernment') { qb.orderBy('booking.isGovernment', 'DESC') .addOrderBy('booking.priorityScore', 'DESC') .addOrderBy('booking.scheduledDate', 'ASC'); } else { const sortField = options.sortBy === 'priorityScore' ? 'booking.priorityScore' : options.sortBy === 'scheduledDate' ? 'booking.scheduledDate' : 'booking.createdAt'; qb.orderBy(sortField, options.sortOrder ?? 'DESC'); } const total = await qb.getCount(); const { entities: items, raw } = await qb .skip((page - 1) * pageSize) .take(pageSize) .getRawAndEntities(); // The joined contract.reference comes back on the raw rows only (entity has no // contract relation) — map it onto each booking by position. const contractRefByBooking = new Map(); for (const row of raw as Array<{ booking_id: string; contract_reference: string | null }>) { if (row.booking_id && !contractRefByBooking.has(row.booking_id)) { contractRefByBooking.set(row.booking_id, row.contract_reference ?? null); } } for (const item of items) { (item as Booking & { contractReference?: string | null }).contractReference = contractRefByBooking.get(item.id) ?? null; } if (items.length) { const links = await this.dataSource.getRepository(TrainScheduleBooking).find({ where: { bookingId: In(items.map((item) => item.id)) }, select: { bookingId: true, trainScheduleId: true }, }); const scheduleByBooking = new Map(links.map((link) => [link.bookingId, link.trainScheduleId])); for (const item of items) { (item as Booking & { trainScheduleId?: string | null }).trainScheduleId = scheduleByBooking.get(item.id) ?? null; } } const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 0; // Return both the flat `total` (consumed by the backoffice list) and a // `meta` block (consumed by the portal, matching PaginationMeta) so neither // app needs to change its read shape. return { items, total, meta: { page, pageSize, total, totalPages, hasNextPage: page < totalPages, hasPreviousPage: page > 1, }, }; } async getStatusCounts(): Promise> { const rows = await this.repository .createQueryBuilder('booking') .select('booking.status', 'status') .addSelect('COUNT(*)::int', 'count') .where('booking.deleted_at IS NULL') .groupBy('booking.status') .getRawMany<{ status: string; count: string }>(); return Object.fromEntries( rows.map((row) => [row.status, Number(row.count)]), ); } async getListSummaryMetrics( options: BookingListFilterOptions & { page: number; pageSize: number; needsActionStatuses: readonly string[]; urgentPriorityThreshold: number; }, ): Promise<{ inQueue: number; onThisPage: number; needsAction: number; urgent: number; }> { const baseQb = () => { const qb = this.repository .createQueryBuilder('booking') .where('booking.deleted_at IS NULL'); this.applyListFilters(qb, options); return qb; }; const inQueue = await baseQb().getCount(); const needsAction = await baseQb() .andWhere('booking.status IN (:...needsActionStatuses)', { needsActionStatuses: [...options.needsActionStatuses], }) .getCount(); const urgent = await baseQb() .andWhere('booking.priority_score >= :urgentPriorityThreshold', { urgentPriorityThreshold: options.urgentPriorityThreshold, }) .getCount(); const offset = (options.page - 1) * options.pageSize; const onThisPage = Math.min( options.pageSize, Math.max(0, inQueue - offset), ); return { inQueue, onThisPage, needsAction, urgent }; } private applyListFilters( qb: SelectQueryBuilder, options: BookingListFilterOptions, ): void { if (options.statuses?.length) { qb.andWhere('booking.status IN (:...statuses)', { statuses: options.statuses, }); } else if (options.status) { qb.andWhere('booking.status = :status', { status: options.status }); } if (options.companyId) { qb.andWhere('booking.company_id = :companyId', { companyId: options.companyId, }); } if (options.companyProfileId) { qb.andWhere('booking.company_profile_id = :companyProfileId', { companyProfileId: options.companyProfileId, }); } if (options.contractType) { qb.andWhere('booking.contract_type = :contractType', { contractType: options.contractType, }); } if (options.serviceTypeId) { qb.andWhere('booking.service_type_id = :serviceTypeId', { serviceTypeId: options.serviceTypeId, }); } if (options.cargoTypeId) { qb.andWhere('booking.cargo_type_id = :cargoTypeId', { cargoTypeId: options.cargoTypeId, }); } if (options.freightType) { qb.andWhere('booking.freight_type = :freightType', { freightType: options.freightType, }); } if (options.bookingType) { // The stored booking_type column is 'ONE_TIME' for every row (contract // drawdowns included — see contract-booking.service create), so the // one-time vs general split keys on the denormalized contract_kind: // GENERAL_CONTRACT tab = bookings under a GENERAL contract, ONE_TIME tab // = everything else (ONE_TIME contracts and legacy contract-less rows). if (options.bookingType === 'GENERAL_CONTRACT') { qb.andWhere("booking.contract_kind = 'GENERAL'"); } else { qb.andWhere("booking.contract_kind IS DISTINCT FROM 'GENERAL'"); } } if (options.createdFrom) { qb.andWhere('booking.created_at >= :createdFrom', { createdFrom: options.createdFrom, }); } if (options.createdTo) { // Inclusive end-of-day: callers pass a date; include the whole day. qb.andWhere('booking.created_at <= :createdTo', { createdTo: options.createdTo, }); } if (options.scheduledFrom) { qb.andWhere('booking.scheduled_date >= :scheduledFrom', { scheduledFrom: options.scheduledFrom, }); } if (options.scheduledTo) { // Inclusive end-of-day: callers pass a date; include the whole day. qb.andWhere('booking.scheduled_date <= :scheduledTo', { scheduledTo: options.scheduledTo, }); } if (options.originYardId) { qb.andWhere('booking.origin_yard_id = :originYardId', { originYardId: options.originYardId, }); } if (options.destinationYardId) { qb.andWhere('booking.destination_yard_id = :destinationYardId', { destinationYardId: options.destinationYardId, }); } if (options.isGovernment === 'true') { qb.andWhere('booking.is_government = TRUE'); } else if (options.isGovernment === 'false') { qb.andWhere('booking.is_government = FALSE'); } if (options.tradeDirection) { qb.andWhere('booking.trade_direction = :tradeDirection', { tradeDirection: options.tradeDirection, }); } if (options.tradeDirections) { applyDirectionScope(qb, 'booking.trade_direction', options.tradeDirections); } if (options.paymentCurrency) { qb.andWhere('booking.payment_currency = :paymentCurrency', { paymentCurrency: options.paymentCurrency, }); } if (options.paymentStatus) { qb.andWhere('booking.payment_status = :paymentStatus', { paymentStatus: options.paymentStatus, }); } if (options.excludePaymentStatus) { qb.andWhere('booking.payment_status != :excludePaymentStatus', { excludePaymentStatus: options.excludePaymentStatus, }); } if (options.customsClearingEnabled !== undefined) { qb.andWhere('booking.customs_clearing_enabled = :customsClearingEnabled', { customsClearingEnabled: options.customsClearingEnabled, }); } if (options.consolidationPaired === 'true') { qb.andWhere('booking.consolidation_partner_id IS NOT NULL'); } else if (options.consolidationPaired === 'false') { qb.andWhere('booking.consolidation_partner_id IS NULL'); } if (options.schedulingStatuses?.length) { qb.andWhere('booking.scheduling_status IN (:...schedulingStatuses)', { schedulingStatuses: options.schedulingStatuses, }); } if (options.assignedToSchedule === 'true') { qb.andWhere( `EXISTS ( SELECT 1 FROM freight.train_schedule_bookings tsb WHERE tsb.booking_id = booking.id AND tsb.deleted_at IS NULL )`, ); } else if (options.assignedToSchedule === 'false') { qb.andWhere( `NOT EXISTS ( SELECT 1 FROM freight.train_schedule_bookings tsb WHERE tsb.booking_id = booking.id AND tsb.deleted_at IS NULL )`, ); } } async findAndCountFiltered(where: FindOptionsWhere, options: { skip: number; take: number; order: Record; }): Promise<[Booking[], number]> { return this.repository.findAndCount({ where, skip: options.skip, take: options.take, order: options.order, }); } findContractSignatures(bookingId: string): Promise { return this.dataSource.getRepository(BookingContractSignature).find({ where: { bookingId }, relations: ['signatureFile'], order: { signedAt: 'ASC' }, }); } findContractSignature( bookingId: string, role: ContractSignerRole, ): Promise { return this.dataSource.getRepository(BookingContractSignature).findOne({ where: { bookingId, signerRole: role }, relations: ['signatureFile'], }); } async saveContractSignature( data: Partial, ): Promise { const repo = this.dataSource.getRepository(BookingContractSignature); const existing = await repo.findOne({ where: { bookingId: data.bookingId!, signerRole: data.signerRole!, }, }); if (existing) { Object.assign(existing, data); return repo.save(existing); } return repo.save(repo.create(data)); } private bookingRepo(manager?: EntityManager) { return manager ? manager.getRepository(Booking) : this.repository; } findEligibleForScheduling(options: { freightType?: string; originStationId?: string; destinationStationId?: string; schedulingStatus?: string; trainScheduleId?: string; /** * EAT calendar day (yyyy-MM-dd). With day-level pooling the staff wizard sees * the whole (route, day) pool rather than bookings pre-targeted to one train. */ day?: string; /** * The schedule's ordered route stops. When given, the corridor filter * replaces the exact origin/destination match: any booking whose BOTH yards * lie on the route qualifies (sub-corridor bookings like Dire→DCT on a * GMT→Dire→DCT train — the caller still checks stop ORDER). Dateless * DOMESTIC (intercity) bookings also join the pool: they ride any train on * their corridor. */ corridorYardIds?: string[]; }): Promise { const qb = this.repository .createQueryBuilder('booking') .leftJoinAndSelect('booking.company', 'company') .leftJoinAndSelect('booking.originYard', 'originYard') .leftJoinAndSelect('booking.destinationYard', 'destinationYard') .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') .leftJoinAndSelect('bookingContainer.containerType', 'containerType') .leftJoinAndSelect('booking.cargoType', 'cargoType') .leftJoin( TrainScheduleBooking, 'scheduleBooking', 'scheduleBooking.booking_id = booking.id', ) .where('booking.status = :paidStatus', { paidStatus: 'PAID' }) .andWhere('scheduleBooking.id IS NULL') .andWhere(NOT_ON_SUSPENDED_CONTRACT); // Day-level pooling: customers no longer set train_schedule_id, so the wizard // surfaces the whole (route, EAT day) pool. Fall back to the legacy // single-schedule filter only when no day is supplied (e.g. a staff-pinned // booking that still carries train_schedule_id). if (options.day) { // Dateless DOMESTIC (intercity) bookings ride any train on their corridor // — no scheduled_date to match, so the day filter must not hide them. qb.andWhere( `(DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day OR (booking.trade_direction = 'DOMESTIC' AND booking.scheduled_date IS NULL))`, { day: options.day }, ); } else if (options.trainScheduleId) { qb.andWhere('booking.train_schedule_id = :trainScheduleId', { trainScheduleId: options.trainScheduleId, }); } if (options.freightType) { qb.andWhere('booking.freightType = :freightType', { freightType: options.freightType }); } if (options.corridorYardIds?.length) { qb.andWhere('booking.originYardId IN (:...corridorYardIds)', { corridorYardIds: options.corridorYardIds, }).andWhere('booking.destinationYardId IN (:...corridorYardIds)', { corridorYardIds: options.corridorYardIds, }); } else { if (options.originStationId) { qb.andWhere('booking.originYardId = :originStationId', { originStationId: options.originStationId, }); } if (options.destinationStationId) { qb.andWhere('booking.destinationYardId = :destinationStationId', { destinationStationId: options.destinationStationId, }); } } if (options.schedulingStatus) { qb.andWhere('booking.scheduling_status = :schedulingStatus', { schedulingStatus: options.schedulingStatus, }); } return qb .orderBy('booking.priority_score', 'DESC') .addOrderBy('booking.scheduled_date', 'ASC') .addOrderBy('booking.created_at', 'ASC') .getMany(); } /** * Ready, not-yet-allocated bookings targeting a schedule (the batch pool). * Commercial = FULLY_EXECUTED; government = APPROVED or PAID (skips contract). * Ordered government → priority → contract-sign time. */ findBatchPool(scheduleId: string): Promise { return this.repository .createQueryBuilder('booking') .leftJoinAndSelect('booking.company', 'company') .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') .leftJoinAndSelect('bookingContainer.containerType', 'containerType') .leftJoinAndSelect('booking.cargoType', 'cargoType') .leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id') .where('booking.train_schedule_id = :scheduleId', { scheduleId }) .andWhere('sb.id IS NULL') .andWhere(BATCH_POOL_READY) .andWhere(NOT_ON_SUSPENDED_CONTRACT) .orderBy('booking.is_government', 'DESC') .addOrderBy('booking.priority_score', 'DESC') .addOrderBy('booking.fully_executed_at', 'ASC') .addOrderBy('booking.created_at', 'ASC') .getMany(); } /** * Day-level batch pool: ready, not-yet-allocated bookings on a route for one * EAT calendar day, regardless of which train they end up on. Same status * rules and ordering as {@link findBatchPool}, but keyed on * (origin, destination, day) instead of train_schedule_id — the engine then * distributes these across all trains departing that day. */ findBatchPoolByRouteDay( originYardId: string, destinationYardId: string, day: string, ): Promise { return this.repository .createQueryBuilder('booking') .leftJoinAndSelect('booking.company', 'company') .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') .leftJoinAndSelect('bookingContainer.containerType', 'containerType') .leftJoinAndSelect('booking.cargoType', 'cargoType') .leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id') .where('booking.origin_yard_id = :originYardId', { originYardId }) .andWhere('booking.destination_yard_id = :destinationYardId', { destinationYardId, }) .andWhere( `DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`, { day }, ) .andWhere('sb.id IS NULL') .andWhere(BATCH_POOL_READY) .andWhere(NOT_ON_SUSPENDED_CONTRACT) .orderBy('booking.is_government', 'DESC') .addOrderBy('booking.priority_score', 'DESC') .addOrderBy('booking.fully_executed_at', 'ASC') .addOrderBy('booking.created_at', 'ASC') .getMany(); } /** * Corridor day pool: ready, not-yet-allocated bookings for one EAT day whose * origin AND destination both lie on the day's corridor stop set — covers * full-route bookings and sub-corridor bookings (Dire→Djibouti on an * Addis→…→Djibouti train). The caller still verifies stop ORDER per train * via the corridor budget; this query only narrows the pool. Same status * rules and ordering as {@link findBatchPool}. */ findBatchPoolByCorridorDay( corridorYardIds: string[], day: string, ): Promise { if (corridorYardIds.length === 0) return Promise.resolve([]); return this.repository .createQueryBuilder('booking') .leftJoinAndSelect('booking.company', 'company') .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') .leftJoinAndSelect('bookingContainer.containerType', 'containerType') .leftJoinAndSelect('booking.cargoType', 'cargoType') .leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id') .where('booking.origin_yard_id IN (:...corridorYardIds)', { corridorYardIds }) .andWhere('booking.destination_yard_id IN (:...corridorYardIds)', { corridorYardIds, }) .andWhere( `DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`, { day }, ) .andWhere('sb.id IS NULL') .andWhere(BATCH_POOL_READY) .andWhere(NOT_ON_SUSPENDED_CONTRACT) .orderBy('booking.is_government', 'DESC') .addOrderBy('booking.priority_score', 'DESC') .addOrderBy('booking.fully_executed_at', 'ASC') .addOrderBy('booking.created_at', 'ASC') .getMany(); } /** * EXPIRED bookings on the day's corridor — the batch board's expired lane. * Expiry nulls train_schedule_id, so neither findAllBySchedule nor the * ready-pool query can ever see them. */ findExpiredByCorridorDay( corridorYardIds: string[], day: string, ): Promise { if (corridorYardIds.length === 0) return Promise.resolve([]); return this.repository .createQueryBuilder('booking') .leftJoinAndSelect('booking.company', 'company') .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') .leftJoinAndSelect('bookingContainer.containerType', 'containerType') .leftJoinAndSelect('booking.cargoType', 'cargoType') .leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id') .where('booking.origin_yard_id IN (:...corridorYardIds)', { corridorYardIds }) .andWhere('booking.destination_yard_id IN (:...corridorYardIds)', { corridorYardIds, }) .andWhere( `DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`, { day }, ) .andWhere('sb.id IS NULL') .andWhere(`booking.status = 'EXPIRED'`) .orderBy('booking.priority_score', 'DESC') .addOrderBy('booking.created_at', 'ASC') .getMany(); } /** * Commercial bookings on the day's corridor whose operation request was NOT * accepted by staff (still pending / changes / price-confirm) and are not yet * linked to a train. These never reached FULLY_EXECUTED, so they never enter the * batch pool; the window's doc-review end sweeps them to EXPIRED. Government * bookings are excluded (they don't go through the customer window). */ findUnacceptedForRouteDay( corridorYardIds: string[], day: string, ): Promise { if (corridorYardIds.length === 0) return Promise.resolve([]); return this.repository .createQueryBuilder('booking') .leftJoinAndSelect('booking.company', 'company') .leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id') .where('booking.origin_yard_id IN (:...corridorYardIds)', { corridorYardIds }) .andWhere('booking.destination_yard_id IN (:...corridorYardIds)', { corridorYardIds, }) .andWhere( `DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`, { day }, ) .andWhere('sb.id IS NULL') .andWhere('booking.is_government = false') .andWhere( `booking.status IN ( 'OPERATION_REQUESTED', 'OPERATION_REQUEST_PENDING', 'OPERATION_CHANGES_REQUESTED', 'OPERATION_PRICE_PENDING_CONFIRM' )`, ) .getMany(); } /** Every booking that targeted a schedule (any status) — for the batch monitoring board. */ findAllBySchedule(scheduleId: string): Promise { return this.repository .createQueryBuilder('booking') .leftJoinAndSelect('booking.company', 'company') .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') .leftJoinAndSelect('bookingContainer.containerType', 'containerType') .leftJoinAndSelect('booking.cargoType', 'cargoType') .where('booking.train_schedule_id = :scheduleId', { scheduleId }) .orderBy('booking.is_government', 'DESC') .addOrderBy('booking.priority_score', 'DESC') .addOrderBy('booking.created_at', 'ASC') .getMany(); } /** Same as {@link findAllBySchedule} but for a page of schedules at once — * one query instead of one per schedule (batch monitoring board). */ findAllBySchedules(scheduleIds: string[]): Promise { if (!scheduleIds.length) return Promise.resolve([]); return this.repository .createQueryBuilder('booking') .leftJoinAndSelect('booking.company', 'company') .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') .leftJoinAndSelect('bookingContainer.containerType', 'containerType') .leftJoinAndSelect('booking.cargoType', 'cargoType') .where('booking.train_schedule_id IN (:...scheduleIds)', { scheduleIds }) .orderBy('booking.is_government', 'DESC') .addOrderBy('booking.priority_score', 'DESC') .addOrderBy('booking.created_at', 'ASC') .getMany(); } /** Bookings currently reserved (SELECTED_FOR_BATCH) against a schedule. */ findReservedForSchedule(scheduleId: string): Promise { return this.repository .createQueryBuilder('booking') .leftJoinAndSelect('booking.company', 'company') .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') .leftJoinAndSelect('bookingContainer.containerType', 'containerType') .leftJoinAndSelect('booking.cargoType', 'cargoType') .where('booking.train_schedule_id = :scheduleId', { scheduleId }) .andWhere(`booking.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`) .getMany(); } /** PAID bookings targeting a schedule that have no train_schedule_bookings link yet. */ findPaidUnlinkedForSchedule(scheduleId: string): Promise { return this.repository .createQueryBuilder('booking') .leftJoinAndSelect('booking.company', 'company') .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') .leftJoin( TrainScheduleBooking, 'scheduleBooking', 'scheduleBooking.booking_id = booking.id', ) .where('booking.train_schedule_id = :scheduleId', { scheduleId }) .andWhere(`booking.status = 'PAID'`) .andWhere('scheduleBooking.id IS NULL') .orderBy('booking.priority_score', 'DESC') .addOrderBy('booking.created_at', 'ASC') .getMany(); } /** Commercial bookings already allocated to a schedule, lowest-priority first (for government preempt). */ findAllocatedCommercialForSchedule(scheduleId: string): Promise { return this.repository .createQueryBuilder('booking') .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') .leftJoinAndSelect('bookingContainer.containerType', 'containerType') .leftJoinAndSelect('booking.cargoType', 'cargoType') .innerJoin( TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id AND sb.train_schedule_id = :scheduleId', { scheduleId }, ) .where('booking.is_government = false') .orderBy('booking.priority_score', 'ASC') .addOrderBy('booking.created_at', 'DESC') .getMany(); } findByIdsForScheduling(bookingIds: string[], manager?: EntityManager): Promise { if (!bookingIds.length) return Promise.resolve([]); return this.bookingRepo(manager).find({ where: { id: In(bookingIds) }, // Per-relation SELECTs: the containerType/cargoType→wagonTypes M2M joins // multiply rows badly in a single join (hot path for every allocation // preview / assignment validation). relationLoadStrategy: 'query', relations: { company: true, originYard: true, destinationYard: true, // units carry the real per-container numbers entered at booking time — // the wagon plan shows those instead of generated placeholders. // containerType.wagonTypes + cargoType.wagonTypes drive wagon-type // resolution during scheduling (many-to-many lists — the plan mixes // wagon types within one consist). bookingContainers: { containerType: { wagonTypes: true }, units: true }, cargoType: { wagonTypes: true }, }, order: { priorityScore: 'DESC', createdAt: 'ASC' }, }); } async updateSchedulingFields( bookingId: string, fields: Partial< Pick< Booking, | 'schedulingStatus' | 'wagonsRequired' | 'scheduledAt' | 'holdStartedAt' | 'holdExpiresAt' | 'trainScheduleId' > >, manager?: EntityManager, ): Promise { await this.bookingRepo(manager).update(bookingId, fields as never); } async setHoldWindowOnPaid(bookingId: string, manager?: EntityManager): Promise { const now = new Date(); const expires = new Date(now.getTime() + 3 * 60 * 60 * 1000); await this.updateSchedulingFields( bookingId, { schedulingStatus: SchedulingStatus.Holding, holdStartedAt: now, holdExpiresAt: expires, }, manager, ); } }