mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 14:38:12 +00:00
A GENERAL + customs contract does not let the customer book directly: they
submit a shipment request, and initiateForShipmentRequest opens a BARE booking
from it — "the request itself carries the quantities; the instance carries
none". Between initiation and completeUnderContract the booking legitimately
holds no cargo, so the export reported 0 containers for a customer who had
declared, say, 2 x 20FT. 23 bookings on dev data are in that state.
Adds two columns and one filter reading booking_requests.requested_lines:
- "Requested cargo" — the declared lines as text ("2 x 20FT"), handling the
bulk shape too (tons / item count), not only containers.
- "Requested containers" — the declared box count, with a matching min/max
filter on the list and the export.
Deliberately a separate column rather than a fallback inside the real container
count: a declared 2 x 20FT is a request, not two boxes on a booking, and
merging them would overstate operational totals. The two compose instead —
Containers = 0 AND Requested containers >= 1 is exactly the set awaiting
completion after clearance.
requested_lines is free-form jsonb, so the container array is guarded by
jsonb_typeof before jsonb_array_elements; one malformed row would otherwise
500 the whole list.
1814 lines
68 KiB
TypeScript
1814 lines
68 KiB
TypeScript
import { BaseRepository, logCtx } 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 { computeFacets, FacetBucket } from '../../common/utils/facets.util';
|
|
import { resolveIamUserNames } from '../../common/utils/iam-user-name.util';
|
|
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 { ShippingLineCompany } from '../shipping-lines/entities/shipping-line-company.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 {
|
|
CARGO_TYPE_SUBTREE_SQL,
|
|
bookingContainerCountSql,
|
|
bookingContentMatchSql,
|
|
bookingHasContainerTypeSql,
|
|
bookingRequestedContainerCountSql,
|
|
} from './booking-content.sql';
|
|
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;
|
|
contractId?: string;
|
|
contractType?: string;
|
|
serviceTypeId?: string;
|
|
/** Cargo type OR cargo group — a group matches every commodity beneath it. */
|
|
cargoTypeId?: string;
|
|
/** Contains-search over content: description, commodity name, container types. */
|
|
cargoText?: string;
|
|
/** Bookings carrying this container type; also scopes the container count. */
|
|
containerTypeId?: string;
|
|
containersMin?: number;
|
|
containersMax?: number;
|
|
/** Bounds on containers declared on the shipment request behind the booking. */
|
|
requestedContainersMin?: number;
|
|
requestedContainersMax?: number;
|
|
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;
|
|
/** Any of these origin yards (OR). ANDed with `destinationYardId`. */
|
|
originYardId?: string[];
|
|
/** Any of these destination yards (OR). ANDed with `originYardId`. */
|
|
destinationYardId?: string[];
|
|
isGovernment?: 'true' | 'false';
|
|
/** Shipping-line bookings vs ordinary customer bookings (exactly one owner is set). */
|
|
customerKind?: 'SHIPPING_LINE' | 'CUSTOMER';
|
|
consolidationPaired?: string;
|
|
}
|
|
|
|
@Injectable()
|
|
export class BookingsRepository extends BaseRepository<Booking> {
|
|
constructor(
|
|
@InjectRepository(Booking)
|
|
repository: Repository<Booking>,
|
|
private readonly dataSource: DataSource,
|
|
) {
|
|
super(repository);
|
|
}
|
|
|
|
/** Find a booking by its human-readable reference number. */
|
|
findByReference(reference: string): Promise<Booking | null> {
|
|
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<void> {
|
|
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<Booking>,
|
|
): Promise<Booking | null> {
|
|
await this.assertContractNotSuspended(id);
|
|
return super.update(id, data);
|
|
}
|
|
|
|
/**
|
|
* Highest NNNNNN sequence already issued for `BK-<year>-…` 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<number> {
|
|
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<Booking | null> {
|
|
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<Booking | null> {
|
|
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<BookingContainer[]> {
|
|
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<number> {
|
|
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<number> {
|
|
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<ContractRateSnapshot[]> {
|
|
return this.dataSource
|
|
.getRepository(ContractRateSnapshot)
|
|
.find({ where: { contractId } });
|
|
}
|
|
|
|
/**
|
|
* Bookings a GL operator may manually link to `booking` as its odd-20ft
|
|
* consolidation partner (Path B customs flow). Unlike
|
|
* {@link findComplementaryConsolidationPartner} — which auto-pairs on an exact
|
|
* quantity complement — this lists CANDIDATES for a human to choose from, so
|
|
* the filter is deliberately looser: any other customs booking on the same
|
|
* route/direction that is itself carrying an odd 20ft count. Two odd counts
|
|
* always sum to even, so any pick fills the shared wagon.
|
|
*
|
|
* Bare instances awaiting completion have no persisted containers yet, so the
|
|
* odd-count test runs on the requested container lines when they exist and the
|
|
* booking is offered as a candidate when they do not (GL enters its cargo on
|
|
* the split form).
|
|
*/
|
|
async findManualConsolidationCandidates(
|
|
booking: Booking,
|
|
limit = 50,
|
|
): Promise<Booking[]> {
|
|
const rows = await this.repository
|
|
.createQueryBuilder('b')
|
|
.leftJoinAndSelect('b.bookingContainers', 'bc')
|
|
.leftJoinAndSelect('bc.containerType', 'ct')
|
|
.leftJoinAndSelect('b.company', 'company')
|
|
.where('b.id != :bookingId', { bookingId: booking.id })
|
|
// Never offer a booking that already shares a wagon with someone else.
|
|
.andWhere('b.consolidationPartnerId IS NULL')
|
|
// Customs-only: this manual flow exists because a customs (Path B)
|
|
// instance is completed by GL, not by the customer.
|
|
.andWhere('b.customsClearingEnabled = true')
|
|
// Same physical wagon ⇒ same route and same direction.
|
|
.andWhere('b.originYardId = :originYardId', {
|
|
originYardId: booking.originYardId,
|
|
})
|
|
.andWhere('b.destinationYardId = :destinationYardId', {
|
|
destinationYardId: booking.destinationYardId,
|
|
})
|
|
.andWhere('b.tradeDirection = :tradeDirection', {
|
|
tradeDirection: booking.tradeDirection,
|
|
})
|
|
// Bookable = clearance finished and the booking is waiting to be completed,
|
|
// the same set completeUnderContract accepts, plus one already parked for a
|
|
// partner.
|
|
.andWhere('b.status IN (:...statuses)', {
|
|
statuses: [
|
|
'CLEARANCE_READY',
|
|
'OPERATION_CHANGES_REQUESTED',
|
|
'PENDING_CONSOLIDATION',
|
|
],
|
|
})
|
|
.orderBy('b.createdAt', 'ASC')
|
|
.take(limit)
|
|
.getMany();
|
|
|
|
// Odd-20ft test in memory: a bare instance has no containers yet (GL fills
|
|
// them on the split form) and stays a candidate; one that already carries
|
|
// cargo qualifies only when its 20ft total is odd.
|
|
return rows.filter((row) => {
|
|
const lines = row.bookingContainers ?? [];
|
|
if (lines.length === 0) return true;
|
|
const ft20 = lines
|
|
.filter((line) => Number(line.containerType?.sizeFt) === 20)
|
|
.reduce((sum, line) => sum + Number(line.quantity || 0), 0);
|
|
return ft20 % 2 === 1;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Candidate partners for rebooking an odd-20ft cancellation credit: unpaired
|
|
* odd-20ft bookings on the same route/direction riding the requested day —
|
|
* SUBMITTED (committed direct booking) or parked PENDING_CONSOLIDATION.
|
|
* Unlike {@link findManualConsolidationCandidates} this is not customs-only:
|
|
* GL picks who shares the rebooked wagon whatever the contract kind.
|
|
*/
|
|
async findRebookConsolidationCandidates(
|
|
booking: Booking,
|
|
scheduledDate: Date,
|
|
limit = 50,
|
|
): Promise<Booking[]> {
|
|
const rows = await this.repository
|
|
.createQueryBuilder('b')
|
|
.leftJoinAndSelect('b.bookingContainers', 'bc')
|
|
.leftJoinAndSelect('bc.containerType', 'ct')
|
|
.leftJoinAndSelect('b.company', 'company')
|
|
.where('b.id != :bookingId', { bookingId: booking.id })
|
|
.andWhere('b.consolidationPartnerId IS NULL')
|
|
.andWhere('b.originYardId = :originYardId', {
|
|
originYardId: booking.originYardId,
|
|
})
|
|
.andWhere('b.destinationYardId = :destinationYardId', {
|
|
destinationYardId: booking.destinationYardId,
|
|
})
|
|
.andWhere('b.tradeDirection = :tradeDirection', {
|
|
tradeDirection: booking.tradeDirection,
|
|
})
|
|
.andWhere('b.status IN (:...statuses)', {
|
|
statuses: ['SUBMITTED', 'PENDING_CONSOLIDATION'],
|
|
})
|
|
// Same EAT booking day as the rebook — the pair shares one physical
|
|
// wagon, so it must board one train.
|
|
.andWhere(
|
|
`DATE(b.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = DATE(:bookingDate AT TIME ZONE 'Africa/Addis_Ababa')`,
|
|
{ bookingDate: scheduledDate },
|
|
)
|
|
.orderBy('b.createdAt', 'ASC')
|
|
.take(limit)
|
|
.getMany();
|
|
|
|
// Odd-20ft test in memory (two 20ft per wagon: odd + odd = whole wagons).
|
|
return rows.filter((row) => {
|
|
const lines = row.bookingContainers ?? [];
|
|
if (lines.length === 0) return false;
|
|
const ft20 = lines
|
|
.filter((line) => Number(line.containerType?.sizeFt) === 20)
|
|
.reduce((sum, line) => sum + Number(line.quantity || 0), 0);
|
|
return ft20 % 2 === 1;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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<Booking | null> {
|
|
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<Booking | null> {
|
|
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<void> {
|
|
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<boolean> {
|
|
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<void> {
|
|
await this.repository.update(bookingId, {
|
|
consolidationPartnerId: null,
|
|
status: 'PENDING_CONSOLIDATION',
|
|
consolidationResumeStatus: resumeStatus ?? null,
|
|
} as never);
|
|
}
|
|
|
|
/**
|
|
* Link two bookings as consolidation partners WITHOUT touching their statuses.
|
|
* Used by the manual GL pairing, where both bookings have just been completed
|
|
* into their live status — unlike {@link pairConsolidation}, which exists to
|
|
* resume bookings parked in PENDING_CONSOLIDATION and rewrites status as part
|
|
* of that resume.
|
|
*/
|
|
async linkConsolidationPartners(
|
|
bookingId: string,
|
|
partnerId: string,
|
|
): Promise<void> {
|
|
await this.repository.update(bookingId, {
|
|
consolidationPartnerId: partnerId,
|
|
} as never);
|
|
await this.repository.update(partnerId, {
|
|
consolidationPartnerId: bookingId,
|
|
} as never);
|
|
}
|
|
|
|
/**
|
|
* Terminal un-pair: break the consolidation link only, touching neither
|
|
* status. Used when one half of a pair is cancelled/expired — the caller
|
|
* decides each side's fate ({@link unpairConsolidation} instead re-parks
|
|
* BOTH sides to PENDING_CONSOLIDATION, which is wrong for a dying booking).
|
|
*/
|
|
async clearConsolidationPair(bookingId: string, partnerId: string): Promise<void> {
|
|
await this.repository.update(bookingId, {
|
|
consolidationPartnerId: null,
|
|
} as never);
|
|
await this.repository.update(partnerId, {
|
|
consolidationPartnerId: null,
|
|
} as never);
|
|
}
|
|
|
|
/** Un-pair a consolidation. */
|
|
async unpairConsolidation(bookingId: string, partnerId: string): Promise<void> {
|
|
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<void> {
|
|
await this.dataSource.getRepository(BookingContainer).delete({ bookingId });
|
|
}
|
|
|
|
// ── Clearance document reviews ────────────────────────────────────────────
|
|
|
|
findDocumentReviews(bookingId: string): Promise<BookingDocumentReview[]> {
|
|
return this.dataSource.getRepository(BookingDocumentReview).find({
|
|
where: { bookingId },
|
|
order: { createdAt: 'ASC' },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Bookings (of those given) that have at least one customer document still
|
|
* waiting on GL — PENDING or QUERIED. Includes ad-hoc `custom_*` documents,
|
|
* which no milestone tracks, so a file added after clearance was finalized
|
|
* still surfaces as needing review. One query for a whole queue page.
|
|
*/
|
|
async findBookingsWithUnreviewedDocuments(
|
|
bookingIds: string[],
|
|
): Promise<Set<string>> {
|
|
if (bookingIds.length === 0) return new Set();
|
|
const rows = (await this.dataSource
|
|
.getRepository(BookingDocumentReview)
|
|
.createQueryBuilder('r')
|
|
.select('DISTINCT r.booking_id', 'bookingId')
|
|
.where('r.booking_id IN (:...bookingIds)', { bookingIds })
|
|
.andWhere('r.status IN (:...statuses)', {
|
|
statuses: ['PENDING', 'QUERIED'],
|
|
})
|
|
.andWhere('r.deleted_at IS NULL')
|
|
.getRawMany()) as Array<{ bookingId: string }>;
|
|
return new Set(rows.map((r) => r.bookingId));
|
|
}
|
|
|
|
findDocumentReview(
|
|
bookingId: string,
|
|
settingCode: string,
|
|
fileKey: string,
|
|
): Promise<BookingDocumentReview | null> {
|
|
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<void> {
|
|
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' }));
|
|
}
|
|
|
|
/** Display names for reviewer staff ids — one query for the whole set. */
|
|
async resolveStaffNames(
|
|
staffIds: (string | null | undefined)[],
|
|
): Promise<Map<string, string>> {
|
|
return resolveIamUserNames(this.dataSource, staffIds);
|
|
}
|
|
|
|
/** 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<void> {
|
|
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<BookingCargoModifier[]> {
|
|
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<BookingRateSnapshot | null> {
|
|
return this.dataSource.getRepository(BookingRateSnapshot).findOne({
|
|
where: { bookingId, rateId },
|
|
});
|
|
}
|
|
|
|
async createReviewNote(
|
|
bookingId: string,
|
|
note: string,
|
|
type: ReviewNoteType,
|
|
authorId?: string,
|
|
): Promise<BookingReviewNote> {
|
|
const repo = this.dataSource.getRepository(BookingReviewNote);
|
|
// Every rejection/cancellation/change-request reason in the booking flow is
|
|
// written through here — the "why" behind the status change on the same log
|
|
// line as the status change itself.
|
|
logCtx(
|
|
{ bookingId, type, note, authorId },
|
|
{ path: 'reviewNotes', mode: 'push' },
|
|
);
|
|
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<BookingReviewNote[]> {
|
|
return this.dataSource.getRepository(BookingReviewNote).find({
|
|
where: { bookingId, type },
|
|
order: { createdAt: 'DESC' },
|
|
});
|
|
}
|
|
|
|
async findLatestReviewNote(
|
|
bookingId: string,
|
|
type?: ReviewNoteType,
|
|
): Promise<BookingReviewNote | null> {
|
|
const repo = this.dataSource.getRepository(BookingReviewNote);
|
|
return repo.findOne({
|
|
where: type ? { bookingId, type } : { bookingId },
|
|
order: { createdAt: 'DESC' },
|
|
});
|
|
}
|
|
|
|
async clearPricingArtifacts(bookingId: string): Promise<void> {
|
|
await this.dataSource.getRepository(BookingCargoModifier).delete({ bookingId });
|
|
await this.dataSource.getRepository(BookingRateSnapshot).delete({ bookingId });
|
|
}
|
|
|
|
async hasPricingArtifacts(bookingId: string): Promise<boolean> {
|
|
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<void> {
|
|
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<Booking[]> {
|
|
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')
|
|
// Shipping-line owner name for search only (no relation, see entity) —
|
|
// the list rows get `shippingLineCompany` hydrated by the service.
|
|
.leftJoin(ShippingLineCompany, 'slc', 'slc.id = booking.shipping_line_company_id')
|
|
.where('booking.deleted_at IS NULL');
|
|
|
|
this.applyListFilters(qb, options);
|
|
|
|
// Free-text search spans joined columns (company, shipping line, 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 slc.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<string, string | null>();
|
|
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,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Facet counts for the filter bar's enum popovers: one `GROUP BY` per
|
|
* column, each with every OTHER active filter applied but its own
|
|
* predicate omitted (see `applyListFilters`'s `omit` param). `bookingType`
|
|
* is derived from `contract_kind` (see the comment in `applyListFilters`),
|
|
* not a plain column, so it facets on the same CASE expression the filter
|
|
* itself applies.
|
|
*/
|
|
async getFacets(options: BookingListFilterOptions): Promise<Record<string, FacetBucket[]>> {
|
|
return computeFacets(
|
|
() => this.repository.createQueryBuilder('booking').where('booking.deleted_at IS NULL'),
|
|
(qb, omit) => this.applyListFilters(qb, options, omit as keyof BookingListFilterOptions),
|
|
{
|
|
status: 'booking.status',
|
|
freightType: 'booking.freight_type',
|
|
tradeDirection: 'booking.trade_direction',
|
|
paymentStatus: 'booking.payment_status',
|
|
bookingType:
|
|
"CASE WHEN booking.contract_kind = 'GENERAL' THEN 'GENERAL_CONTRACT' ELSE 'ONE_TIME' END",
|
|
},
|
|
);
|
|
}
|
|
|
|
async getStatusCounts(): Promise<Record<string, number>> {
|
|
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 };
|
|
}
|
|
|
|
/**
|
|
* @param omit skip this one predicate — used by `getFacets` so a facet's
|
|
* own filter doesn't hide its own sibling values. Every other caller
|
|
* (list, summary metrics) passes nothing.
|
|
*/
|
|
private applyListFilters(
|
|
qb: SelectQueryBuilder<Booking>,
|
|
options: BookingListFilterOptions,
|
|
omit?: keyof BookingListFilterOptions | 'status',
|
|
): void {
|
|
if (omit !== 'status') {
|
|
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.contractId) {
|
|
qb.andWhere('booking.contract_id = :contractId', {
|
|
contractId: options.contractId,
|
|
});
|
|
}
|
|
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,
|
|
});
|
|
}
|
|
// A group is selectable in the filter, not just a leaf commodity, so this
|
|
// matches the whole subtree — picking "Bulk" must return every commodity
|
|
// under it, the same drill-down the booking wizard offers, read back.
|
|
if (options.cargoTypeId) {
|
|
qb.andWhere(`booking.cargo_type_id IN ${CARGO_TYPE_SUBTREE_SQL}`, {
|
|
cargoTypeId: options.cargoTypeId,
|
|
});
|
|
}
|
|
if (options.cargoText) {
|
|
qb.andWhere(bookingContentMatchSql('booking'), {
|
|
cargoText: `%${options.cargoText}%`,
|
|
});
|
|
}
|
|
if (options.containerTypeId) {
|
|
qb.andWhere(bookingHasContainerTypeSql('booking'), {
|
|
containerTypeId: options.containerTypeId,
|
|
});
|
|
}
|
|
// One count filter, two questions: with a container type picked it counts
|
|
// that type, without one it counts every box on the booking.
|
|
if (options.containersMin != null || options.containersMax != null) {
|
|
const count = bookingContainerCountSql(
|
|
'booking',
|
|
Boolean(options.containerTypeId),
|
|
);
|
|
if (options.containersMin != null) {
|
|
qb.andWhere(`${count} >= :containersMin`, {
|
|
containersMin: options.containersMin,
|
|
});
|
|
}
|
|
if (options.containersMax != null) {
|
|
qb.andWhere(`${count} <= :containersMax`, {
|
|
containersMax: options.containersMax,
|
|
});
|
|
}
|
|
}
|
|
// Declared on the shipment request, not on the booking. Pairs with the
|
|
// count above: containers 0..0 AND requested >= 1 is the set awaiting
|
|
// completion after clearance.
|
|
if (options.requestedContainersMin != null) {
|
|
qb.andWhere(`${bookingRequestedContainerCountSql('booking')} >= :requestedContainersMin`, {
|
|
requestedContainersMin: options.requestedContainersMin,
|
|
});
|
|
}
|
|
if (options.requestedContainersMax != null) {
|
|
qb.andWhere(`${bookingRequestedContainerCountSql('booking')} <= :requestedContainersMax`, {
|
|
requestedContainersMax: options.requestedContainersMax,
|
|
});
|
|
}
|
|
if (omit !== 'freightType' && options.freightType) {
|
|
qb.andWhere('booking.freight_type = :freightType', {
|
|
freightType: options.freightType,
|
|
});
|
|
}
|
|
if (omit !== 'bookingType' && 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,
|
|
});
|
|
}
|
|
// Each end is its own OR-list, and the two ends AND together — so
|
|
// "leaving Nagad or DMP" and "leaving Nagad, arriving Gelan" are both
|
|
// expressible. `?.length` guards the empty array: `IN ()` is a syntax error.
|
|
if (options.originYardId?.length) {
|
|
qb.andWhere('booking.origin_yard_id IN (:...originYardIds)', {
|
|
originYardIds: options.originYardId,
|
|
});
|
|
}
|
|
if (options.destinationYardId?.length) {
|
|
qb.andWhere('booking.destination_yard_id IN (:...destinationYardIds)', {
|
|
destinationYardIds: 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.customerKind === 'SHIPPING_LINE') {
|
|
qb.andWhere('booking.shipping_line_company_id IS NOT NULL');
|
|
} else if (options.customerKind === 'CUSTOMER') {
|
|
qb.andWhere('booking.shipping_line_company_id IS NULL');
|
|
}
|
|
if (omit !== 'tradeDirection' && options.tradeDirection) {
|
|
qb.andWhere('booking.trade_direction = :tradeDirection', {
|
|
tradeDirection: options.tradeDirection,
|
|
});
|
|
}
|
|
if (omit !== 'tradeDirection' && options.tradeDirections) {
|
|
applyDirectionScope(qb, 'booking.trade_direction', options.tradeDirections);
|
|
}
|
|
if (options.paymentCurrency) {
|
|
qb.andWhere('booking.payment_currency = :paymentCurrency', {
|
|
paymentCurrency: options.paymentCurrency,
|
|
});
|
|
}
|
|
if (omit !== 'paymentStatus' && 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<Booking>, options: {
|
|
skip: number;
|
|
take: number;
|
|
order: Record<string, 'ASC' | 'DESC'>;
|
|
}): Promise<[Booking[], number]> {
|
|
return this.repository.findAndCount({
|
|
where,
|
|
skip: options.skip,
|
|
take: options.take,
|
|
order: options.order,
|
|
});
|
|
}
|
|
|
|
findContractSignatures(bookingId: string): Promise<BookingContractSignature[]> {
|
|
return this.dataSource.getRepository(BookingContractSignature).find({
|
|
where: { bookingId },
|
|
relations: ['signatureFile'],
|
|
order: { signedAt: 'ASC' },
|
|
});
|
|
}
|
|
|
|
findContractSignature(
|
|
bookingId: string,
|
|
role: ContractSignerRole,
|
|
): Promise<BookingContractSignature | null> {
|
|
return this.dataSource.getRepository(BookingContractSignature).findOne({
|
|
where: { bookingId, signerRole: role },
|
|
relations: ['signatureFile'],
|
|
});
|
|
}
|
|
|
|
async saveContractSignature(
|
|
data: Partial<BookingContractSignature>,
|
|
): Promise<BookingContractSignature> {
|
|
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<Booking[]> {
|
|
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<Booking[]> {
|
|
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<Booking[]> {
|
|
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<Booking[]> {
|
|
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<Booking[]> {
|
|
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<Booking[]> {
|
|
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<Booking[]> {
|
|
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<Booking[]> {
|
|
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<Booking[]> {
|
|
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', 'PAYMENT_VERIFICATION_IN_PROGRESS')`,
|
|
)
|
|
.getMany();
|
|
}
|
|
|
|
/** PAID bookings targeting a schedule that have no train_schedule_bookings link yet. */
|
|
findPaidUnlinkedForSchedule(scheduleId: string): Promise<Booking[]> {
|
|
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<Booking[]> {
|
|
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<Booking[]> {
|
|
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<void> {
|
|
await this.bookingRepo(manager).update(bookingId, fields as never);
|
|
}
|
|
|
|
async setHoldWindowOnPaid(bookingId: string, manager?: EntityManager): Promise<void> {
|
|
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,
|
|
);
|
|
}
|
|
}
|