mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 10:58:14 +00:00
contrat,booking,global logestic
This commit is contained in:
@@ -0,0 +1,383 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
||||
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { hasFreightPermission } from '../../common/freight-permission.util';
|
||||
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { ContractRoute } from './entities/contract-route.entity';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto';
|
||||
|
||||
/** Statuses that still occupy the single active-booking slot of a ONE_TIME contract. */
|
||||
const TERMINAL_BOOKING_STATUSES = ['EXPIRED', 'CANCELLED', 'COMPLETED', 'REJECTED'];
|
||||
|
||||
export interface CreateBookingUnderContractResult {
|
||||
booking: Booking;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The single create path for shipment bookings under a contract.
|
||||
*
|
||||
* - Path A (transport only): the customer creates the booking once the contract
|
||||
* is FULLY_EXECUTED / CONTRACT_ACTIVE and customs is NOT bundled.
|
||||
* - Path B (customs clearance): only GL Ethiopia creates the booking, once the
|
||||
* contract reaches CLEARANCE_READY_FOR_BOOKING; the customer never enters
|
||||
* shipment data.
|
||||
*
|
||||
* From booking creation onward the existing batch/payment/allocation pipeline
|
||||
* runs unchanged. See docs/new-doc.md §8, §13.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ContractBookingService {
|
||||
constructor(
|
||||
private readonly contractsRepository: ContractsRepository,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly bookingPricingService: BookingPricingService,
|
||||
private readonly containerTypesService: ContainerTypesService,
|
||||
private readonly ruleEngineService: RuleEngineService,
|
||||
private readonly milestoneService: ClearanceMilestoneService,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async createUnderContract(
|
||||
contractId: string,
|
||||
dto: CreateBookingUnderContractDto,
|
||||
user?: { id?: string } | null,
|
||||
actorPermissions?: unknown,
|
||||
): Promise<CreateBookingUnderContractResult> {
|
||||
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
|
||||
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
|
||||
|
||||
// GL Ethiopia is identified by the dedicated contract create-booking permission
|
||||
// (granted to the edr_gl_ethiopia preset).
|
||||
const isGlActor =
|
||||
actorPermissions != null &&
|
||||
hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking);
|
||||
|
||||
const createdByRole = await this.assertGate(contract, isGlActor);
|
||||
|
||||
// Validity window must still be open.
|
||||
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
|
||||
throw new BadRequestException('Contract validity has expired — no new bookings.');
|
||||
}
|
||||
|
||||
// ONE_TIME: only one active booking at a time (also enforced by partial unique index).
|
||||
if (contract.contractKind === 'ONE_TIME') {
|
||||
const active = await this.countActiveBookings(contractId);
|
||||
if (active > 0) {
|
||||
throw new BadRequestException(
|
||||
'This one-time contract already has an active booking.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const route = await this.resolveRoute(contract, dto.contractRouteId);
|
||||
const warnings: string[] = [];
|
||||
|
||||
const reference = await this.generateReference();
|
||||
const freightType = contract.freightType;
|
||||
|
||||
// Denormalize route/direction/freight onto the booking for the scheduling engine.
|
||||
const booking = await this.bookingsRepository.create({
|
||||
reference,
|
||||
companyId: contract.companyId ?? null,
|
||||
companyProfileId: contract.companyProfileId ?? null,
|
||||
isGovernment: contract.isGovernment,
|
||||
governmentInstitution: contract.governmentInstitution ?? null,
|
||||
status: 'OPERATION_REQUEST_PENDING',
|
||||
bookingType: 'ONE_TIME',
|
||||
contractId: contract.id,
|
||||
contractRouteId: route?.id ?? null,
|
||||
contractKind: contract.contractKind,
|
||||
createdByRole,
|
||||
createdByUserId: user?.id ?? null,
|
||||
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
|
||||
serviceTypeId: contract.serviceTypeId,
|
||||
paymentCurrency: contract.paymentCurrency,
|
||||
contractType: 'NEW',
|
||||
customsClearingEnabled: contract.customsClearingEnabled,
|
||||
customsClearingAgent: contract.customsClearingAgent ?? null,
|
||||
equipmentReturn: contract.equipmentReturn ?? 'WITHOUT_RETURN',
|
||||
originYardId: route?.originYardId ?? null,
|
||||
destinationYardId: route?.destinationYardId ?? null,
|
||||
tradeDirection: contract.tradeDirection,
|
||||
freightType,
|
||||
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
||||
isHazardous: contract.isHazardous,
|
||||
isReefer: contract.isReefer,
|
||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
|
||||
firstMilePickupLat: contract.firstMilePickupLat ?? null,
|
||||
firstMilePickupLng: contract.firstMilePickupLng ?? null,
|
||||
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
|
||||
lastMileDeliveryLat: contract.lastMileDeliveryLat ?? null,
|
||||
lastMileDeliveryLng: contract.lastMileDeliveryLng ?? null,
|
||||
} as never);
|
||||
|
||||
// Persist container lines + per-unit container numbers (container freight only).
|
||||
if (freightType === 'CONTAINER') {
|
||||
await this.persistContainers(booking.id, contract, dto);
|
||||
}
|
||||
|
||||
// Reload with containers to compute the total from contract unit rates × qty.
|
||||
const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||
if (loaded) {
|
||||
if (freightType === 'CONTAINER') {
|
||||
await this.applyWeightResults(loaded);
|
||||
}
|
||||
const computed = await this.bookingPricingService.computePriceForBooking(loaded);
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
totalAmount: computed.totalAmount,
|
||||
priorityScore: computed.priorityScore,
|
||||
pricingBreakdown: {
|
||||
lineItems: computed.lineItems,
|
||||
totalAmount: computed.totalAmount,
|
||||
currency: computed.currency,
|
||||
generatedAt: new Date().toISOString(),
|
||||
},
|
||||
} as never);
|
||||
await this.bookingPricingService.createPricingSnapshots(
|
||||
booking.id,
|
||||
computed.usedRates,
|
||||
computed.appliedModifiers,
|
||||
);
|
||||
warnings.push(...computed.warnings);
|
||||
}
|
||||
|
||||
// Path B side effects: link the clearance cycle, seed post-booking
|
||||
// milestones onto the booking, and advance the contract.
|
||||
if (contract.customsClearingEnabled) {
|
||||
const cycle = await this.contractsRepository.currentCycle(contract.id);
|
||||
if (cycle) {
|
||||
await this.contractsRepository.linkBooking(cycle.id, booking.id);
|
||||
}
|
||||
await this.milestoneService.seedPostBookingMilestones(
|
||||
booking.id,
|
||||
contract.tradeDirection,
|
||||
);
|
||||
await this.contractsRepository.update(contract.id, {
|
||||
status: 'ACTIVE_SHIPMENT_IN_PROGRESS',
|
||||
clearanceStatus: 'ACTIVE_SHIPMENT_IN_PROGRESS',
|
||||
} as never);
|
||||
}
|
||||
|
||||
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||
return { booking: result ?? booking, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the role to stamp on the booking, or throws if the caller is not
|
||||
* allowed to create one for this contract's execution path.
|
||||
*/
|
||||
private async assertGate(contract: Contract, isGlActor: boolean): Promise<string> {
|
||||
if (contract.customsClearingEnabled) {
|
||||
// Path B — GL Ethiopia only.
|
||||
if (!isGlActor) {
|
||||
throw new ForbiddenException(
|
||||
'Only Global Logistics Ethiopia can create bookings for customs-clearance contracts.',
|
||||
);
|
||||
}
|
||||
if (contract.clearanceStatus !== 'CLEARANCE_READY_FOR_BOOKING') {
|
||||
throw new BadRequestException(
|
||||
'Contract clearance is not ready for booking yet.',
|
||||
);
|
||||
}
|
||||
return 'GL_ET';
|
||||
}
|
||||
|
||||
// Path A — customer (or staff) once the contract is executed.
|
||||
if (!['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status)) {
|
||||
throw new BadRequestException(
|
||||
'Contract must be fully executed before booking a shipment.',
|
||||
);
|
||||
}
|
||||
return isGlActor ? 'STAFF' : 'CUSTOMER';
|
||||
}
|
||||
|
||||
private async countActiveBookings(contractId: string): Promise<number> {
|
||||
return this.dataSource
|
||||
.getRepository(Booking)
|
||||
.createQueryBuilder('b')
|
||||
.where('b.contract_id = :contractId', { contractId })
|
||||
.andWhere('b.status NOT IN (:...terminal)', { terminal: TERMINAL_BOOKING_STATUSES })
|
||||
.getCount();
|
||||
}
|
||||
|
||||
private async resolveRoute(
|
||||
contract: Contract,
|
||||
contractRouteId?: string,
|
||||
): Promise<ContractRoute | null> {
|
||||
const routes = contract.routes ?? [];
|
||||
if (contractRouteId) {
|
||||
const found = routes.find((r) => r.id === contractRouteId);
|
||||
if (!found) {
|
||||
throw new BadRequestException('Selected route is not part of this contract.');
|
||||
}
|
||||
return found;
|
||||
}
|
||||
// ONE_TIME (or single-route GENERAL): auto-select the only route.
|
||||
if (routes.length === 1) return routes[0];
|
||||
if (routes.length === 0) return null;
|
||||
throw new BadRequestException(
|
||||
'contractRouteId is required for multi-route general contracts.',
|
||||
);
|
||||
}
|
||||
|
||||
private resolveCargoTypeId(
|
||||
contract: Contract,
|
||||
dto: CreateBookingUnderContractDto,
|
||||
): string | null {
|
||||
if (contract.freightType === 'BULK') {
|
||||
const bulk = dto.bulkLines?.[0];
|
||||
if (bulk?.cargoTypeId) return bulk.cargoTypeId;
|
||||
const scope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
|
||||
return scope?.cargoTypeId ?? null;
|
||||
}
|
||||
const scope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
|
||||
return scope?.cargoTypeId ?? null;
|
||||
}
|
||||
|
||||
private resolveBulkTons(dto: CreateBookingUnderContractDto): number {
|
||||
if (!dto.bulkLines?.length) return 0;
|
||||
return dto.bulkLines.reduce(
|
||||
(sum, l) => sum + Number(l.cargoWeightTons ?? l.itemCount ?? 0),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map each contract-scope container size to a concrete container type and
|
||||
* persist the booking_container line + its per-unit container numbers. Weight
|
||||
* rule results are filled in afterward by {@link applyWeightResults} once all
|
||||
* lines exist (a single rule-engine pass over the booking).
|
||||
*/
|
||||
private async persistContainers(
|
||||
bookingId: string,
|
||||
contract: Contract,
|
||||
dto: CreateBookingUnderContractDto,
|
||||
): Promise<void> {
|
||||
const lines = dto.containers ?? [];
|
||||
if (!lines.length) {
|
||||
throw new BadRequestException('At least one container line is required.');
|
||||
}
|
||||
|
||||
const allowedSizes = new Set(
|
||||
(contract.cargoScope ?? [])
|
||||
.map((c) => c.containerSize)
|
||||
.filter((s): s is string => !!s),
|
||||
);
|
||||
|
||||
const containerRepo = this.dataSource.getRepository(BookingContainer);
|
||||
const unitRepo = this.dataSource.getRepository(BookingContainerUnit);
|
||||
|
||||
for (const line of lines) {
|
||||
if (allowedSizes.size && !allowedSizes.has(line.containerSize)) {
|
||||
throw new BadRequestException(
|
||||
`Container size ${line.containerSize} is outside the contract scope.`,
|
||||
);
|
||||
}
|
||||
|
||||
const containerType = await this.resolveContainerTypeForSize(
|
||||
line.containerSize,
|
||||
contract.isReefer || (line.reeferQuantity ?? 0) > 0,
|
||||
);
|
||||
|
||||
const vgmPerUnit = line.units.length
|
||||
? line.units.reduce((s, u) => s + Number(u.vgmTons ?? 0), 0) / line.units.length
|
||||
: 0;
|
||||
const totalVgm = line.units.reduce((s, u) => s + Number(u.vgmTons ?? 0), 0);
|
||||
|
||||
const containerRow = await containerRepo.save(
|
||||
containerRepo.create({
|
||||
bookingId,
|
||||
containerTypeId: containerType.id,
|
||||
containerSize: line.containerSize,
|
||||
quantity: line.quantity,
|
||||
hazardousQuantity: line.hazardousQuantity ?? 0,
|
||||
reeferQuantity: line.reeferQuantity ?? 0,
|
||||
vgmPerUnitTons: vgmPerUnit,
|
||||
totalVgmTons: totalVgm,
|
||||
wagonsRequired: Math.ceil(line.quantity * Number(containerType.wagonsPerUnit ?? 1)),
|
||||
isOverweight: false,
|
||||
overweightExcessTons: null,
|
||||
} as Partial<BookingContainer>),
|
||||
);
|
||||
|
||||
let sortOrder = 0;
|
||||
for (const unit of line.units) {
|
||||
await unitRepo.save(
|
||||
unitRepo.create({
|
||||
bookingContainerId: containerRow.id,
|
||||
containerNumber: unit.containerNumber,
|
||||
sealNumber: unit.sealNumber ?? null,
|
||||
vgmTons: unit.vgmTons,
|
||||
isHazardous: unit.isHazardous ?? false,
|
||||
isReefer: unit.isReefer ?? false,
|
||||
sortOrder: sortOrder++,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the rule engine once over the freshly-created booking and persist the
|
||||
* overweight result per container line (same ordering the engine returns).
|
||||
*/
|
||||
private async applyWeightResults(booking: Booking): Promise<void> {
|
||||
const evalInput = await this.bookingPricingService.buildEvalInputForBooking(booking);
|
||||
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
|
||||
const containers = booking.bookingContainers ?? [];
|
||||
const containerRepo = this.dataSource.getRepository(BookingContainer);
|
||||
for (let i = 0; i < containers.length; i++) {
|
||||
const wr = ruleResult.containerWeightResults[i];
|
||||
if (!wr) continue;
|
||||
await containerRepo.update(containers[i].id, {
|
||||
weightLimitRuleId: wr.weightLimitRuleId,
|
||||
isOverweight: wr.isOverweight,
|
||||
overweightExcessTons: wr.overweightExcessTons,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Pick the default container type for a size; prefer reefer when requested. */
|
||||
private async resolveContainerTypeForSize(
|
||||
size: string,
|
||||
preferReefer: boolean,
|
||||
): Promise<ContainerType> {
|
||||
const sizeFt = parseInt(size, 10);
|
||||
const { data } = await this.containerTypesService.findAll({ pageSize: 200 });
|
||||
const types = data.filter((t) => Number(t.sizeFt) === sizeFt && t.isActive !== false);
|
||||
if (!types.length) {
|
||||
throw new BadRequestException(`No container type configured for size ${size}.`);
|
||||
}
|
||||
if (preferReefer) {
|
||||
const reefer = types.find((t) => t.isReefer);
|
||||
if (reefer) return reefer;
|
||||
}
|
||||
return types.find((t) => !t.isReefer) ?? types[0];
|
||||
}
|
||||
|
||||
private async generateReference(): Promise<string> {
|
||||
const year = new Date().getFullYear();
|
||||
const count = await this.bookingsRepository.countByYear(year);
|
||||
const seq = String(count + 1).padStart(6, '0');
|
||||
return `BK-${year}-${seq}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user