mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 02:58:11 +00:00
425 lines
14 KiB
TypeScript
425 lines
14 KiB
TypeScript
import { BaseRepository } from '@edr/api-common';
|
|
import { Injectable } from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { DataSource, FindOptionsWhere, Repository } 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 { BookingContainer } from './entities/booking-container.entity';
|
|
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
|
import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.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';
|
|
|
|
@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')
|
|
.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.allowConsolidation = true')
|
|
.andWhere('b.consolidationPartnerId IS NULL')
|
|
.andWhere('b.status IN (:...statuses)', {
|
|
statuses: ['DRAFT', '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. */
|
|
async pairConsolidation(bookingId: string, partnerId: string): Promise<void> {
|
|
await this.repository.update(bookingId, {
|
|
consolidationPartnerId: partnerId,
|
|
status: 'CONSOLIDATED',
|
|
} as never);
|
|
await this.repository.update(partnerId, {
|
|
consolidationPartnerId: bookingId,
|
|
status: 'CONSOLIDATED',
|
|
} 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' },
|
|
relations: ['approvalRule'],
|
|
});
|
|
}
|
|
|
|
async findApprovalStepById(
|
|
bookingId: string,
|
|
stepId: string,
|
|
): Promise<BookingApprovalStep | null> {
|
|
return this.dataSource.getRepository(BookingApprovalStep).findOne({
|
|
where: { bookingId, id: stepId },
|
|
relations: ['approvalRule'],
|
|
});
|
|
}
|
|
|
|
/** 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;
|
|
}
|
|
|
|
/** Persist cargo modifiers linked to rate snapshots. */
|
|
async createCargoModifiers(
|
|
rows: Array<{
|
|
bookingId: string;
|
|
surchargeTypeId: 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 findByPnrCode(pnrCode: string): Promise<Booking | null> {
|
|
return this.repository.findOne({ where: { pnrCode } });
|
|
}
|
|
|
|
/** 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.customer', 'customer')
|
|
.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.priority_score' : 'booking.created_at';
|
|
qb.orderBy(sortField, options.sortOrder ?? 'DESC');
|
|
|
|
const [items, total] = await qb
|
|
.skip((page - 1) * pageSize)
|
|
.take(pageSize)
|
|
.getManyAndCount();
|
|
|
|
return { items, total };
|
|
}
|
|
|
|
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));
|
|
}
|
|
}
|