import { BaseRepository } from '@edr/api-common'; import { SchedulingStatus } from '@edr/types'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; import { Contract } from '../contracts/entities/contract.entity'; import { ContractRoute } from '../contracts/entities/contract-route.entity'; import { BookingApprovalStep } from './entities/booking-approval-step.entity'; 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 { 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'; 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; paymentCurrency?: string; paymentStatus?: string; excludePaymentStatus?: string; customsClearingEnabled?: boolean; createdFrom?: string; createdTo?: string; 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 } }); } /** * 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.approvalSteps', 'steps') .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', "file.resource_id = booking.id AND file.resource = 'bookings'", ) .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; weightResult: ContainerWeightResult; }>, ): Promise { const containerRepo = this.dataSource.getRepository(BookingContainer); 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 = ct ? Number(ct.wagonsPerUnit) : 1; 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, }); saved.push(await containerRepo.save(row)); } 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 * ct.wagons_per_unit))', '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); } /** * 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; }, ): Promise { const { containerTypeId, quantity, containersPerWagon: perWagon } = slot; const qb = this.repository .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 }, ); } return qb.orderBy('b.createdAt', 'ASC').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; }>, ): Promise { for (const slot of slots) { const partner = await this.findComplementaryConsolidationPartner(booking, slot); 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); } /** * 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 }); } /** Lowest-order pending approval step (sequential enforcement). */ async findNextPendingApprovalStep( bookingId: string, ): Promise { return this.dataSource.getRepository(BookingApprovalStep).findOne({ where: { bookingId, status: 'PENDING' }, order: { stepOrder: 'ASC' }, }); } async findApprovalStepById( bookingId: string, stepId: string, ): Promise { return this.dataSource.getRepository(BookingApprovalStep).findOne({ where: { bookingId, id: stepId }, }); } /** Get pending approval step for a role (must match next in sequence). */ async findPendingApprovalStep( bookingId: string, requiredRole: string, ): Promise { const next = await this.findNextPendingApprovalStep(bookingId); if (!next || next.requiredRole !== requiredRole) return null; return next; } /** Mark an approval step complete. */ async completeApprovalStep( stepId: string, actorId: string, status: 'APPROVED' | 'REJECTED', remarks?: string, ): Promise { await this.dataSource.getRepository(BookingApprovalStep).update(stepId, { status, actionedByStaffId: actorId, actionedAt: new Date(), remarks, }); } /** Check if all approval steps are approved. */ async allApprovalStepsComplete(bookingId: string): Promise { const pending = await this.dataSource.getRepository(BookingApprovalStep).count({ where: { bookingId, status: 'PENDING' }, }); return pending === 0; } // ── 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 }), ); } 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') .leftJoinAndSelect('booking.approvalSteps', 'approvalSteps') .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; 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.approvalSteps', 'approvalSteps') .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); 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) { qb.andWhere('booking.bookingType = :bookingType', { bookingType: options.bookingType, }); } 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.tradeDirection) { qb.andWhere('booking.trade_direction = :tradeDirection', { tradeDirection: options.tradeDirection, }); } 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; }): 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'); // 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) { qb.andWhere( `DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`, { 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.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') .leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id') .where('booking.train_schedule_id = :scheduleId', { scheduleId }) .andWhere('sb.id IS NULL') .andWhere( `((booking.is_government = false AND booking.status = 'FULLY_EXECUTED') OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`, ) .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') .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( `((booking.is_government = false AND booking.status = 'FULLY_EXECUTED') OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`, ) .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') .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 AND booking.status = 'FULLY_EXECUTED') OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`, ) .orderBy('booking.is_government', 'DESC') .addOrderBy('booking.priority_score', 'DESC') .addOrderBy('booking.fully_executed_at', 'ASC') .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') .where('booking.train_schedule_id = :scheduleId', { scheduleId }) .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') .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') .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) }, 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.wagonType + cargoType.wagonType drive wagon-type // resolution during scheduling (FK, not the old load-type string map). bookingContainers: { containerType: { wagonType: true }, units: true }, cargoType: { wagonType: true }, }, order: { priorityScore: 'DESC', createdAt: 'ASC' }, }); } async updateSchedulingFields( bookingId: string, fields: Partial< Pick< Booking, 'schedulingStatus' | 'wagonsRequired' | 'scheduledAt' | 'holdStartedAt' | 'holdExpiresAt' > >, 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, ); } }