mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 17:38:12 +00:00
refactor pricing data seeder to fold surcharge types into rates update route meta subtitle to remove surcharge types enhance RuleEngineFormDialog to support conditional field visibility remove surcharge types from URL constants and related services add cargo leaf options query for bulk cargo type selection update RuleEngineResourcePage to utilize cargo leaf options modify resources configuration to remove surcharge types implement migration to fold surcharge types into rates create utility to derive legacy rate types from new rate structure
1043 lines
35 KiB
TypeScript
1043 lines
35 KiB
TypeScript
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 { 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;
|
|
createdFrom?: string;
|
|
createdTo?: string;
|
|
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 } });
|
|
}
|
|
|
|
/** Count bookings created in a specific year. */
|
|
async countByYear(year: number): Promise<number> {
|
|
const startDate = new Date(year, 0, 1);
|
|
const endDate = new Date(year + 1, 0, 1);
|
|
|
|
return this.repository
|
|
.createQueryBuilder('booking')
|
|
.where('booking.created_at >= :startDate', { startDate })
|
|
.andWhere('booking.created_at < :endDate', { endDate })
|
|
.getCount();
|
|
}
|
|
|
|
/** 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('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 })
|
|
.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;
|
|
weightResult: ContainerWeightResult;
|
|
}>,
|
|
): Promise<BookingContainer[]> {
|
|
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);
|
|
|
|
const row = containerRepo.create({
|
|
bookingId,
|
|
containerTypeId: item.containerTypeId,
|
|
quantity: item.quantity,
|
|
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<number> {
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* Find another booking whose container quantity complements this one to fill whole wagon(s)
|
|
* (same route, same container type, partial wagon on both sides).
|
|
*/
|
|
async findComplementaryConsolidationPartner(
|
|
booking: Booking,
|
|
slot: {
|
|
containerTypeId: string;
|
|
quantity: number;
|
|
containersPerWagon: number;
|
|
},
|
|
): Promise<Booking | null> {
|
|
const { containerTypeId, quantity, containersPerWagon: perWagon } = slot;
|
|
|
|
return 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,
|
|
})
|
|
.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<Booking | null> {
|
|
for (const slot of slots) {
|
|
const partner = await this.findComplementaryConsolidationPartner(booking, slot);
|
|
if (partner) return partner;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Pair two bookings for consolidation. Both return to SUBMITTED so staff can
|
|
* accept them into the approval chain; the link itself (consolidationPartnerId)
|
|
* marks them as consolidated in the UI.
|
|
*/
|
|
async pairConsolidation(bookingId: string, partnerId: string): Promise<void> {
|
|
await this.repository.update(bookingId, {
|
|
consolidationPartnerId: partnerId,
|
|
status: 'SUBMITTED',
|
|
} as never);
|
|
await this.repository.update(partnerId, {
|
|
consolidationPartnerId: bookingId,
|
|
status: 'SUBMITTED',
|
|
} as never);
|
|
}
|
|
|
|
/** Park a booking that needs consolidation but has no partner yet. */
|
|
async parkForConsolidation(bookingId: string): Promise<void> {
|
|
await this.repository.update(bookingId, {
|
|
consolidationPartnerId: null,
|
|
status: 'PENDING_CONSOLIDATION',
|
|
} 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 });
|
|
}
|
|
|
|
/** Lowest-order pending approval step (sequential enforcement). */
|
|
async findNextPendingApprovalStep(
|
|
bookingId: string,
|
|
): Promise<BookingApprovalStep | null> {
|
|
return this.dataSource.getRepository(BookingApprovalStep).findOne({
|
|
where: { bookingId, status: 'PENDING' },
|
|
order: { stepOrder: 'ASC' },
|
|
});
|
|
}
|
|
|
|
async findApprovalStepById(
|
|
bookingId: string,
|
|
stepId: string,
|
|
): Promise<BookingApprovalStep | null> {
|
|
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<BookingApprovalStep | null> {
|
|
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<void> {
|
|
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<boolean> {
|
|
const pending = await this.dataSource.getRepository(BookingApprovalStep).count({
|
|
where: { bookingId, status: 'PENDING' },
|
|
});
|
|
return pending === 0;
|
|
}
|
|
|
|
// ── Clearance document reviews ────────────────────────────────────────────
|
|
|
|
findDocumentReviews(bookingId: string): Promise<BookingDocumentReview[]> {
|
|
return this.dataSource.getRepository(BookingDocumentReview).find({
|
|
where: { bookingId },
|
|
order: { createdAt: 'ASC' },
|
|
});
|
|
}
|
|
|
|
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' }));
|
|
}
|
|
|
|
/** 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);
|
|
return repo.save(
|
|
repo.create({ bookingId, note, type, authorId: authorId ?? null }),
|
|
);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/** 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')
|
|
.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 [items, total] = await qb
|
|
.skip((page - 1) * pageSize)
|
|
.take(pageSize)
|
|
.getManyAndCount();
|
|
|
|
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<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 };
|
|
}
|
|
|
|
private applyListFilters(
|
|
qb: SelectQueryBuilder<Booking>,
|
|
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.booking_type = :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.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;
|
|
}): 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');
|
|
|
|
// 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<Booking[]> {
|
|
return this.repository
|
|
.createQueryBuilder('booking')
|
|
.leftJoinAndSelect('booking.company', 'company')
|
|
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
|
.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<Booking[]> {
|
|
return this.repository
|
|
.createQueryBuilder('booking')
|
|
.leftJoinAndSelect('booking.company', 'company')
|
|
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
|
.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();
|
|
}
|
|
|
|
/** 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')
|
|
.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<Booking[]> {
|
|
return this.repository
|
|
.createQueryBuilder('booking')
|
|
.leftJoinAndSelect('booking.company', 'company')
|
|
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
|
.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<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')
|
|
.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) },
|
|
relations: {
|
|
company: true,
|
|
originYard: true,
|
|
destinationYard: true,
|
|
bookingContainers: { containerType: true },
|
|
cargoType: true,
|
|
},
|
|
order: { priorityScore: 'DESC', createdAt: 'ASC' },
|
|
});
|
|
}
|
|
|
|
async updateSchedulingFields(
|
|
bookingId: string,
|
|
fields: Partial<
|
|
Pick<
|
|
Booking,
|
|
'schedulingStatus' | 'wagonsRequired' | 'scheduledAt' | 'holdStartedAt' | 'holdExpiresAt'
|
|
>
|
|
>,
|
|
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,
|
|
);
|
|
}
|
|
}
|