mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 21:50:57 +00:00
343 lines
14 KiB
TypeScript
343 lines
14 KiB
TypeScript
import { Injectable, Logger, forwardRef, Inject } from '@nestjs/common';
|
|
import { DataSource, IsNull, Not } 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 { Contract } from '../contracts/entities/contract.entity';
|
|
import {
|
|
ContractBookingService,
|
|
SplitOutstanding,
|
|
} from '../contracts/contract-booking.service';
|
|
import { ContractsRepository } from '../contracts/contracts.repository';
|
|
import {
|
|
CreateBookingUnderContractDto,
|
|
CreateContainerUnitDto,
|
|
} from '../contracts/dto/create-booking-under-contract.dto';
|
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
|
import { BookingBatchService } from './booking-batch.service';
|
|
import { BookingNotifierService } from './booking-notifier.service';
|
|
import { eatDay } from './batch-window.util';
|
|
|
|
/**
|
|
* Auto-creates and places the OUTSTANDING split remainder of a contract as a new
|
|
* booking, so the customer doesn't have to manually rebook the wagons that did
|
|
* not fit the train they just paid for.
|
|
*
|
|
* Fired (feature-flagged) right after `applySplit` runs on payment — i.e. only
|
|
* once the customer has actually accepted+paid the offered part. Before payment
|
|
* nothing is split: the booking stays whole and the customer may still edit or
|
|
* cancel it. See the split lifecycle in {@link BookingSplitService.applySplit}.
|
|
*
|
|
* IMPORT/DOMESTIC: the remainder booking is created with the next fitting
|
|
* shipment day set and then follows the normal windowed batch flow (train
|
|
* assigned at window close, paid in its own window). It is NOT force-reserved on
|
|
* a specific train — import is not FCFS.
|
|
*
|
|
* Container reconstruction is HYBRID: the remainder's quantities come from the
|
|
* split snapshot (`splitOutstanding`), but the actual container numbers / VGM /
|
|
* seals are read back from the units `applySplit` SOFT-DELETED off the parent
|
|
* (they survive as valid ISO records). We never `restore()` those rows — the new
|
|
* booking gets fresh rows — so the contract cap is never double-counted.
|
|
*/
|
|
@Injectable()
|
|
export class RemainderPlacementService {
|
|
private readonly logger = new Logger(RemainderPlacementService.name);
|
|
|
|
constructor(
|
|
private readonly dataSource: DataSource,
|
|
private readonly contractsRepository: ContractsRepository,
|
|
@Inject(forwardRef(() => ContractBookingService))
|
|
private readonly contractBookingService: ContractBookingService,
|
|
@Inject(forwardRef(() => BookingBatchService))
|
|
private readonly bookingBatchService: BookingBatchService,
|
|
private readonly notifier: BookingNotifierService,
|
|
) {}
|
|
|
|
/**
|
|
* Create + place the outstanding split remainder of the contract that owns
|
|
* `splitBooking`. No-op when there is no live remainder or no fitting day.
|
|
* Returns the created remainder booking id, or null when nothing was placed
|
|
* (residual falls back to the customer's manual rebook, as today).
|
|
*/
|
|
async placeRemainder(splitBooking: Booking): Promise<string | null> {
|
|
if (!splitBooking.contractId) return null;
|
|
// Two payment webhooks for the same contract landing together would both see
|
|
// the remainder as unbooked (the placing create has not committed yet) and
|
|
// each create one — double-booking the leftover. Serialize per contract: the
|
|
// second caller returns immediately and the first one's create is what the
|
|
// (now smaller) outstanding reflects.
|
|
if (this.inFlight.has(splitBooking.contractId)) {
|
|
this.logger.debug(
|
|
`Remainder placement already running for contract ${splitBooking.contractId} — skipped.`,
|
|
);
|
|
return null;
|
|
}
|
|
this.inFlight.add(splitBooking.contractId);
|
|
try {
|
|
return await this.placeRemainderInner(splitBooking);
|
|
} finally {
|
|
this.inFlight.delete(splitBooking.contractId);
|
|
}
|
|
}
|
|
|
|
/** Contracts with a placement in flight — see {@link placeRemainder}. */
|
|
private readonly inFlight = new Set<string>();
|
|
|
|
private async placeRemainderInner(
|
|
splitBooking: Booking,
|
|
): Promise<string | null> {
|
|
const contract = await this.contractsRepository.findByIdWithRelations(
|
|
splitBooking.contractId!,
|
|
);
|
|
if (!contract) return null;
|
|
|
|
// ONE_TIME only. `splitOutstanding` subtracts a CONTRACT-WIDE booked total
|
|
// from a SINGLE booking's pre-split snapshot, which is only coherent when
|
|
// the contract has exactly one live chain — that is the ONE_TIME invariant
|
|
// (enforced by hasSplitBooking → assertExactRemainder). On a GENERAL
|
|
// contract with other live bookings the subtraction mixes scopes: it either
|
|
// clamps to 0 and silently drops a real remainder, or sizes one that then
|
|
// draws the quantity cap a second time. GENERAL remainders keep the existing
|
|
// manual-rebook behaviour until the remainder can be derived from the
|
|
// offer's own dropped lines rather than from the contract-wide ledger.
|
|
if (contract.contractKind !== 'ONE_TIME') {
|
|
this.logger.debug(
|
|
`Contract ${contract.id} is ${contract.contractKind} — remainder left ` +
|
|
`for manual rebook (auto-placement is ONE_TIME only).`,
|
|
);
|
|
return null;
|
|
}
|
|
|
|
const outstanding = await this.contractBookingService.splitOutstanding(
|
|
contract,
|
|
);
|
|
if (!outstanding || !this.hasOutstanding(contract, outstanding)) {
|
|
return null;
|
|
}
|
|
|
|
// The next fitting day: the earliest day on/after the split booking's own day
|
|
// that still has an import train with room for this cargo type. We reuse the
|
|
// split booking as the capacity probe — it carries the leg + cargo relations.
|
|
const day = await this.nextFittingDay(splitBooking);
|
|
if (!day) {
|
|
this.logger.warn(
|
|
`No train with room for the remainder of contract ${contract.id} ` +
|
|
`(booking ${splitBooking.reference}) — left for manual rebook.`,
|
|
);
|
|
return null;
|
|
}
|
|
|
|
let dto: CreateBookingUnderContractDto;
|
|
try {
|
|
dto = await this.buildRemainderDto(
|
|
contract,
|
|
outstanding,
|
|
splitBooking.id,
|
|
day,
|
|
);
|
|
} catch (err) {
|
|
// A reconstruction shortfall (fewer recoverable units than outstanding)
|
|
// must NOT fabricate container numbers — fail loudly, leave manual rebook.
|
|
this.logger.error(
|
|
`Could not reconstruct the remainder of contract ${contract.id}: ` +
|
|
`${err instanceof Error ? err.message : String(err)} — left for manual rebook.`,
|
|
);
|
|
return null;
|
|
}
|
|
|
|
// Any create-gate rejection (no train space, cap, container clash) must not
|
|
// escape: the customer's paid booking has already boarded, and a thrown
|
|
// error here would only be logged upstream while the remainder vanished
|
|
// silently. Fall back to leaving it rebookable, which is the pre-feature
|
|
// behaviour, and say so in the log.
|
|
let created: Awaited<
|
|
ReturnType<ContractBookingService['createUnderContract']>
|
|
>;
|
|
try {
|
|
created = await this.contractBookingService.createUnderContract(
|
|
contract.id,
|
|
dto,
|
|
{ id: splitBooking.createdByUserId ?? undefined },
|
|
// System actor: a permission-bag carrying the contract create-booking key
|
|
// so the GL gate (isGlActor → hasFreightPermission) passes for GL Path B
|
|
// contracts; harmless for customer (Path A) contracts.
|
|
{ permissions: [{ key: FREIGHT_PERMS.contracts.createBooking }] },
|
|
);
|
|
} catch (err) {
|
|
this.logger.error(
|
|
`Could not create the remainder booking for contract ${contract.id} ` +
|
|
`(from ${splitBooking.reference}): ${
|
|
err instanceof Error ? err.message : String(err)
|
|
} — left for manual rebook.`,
|
|
);
|
|
return null;
|
|
}
|
|
// EXPORT is FCFS — there is no window to wait for, so the remainder is
|
|
// reserved on the next export train right away (its own pay window opens).
|
|
// If it does not fit one train whole either, the export accept offers it a
|
|
// partial and the chain repeats on ITS payment: each pass leaves a strictly
|
|
// smaller remainder, so it terminates at the day's train count.
|
|
// IMPORT/DOMESTIC deliberately does NOT force a train: it carries the next
|
|
// fitting day and rides the normal windowed batch flow.
|
|
if (splitBooking.tradeDirection === 'EXPORT') {
|
|
const fresh = await this.dataSource
|
|
.getRepository(Booking)
|
|
.findOne({
|
|
where: { id: created.booking.id },
|
|
relations: {
|
|
company: true,
|
|
bookingContainers: { containerType: true },
|
|
cargoType: true,
|
|
},
|
|
});
|
|
if (fresh) {
|
|
await this.bookingBatchService
|
|
.acceptExportBooking(fresh)
|
|
.catch((err) =>
|
|
// No export train took it — it stays created and rebookable, which
|
|
// is the same place a customer-driven rebook would leave it.
|
|
this.logger.warn(
|
|
`Export remainder ${fresh.reference} created but not reserved: ${
|
|
err instanceof Error ? err.message : String(err)
|
|
}`,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
this.notifier.remainderPlaced(
|
|
created.booking,
|
|
splitBooking.reference ?? splitBooking.id,
|
|
);
|
|
this.logger.log(
|
|
`Auto-placed split remainder of contract ${contract.id} as booking ` +
|
|
`${created.booking.reference} on ${day}.`,
|
|
);
|
|
return created.booking.id;
|
|
}
|
|
|
|
private hasOutstanding(
|
|
contract: Contract,
|
|
outstanding: SplitOutstanding,
|
|
): boolean {
|
|
if (contract.freightType === 'CONTAINER') {
|
|
return [...outstanding.bySize.values()].some((s) => s.outstanding > 0);
|
|
}
|
|
return (outstanding.bulk?.outstanding ?? 0) > 0.001;
|
|
}
|
|
|
|
/**
|
|
* The shipment day to create the remainder on — the split booking's own day.
|
|
*
|
|
* EXPORT is FCFS and must actually board a train that day, so a day with NO
|
|
* export train having room is rejected (null → left for manual rebook on a day
|
|
* the customer picks). IMPORT/DOMESTIC keeps the day regardless: its train is
|
|
* assigned by the batch engine at window close, not now, and the window may
|
|
* still free up — forcing a different day here would override the customer's
|
|
* binding shipment day.
|
|
*/
|
|
private async nextFittingDay(booking: Booking): Promise<string | null> {
|
|
if (!booking.scheduledDate) return null;
|
|
const day = eatDay(new Date(booking.scheduledDate));
|
|
if (booking.tradeDirection !== 'EXPORT') return day;
|
|
|
|
const fitting = await this.bookingBatchService.fittingTrainsForDay(
|
|
booking,
|
|
day,
|
|
'EXPORT',
|
|
);
|
|
return fitting.length > 0 ? day : null;
|
|
}
|
|
|
|
/**
|
|
* Build the create-DTO for the WHOLE outstanding remainder. Bulk uses the
|
|
* outstanding tonnage directly. Container reads the deferred (soft-deleted)
|
|
* units of the split booking back into real unit records.
|
|
*/
|
|
private async buildRemainderDto(
|
|
contract: Contract,
|
|
outstanding: SplitOutstanding,
|
|
splitBookingId: string,
|
|
day: string,
|
|
): Promise<CreateBookingUnderContractDto> {
|
|
const dto: CreateBookingUnderContractDto = { scheduledDate: day };
|
|
|
|
if (contract.freightType !== 'CONTAINER') {
|
|
const tons = outstanding.bulk?.outstanding ?? 0;
|
|
dto.bulkLines = [{ cargoWeightTons: tons }];
|
|
return dto;
|
|
}
|
|
|
|
// Container: recover the deferred units per size from the split booking's
|
|
// soft-deleted rows and reshape into DTO units.
|
|
const containers: NonNullable<CreateBookingUnderContractDto['containers']> = [];
|
|
for (const [size, { outstanding: need }] of outstanding.bySize) {
|
|
if (need <= 0) continue;
|
|
const units = await this.recoverDeferredUnits(splitBookingId, size, need);
|
|
if (units.length < need) {
|
|
throw new Error(
|
|
`size ${size}: recovered ${units.length} deferred container(s) but ` +
|
|
`${need} are outstanding`,
|
|
);
|
|
}
|
|
const line: NonNullable<CreateBookingUnderContractDto['containers']>[number] = {
|
|
containerSize: size,
|
|
quantity: need,
|
|
units,
|
|
};
|
|
line.hazardousQuantity = units.filter((u) => u.isHazardous).length;
|
|
line.reeferQuantity = units.filter((u) => u.isReefer).length;
|
|
containers.push(line);
|
|
}
|
|
dto.containers = containers;
|
|
return dto;
|
|
}
|
|
|
|
/**
|
|
* The `need` deferred container units of a given size for the split booking,
|
|
* read from the SOFT-DELETED unit rows (oldest sortOrder first — mirroring the
|
|
* LIFO trim in applySplit so the same physical containers deferred are the
|
|
* ones rebooked). Returns them as DTO units; does NOT restore the rows.
|
|
*/
|
|
private async recoverDeferredUnits(
|
|
splitBookingId: string,
|
|
containerSize: string,
|
|
need: number,
|
|
): Promise<CreateContainerUnitDto[]> {
|
|
// The line ids of this booking for this size (live + soft-deleted): units
|
|
// key on bookingContainerId, so gather every line of the size first.
|
|
const lines = await this.dataSource
|
|
.getRepository(BookingContainer)
|
|
.find({
|
|
where: { bookingId: splitBookingId, containerSize },
|
|
withDeleted: true,
|
|
select: { id: true },
|
|
});
|
|
const lineIds = lines.map((l) => l.id);
|
|
if (!lineIds.length) return [];
|
|
|
|
// Only the DELETED units are the deferred ones (live units stayed on the
|
|
// paid part). Oldest-first to match the deferred set.
|
|
const deferred = await this.dataSource
|
|
.getRepository(BookingContainerUnit)
|
|
.find({
|
|
where: lineIds.map((bookingContainerId) => ({
|
|
bookingContainerId,
|
|
deletedAt: Not(IsNull()),
|
|
})),
|
|
withDeleted: true,
|
|
order: { sortOrder: 'ASC', createdAt: 'ASC' },
|
|
take: need,
|
|
});
|
|
|
|
return deferred.map((u) => ({
|
|
containerNumber: u.containerNumber,
|
|
sealNumber: u.sealNumber ?? undefined,
|
|
vgmTons: Number(u.vgmTons),
|
|
isHazardous: u.isHazardous,
|
|
isReefer: u.isReefer,
|
|
}));
|
|
}
|
|
}
|