mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 19:28:17 +00:00
split export
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user