mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
split export
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
import { ContractRateScheduleBuilder } from './contract-rate-schedule.builder';
|
||||
import { Rate } from '../modules/rule-engine/entities/rate.entity';
|
||||
|
||||
/** Minimal Rate factory for the builder unit tests. */
|
||||
function rate(partial: Partial<Rate>): Rate {
|
||||
return {
|
||||
trigger: 'ALWAYS',
|
||||
appliesTo: 'CONTAINER',
|
||||
tradeDirection: 'IMPORT',
|
||||
rateType: 'CONTAINER_IMPORT',
|
||||
currency: 'USD',
|
||||
rateValue: 200,
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
...partial,
|
||||
} as Rate;
|
||||
}
|
||||
|
||||
describe('ContractRateScheduleBuilder', () => {
|
||||
const LIVE: Rate[] = [
|
||||
rate({
|
||||
appliesTo: 'CONTAINER',
|
||||
tradeDirection: 'IMPORT',
|
||||
rateType: 'CONTAINER_IMPORT',
|
||||
rateValue: 200,
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
originYard: { label: 'Negad' } as never,
|
||||
destinationYard: { label: 'Mojo Dry Port' } as never,
|
||||
containerType: { label: '40ft GP' } as never,
|
||||
}),
|
||||
rate({
|
||||
appliesTo: 'CONTAINER',
|
||||
tradeDirection: 'EXPORT', // wrong direction — must be filtered out for import
|
||||
rateType: 'CONTAINER_EXPORT',
|
||||
rateValue: 819,
|
||||
originYard: { label: 'GMP' } as never,
|
||||
destinationYard: { label: 'SGTD' } as never,
|
||||
}),
|
||||
rate({
|
||||
appliesTo: 'BULK', // wrong freight — filtered out for a container contract
|
||||
tradeDirection: 'IMPORT',
|
||||
rateType: 'BULK_IMPORT',
|
||||
rateUnit: 'PER_WAGON',
|
||||
rateValue: 100,
|
||||
}),
|
||||
rate({
|
||||
appliesTo: 'FIRST_MILE',
|
||||
trigger: 'ALWAYS',
|
||||
tradeDirection: null,
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
rateValue: 50,
|
||||
}),
|
||||
rate({
|
||||
appliesTo: 'OTHER',
|
||||
trigger: 'CUSTOMS_CLEARANCE',
|
||||
tradeDirection: null,
|
||||
rateType: 'CUSTOMS_CLEARANCE',
|
||||
rateUnit: 'FLAT',
|
||||
rateValue: 120,
|
||||
}),
|
||||
];
|
||||
|
||||
const build = (dir: 'IMP' | 'EXP' | 'DOM', freight: 'CON' | 'BULK') => {
|
||||
const service = { findLiveRatesDetailed: jest.fn().mockResolvedValue(LIVE) };
|
||||
return new ContractRateScheduleBuilder(service as never).build(dir, freight);
|
||||
};
|
||||
|
||||
it('shows only import container lanes for an import container contract', async () => {
|
||||
const s = await build('IMP', 'CON');
|
||||
expect(s.freightLanes).toHaveLength(1);
|
||||
expect(s.freightLanes[0]).toMatchObject({
|
||||
route: 'Negad → Mojo Dry Port',
|
||||
cargo: '40ft GP',
|
||||
currency: 'USD',
|
||||
amount: '200',
|
||||
unit: 'per container',
|
||||
});
|
||||
});
|
||||
|
||||
it('always lists route-agnostic services and surcharges', async () => {
|
||||
const s = await build('IMP', 'CON');
|
||||
expect(s.additionalServices).toHaveLength(1);
|
||||
expect(s.additionalServices[0].route).toBe('First-mile pickup by truck');
|
||||
expect(s.surcharges).toHaveLength(1);
|
||||
expect(s.surcharges[0].route).toBe('Customs clearance service');
|
||||
});
|
||||
|
||||
it('excludes container lanes from a bulk contract', async () => {
|
||||
const s = await build('IMP', 'BULK');
|
||||
expect(s.freightLanes).toHaveLength(1);
|
||||
expect(s.freightLanes[0]).toMatchObject({ amount: '100', unit: 'per wagon' });
|
||||
});
|
||||
|
||||
it('flags an empty schedule when nothing priced matches', async () => {
|
||||
const service = { findLiveRatesDetailed: jest.fn().mockResolvedValue([]) };
|
||||
const s = await new ContractRateScheduleBuilder(service as never).build('DOM', 'CON');
|
||||
expect(s.isEmpty).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
import { CONTRACT_TEMPLATE_DEFAULTS } from '../seed/data/contract-template-defaults';
|
||||
|
||||
/**
|
||||
* Refresh the `pricing` article body of the six seeded contract templates to
|
||||
* the live-rate-schedule wording. The per-lane figures (e.g. "USD 400 per
|
||||
* wagon") are now rendered from the LIVE rate config instead of frozen prose,
|
||||
* so any template whose pricing article still carries a hardcoded price token
|
||||
* is rewritten to the current seed text.
|
||||
*
|
||||
* The guard `body ~ '(USD|ETB) [0-9]'` identifies the auto-seeded original
|
||||
* prose (which always quoted a currency + figure) and matches neither an
|
||||
* already-migrated body nor a hand-edited one that adopted the schedule
|
||||
* wording — so admin edits are preserved. Idempotent: after the rewrite the
|
||||
* price token is gone, so a re-run is a no-op. Fresh databases seed the new
|
||||
* text directly (CreateContractTemplates imports the same seed), making this
|
||||
* a targeted backfill for databases seeded before the seed changed.
|
||||
*/
|
||||
const HARDCODED_PRICE_TOKEN = '(USD|ETB) [0-9]';
|
||||
|
||||
export class RefreshContractPricingArticles2360000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
for (const seed of CONTRACT_TEMPLATE_DEFAULTS) {
|
||||
const pricing = seed.articles.find((a) => a.id === 'pricing');
|
||||
if (!pricing) continue;
|
||||
|
||||
// Rewrite only the article whose id = 'pricing', in place, and only when
|
||||
// its body still quotes a hardcoded currency figure. jsonb_agg keeps the
|
||||
// rest of the article (id/title/order) and every other article intact.
|
||||
await queryRunner.query(
|
||||
`
|
||||
UPDATE freight.contract_templates AS t
|
||||
SET articles = (
|
||||
SELECT jsonb_agg(
|
||||
CASE
|
||||
WHEN elem->>'id' = 'pricing'
|
||||
THEN jsonb_set(elem, '{body}', to_jsonb($2::text), true)
|
||||
ELSE elem
|
||||
END
|
||||
ORDER BY ord
|
||||
)
|
||||
FROM jsonb_array_elements(t.articles) WITH ORDINALITY AS a(elem, ord)
|
||||
),
|
||||
updated_at = now()
|
||||
WHERE t.code = $1
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements(t.articles) AS x
|
||||
WHERE x->>'id' = 'pricing'
|
||||
AND x->>'body' ~ $3
|
||||
);
|
||||
`,
|
||||
[seed.code, pricing.body, HARDCODED_PRICE_TOKEN],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Irreversible in practice — the original per-lane figures are not restored.
|
||||
* A no-op down keeps the migration reversible-by-contract without
|
||||
* resurrecting stale hardcoded prices.
|
||||
*/
|
||||
public async down(): Promise<void> {
|
||||
// intentionally empty
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ export class BookingTransitionService {
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly ruleEngineService: RuleEngineService,
|
||||
private readonly pricingService: BookingPricingService,
|
||||
@Inject(forwardRef(() => BookingContractService))
|
||||
private readonly contractService: BookingContractService,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
||||
@@ -1049,7 +1050,25 @@ export class BookingTransitionService {
|
||||
booking.tradeDirection === "EXPORT" &&
|
||||
!isRoadService(booking.serviceType);
|
||||
if (isExportTrain) {
|
||||
await this.bookingBatchService.pickExportSchedule(scheduledBooking);
|
||||
// With export split ON the booking no longer has to ride ONE train whole:
|
||||
// the largest fitting part is offered and the leftover rebooks on the next
|
||||
// train. So the day is only unbookable when NO export train that day has
|
||||
// any room at all — reject on the day total, not on a single-train fit.
|
||||
// With the flag off this stays the strict whole-booking gate.
|
||||
if (process.env.FREIGHT_EXPORT_SPLIT === "true") {
|
||||
const fitting = await this.bookingBatchService.fittingTrainsForDay(
|
||||
scheduledBooking,
|
||||
eatDay(date),
|
||||
"EXPORT",
|
||||
);
|
||||
if (!fitting.length) {
|
||||
throw new ConflictException(
|
||||
"No export train on this day has space left — pick another shipment day.",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await this.bookingBatchService.pickExportSchedule(scheduledBooking);
|
||||
}
|
||||
}
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
|
||||
@@ -25,6 +25,7 @@ import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
|
||||
import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
|
||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
||||
@@ -54,6 +55,18 @@ export interface CreateBookingUnderContractResult {
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Outstanding split remainder of a contract: what was booked in the first split
|
||||
* booking's pre-split snapshot MINUS everything currently booked. Container
|
||||
* contracts report per size; bulk reports one tonnage figure. `null` when the
|
||||
* contract has no live split chain. Consumed by the remainder-placement engine
|
||||
* to size the auto-created remainder booking.
|
||||
*/
|
||||
export type SplitOutstanding = {
|
||||
bySize: Map<string, { total: number; outstanding: number }>;
|
||||
bulk: { total: number; outstanding: number } | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The single create path for shipment bookings under a contract.
|
||||
*
|
||||
@@ -966,9 +979,11 @@ export class ContractBookingService {
|
||||
* (CANCELLED / REJECTED / EXPIRED) release their share. Null when the
|
||||
* contract has no live split booking.
|
||||
*/
|
||||
private async splitOutstanding(
|
||||
contract: Contract,
|
||||
): Promise<{ bySize: Map<string, { total: number; outstanding: number }>; bulk: { total: number; outstanding: number } | null } | null> {
|
||||
/**
|
||||
* Public: the remainder-placement engine reads this to size the auto-created
|
||||
* remainder booking. Returns `null` when there is no live split chain.
|
||||
*/
|
||||
async splitOutstanding(contract: Contract): Promise<SplitOutstanding | null> {
|
||||
const first = await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.createQueryBuilder('b')
|
||||
@@ -1015,6 +1030,25 @@ export class ContractBookingService {
|
||||
const probe = await this.buildExportProbe(contract, route, dto, yards);
|
||||
const report = await this.bookingBatchService.exportSpaceReport(probe);
|
||||
if (report.scheduleId) return;
|
||||
|
||||
// With export split ON a booking no longer has to ride ONE train whole: the
|
||||
// largest fitting part is offered and the leftover is rebooked on the next
|
||||
// train. Rejecting on the single-train fit here would block exactly the
|
||||
// bookings the split exists to serve — including the auto-created remainder,
|
||||
// which by definition did not fit the train it was split off. Fall back to
|
||||
// the day total: unbookable only when NO export train that day has room.
|
||||
if (process.env.FREIGHT_EXPORT_SPLIT === 'true') {
|
||||
const fitting = await this.bookingBatchService.fittingTrainsForDay(
|
||||
probe,
|
||||
eatDay(new Date(dto.scheduledDate)),
|
||||
'EXPORT',
|
||||
);
|
||||
if (fitting.length > 0) return;
|
||||
throw new BadRequestException(
|
||||
'No export train on this day has space left — pick another shipment day.',
|
||||
);
|
||||
}
|
||||
|
||||
throw new BadRequestException(
|
||||
report.fullMessage ?? 'Not enough train space for this day.',
|
||||
);
|
||||
@@ -1636,6 +1670,8 @@ export class ContractBookingService {
|
||||
isGovernment: contract.isGovernment,
|
||||
shippingLineId: null,
|
||||
contractRouteId: route?.id ?? null,
|
||||
originYardId: route?.originYardId ?? null,
|
||||
destinationYardId: route?.destinationYardId ?? null,
|
||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
|
||||
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
|
||||
|
||||
@@ -74,9 +74,14 @@ export class CreateRateDto {
|
||||
@Transform(({ value }) => Number(value))
|
||||
rateValue!: number;
|
||||
|
||||
@ApiProperty({ enum: RATE_UNITS, description: 'Unit basis for the rate' })
|
||||
@ApiPropertyOptional({
|
||||
enum: RATE_UNITS,
|
||||
description:
|
||||
'Unit basis for the rate. Optional for shapes with a forced unit (overweight is always PER_TON — the admin form hides the field and omits it); required otherwise.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn([...RATE_UNITS])
|
||||
rateUnit!: string;
|
||||
rateUnit?: string;
|
||||
}
|
||||
|
||||
export class SubmitRateForApprovalDto {
|
||||
|
||||
@@ -68,15 +68,21 @@ export class RatesService {
|
||||
private resolveRateUnit(
|
||||
appliesTo: Rate['appliesTo'],
|
||||
trigger: Rate['trigger'],
|
||||
requestedUnit: Rate['rateUnit'],
|
||||
requestedUnit: Rate['rateUnit'] | undefined,
|
||||
): Rate['rateUnit'] {
|
||||
// Overweight is per-ton, full stop.
|
||||
// Overweight is per-ton, full stop — the admin form hides the unit field
|
||||
// for it and omits rateUnit from the payload entirely.
|
||||
if (trigger === 'OVERWEIGHT') return 'PER_TON';
|
||||
|
||||
if (!isRateUnitAllowed({ appliesTo, trigger, unit: requestedUnit })) {
|
||||
const allowed = allowedRateUnits({ appliesTo, trigger }).join(', ');
|
||||
const allowed = allowedRateUnits({ appliesTo, trigger });
|
||||
if (!requestedUnit) {
|
||||
throw new BadRequestException(
|
||||
`Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed}.`,
|
||||
`Pick a rate unit for this rate. Allowed: ${allowed.join(', ')}.`,
|
||||
);
|
||||
}
|
||||
if (!isRateUnitAllowed({ appliesTo, trigger, unit: requestedUnit })) {
|
||||
throw new BadRequestException(
|
||||
`Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed.join(', ')}.`,
|
||||
);
|
||||
}
|
||||
return requestedUnit;
|
||||
@@ -272,7 +278,11 @@ export class RatesService {
|
||||
tradeDirection,
|
||||
isBulk: this.resolvesToBulk(appliesTo, intercityKind),
|
||||
});
|
||||
const rateUnit = this.resolveRateUnit(appliesTo, trigger, dto.rateUnit as Rate['rateUnit']);
|
||||
const rateUnit = this.resolveRateUnit(
|
||||
appliesTo,
|
||||
trigger,
|
||||
dto.rateUnit as Rate['rateUnit'] | undefined,
|
||||
);
|
||||
|
||||
await this.assertNoDuplicatePattern({
|
||||
rateType,
|
||||
|
||||
@@ -71,6 +71,7 @@ import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
||||
import { BookingSplitService } from './booking-split.service';
|
||||
import { RemainderPlacementService } from './remainder-placement.service';
|
||||
import { BookingWindowGateway } from './booking-window.gateway';
|
||||
import {
|
||||
MAX_TEU_SLOTS_PER_WAGON,
|
||||
@@ -317,9 +318,29 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||
@Optional() private readonly splitService?: BookingSplitService,
|
||||
@Optional()
|
||||
@Inject(forwardRef(() => RemainderPlacementService))
|
||||
private readonly remainderPlacement?: RemainderPlacementService,
|
||||
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Auto-place a paid booking's split remainder onto the next fitting train.
|
||||
* Gated so it can ship dark: off unless FREIGHT_AUTO_REMAINDER=true.
|
||||
*/
|
||||
private get autoRemainderEnabled(): boolean {
|
||||
return process.env.FREIGHT_AUTO_REMAINDER === "true";
|
||||
}
|
||||
|
||||
/**
|
||||
* Let EXPORT bookings split (offer the largest fitting part, leftover rebooks
|
||||
* on the next train). Separate flag from auto-remainder: export touches the
|
||||
* FCFS money path, so partial-offer can be enabled independently.
|
||||
*/
|
||||
private get exportSplitEnabled(): boolean {
|
||||
return process.env.FREIGHT_EXPORT_SPLIT === "true";
|
||||
}
|
||||
|
||||
/** On boot, reconcile OPEN route-days and re-arm settle timers. */
|
||||
async onModuleInit(): Promise<void> {
|
||||
const groups = await this.openRouteDayGroups();
|
||||
@@ -496,6 +517,34 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// to the offered part before it boards (remainder returns to the contract cap).
|
||||
if (this.splitService) {
|
||||
await this.splitService.applySplit(bookingId);
|
||||
|
||||
// The split only happens on payment (here) — so auto-placing the remainder
|
||||
// also only happens once the customer has accepted+paid. Re-read to see if
|
||||
// applySplit actually reduced this booking (an open offer existed); if so,
|
||||
// auto-create + place the remainder booking on the next fitting train.
|
||||
// applySplit committed its own transaction before returning, so this reads
|
||||
// the reduced lines. Best-effort: a placement failure never blocks the
|
||||
// paid booking from boarding — the remainder falls back to manual rebook.
|
||||
if (this.autoRemainderEnabled && this.remainderPlacement) {
|
||||
const split = await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.findOne({ where: { id: bookingId } });
|
||||
// Export remainders only auto-place when export split is on — otherwise
|
||||
// an export booking never splits in the first place.
|
||||
const directionOn =
|
||||
split?.tradeDirection !== "EXPORT" || this.exportSplitEnabled;
|
||||
if (split?.isSplit && directionOn) {
|
||||
await this.remainderPlacement
|
||||
.placeRemainder(split)
|
||||
.catch((err) =>
|
||||
this.logger.error(
|
||||
`Auto-place remainder failed for ${split.reference}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const linked =
|
||||
@@ -731,6 +780,76 @@ export class BookingBatchService implements OnModuleInit {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trains that can carry a booking's leg on a given day, earliest departure
|
||||
* first, each with the largest number of wagons it could still admit for the
|
||||
* booking's wagon type. Direction-filtered: EXPORT bookings see export trains,
|
||||
* IMPORT/DOMESTIC see non-export trains. Measures against the booking's FULL
|
||||
* allowed wagon-type set ({@link dimsForAllowed}) so a train stocking a
|
||||
* non-primary allowed type still counts. The remainder placer uses this to
|
||||
* pick the next fitting train; the `free` wagon count is the best across the
|
||||
* allowed types (a train fits under whichever allowed type gives most room).
|
||||
*/
|
||||
async fittingTrainsForDay(
|
||||
booking: Booking,
|
||||
day: string,
|
||||
direction: "IMPORT" | "EXPORT",
|
||||
): Promise<Array<{ scheduleId: string; departure: Date; freeWagons: number }>> {
|
||||
const corridor = await this.trainSchedulesRepository.findAll({
|
||||
where: [
|
||||
{ status: TrainScheduleStatusEnum.Draft },
|
||||
{ status: TrainScheduleStatusEnum.Scheduled },
|
||||
],
|
||||
});
|
||||
const candidates = corridor
|
||||
.filter(
|
||||
(s) =>
|
||||
s.scheduledDepartureDate != null &&
|
||||
eatDay(s.scheduledDepartureDate) === day &&
|
||||
s.bookingWindowStatus !== "FULL" &&
|
||||
(direction === "EXPORT"
|
||||
? s.direction === "EXPORT"
|
||||
: s.direction !== "EXPORT"),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.scheduledDepartureDate!.getTime() -
|
||||
b.scheduledDepartureDate!.getTime(),
|
||||
);
|
||||
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const dimsOptions = this.dimsForAllowed(booking, wagonDims);
|
||||
const out: Array<{ scheduleId: string; departure: Date; freeWagons: number }> = [];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||
candidate.id,
|
||||
);
|
||||
const locomotive = schedule?.trainSet?.locomotive;
|
||||
if (!schedule || !locomotive) continue;
|
||||
const limits = await this.capacityLimits(locomotive);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
|
||||
if (!leg) continue; // this train's route doesn't carry the booking's leg
|
||||
const room = budget.remainingFor(leg);
|
||||
// Best usable wagons across the allowed types — a train fits under
|
||||
// whichever configured wagon type gives it the most room.
|
||||
let freeWagons = 0;
|
||||
for (const dims of dimsOptions) {
|
||||
const w = this.bookableWithin(room, dims).wagons;
|
||||
if (w > freeWagons) freeWagons = w;
|
||||
}
|
||||
if (freeWagons > 0) {
|
||||
out.push({
|
||||
scheduleId: schedule.id,
|
||||
departure: schedule.scheduledDepartureDate!,
|
||||
freeWagons,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advisory free-wagon count for an IMPORT/DOMESTIC booking on a given day,
|
||||
* summed across every train on the booking's corridor that day. Unlike the
|
||||
@@ -789,6 +908,58 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return { freeWagons, need, trainsForDay };
|
||||
}
|
||||
|
||||
/**
|
||||
* Export split: no single train carries the whole booking, so offer the
|
||||
* largest fitting part on the export train with the most room for its leg.
|
||||
* Returns true when an offer was opened (the caller must NOT then reserve —
|
||||
* the offer already opened its own pay window), false when the booking fits
|
||||
* whole somewhere (normal FCFS path) or no meaningful partial exists.
|
||||
*
|
||||
* Only the offer is written here: the booking is reduced to the offered part
|
||||
* on payment (applySplit), and the leftover is auto-placed afterwards. So an
|
||||
* unpaid export booking stays whole and the customer may still cancel it.
|
||||
*/
|
||||
private async tryExportPartialOffer(booking: Booking): Promise<boolean> {
|
||||
if (!this.splitService) return false;
|
||||
const report = await this.exportSpaceReport(booking);
|
||||
// A train fits it whole — nothing to split, take the normal path.
|
||||
if (report.scheduleId) return false;
|
||||
if (!report.bestAvailable || report.bestAvailable.wagons < 1) return false;
|
||||
|
||||
if (!booking.scheduledDate) return false;
|
||||
const day = eatDay(new Date(booking.scheduledDate));
|
||||
const fitting = await this.fittingTrainsForDay(booking, day, "EXPORT");
|
||||
if (!fitting.length) return false;
|
||||
// Most room first — the largest single part ships now, the smallest leftover
|
||||
// is what has to find another train.
|
||||
const target = [...fitting].sort((a, b) => b.freeWagons - a.freeWagons)[0];
|
||||
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||
target.scheduleId,
|
||||
);
|
||||
const locomotive = schedule?.trainSet?.locomotive;
|
||||
if (!schedule || !locomotive) return false;
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const limits = await this.capacityLimits(locomotive);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
|
||||
if (!leg) return false;
|
||||
|
||||
const offered = await this.tryPartialOffer(
|
||||
booking,
|
||||
schedule.id,
|
||||
budget.remainingFor(leg),
|
||||
report.need,
|
||||
);
|
||||
if (!offered) return false;
|
||||
this.logger.log(
|
||||
`[EXPORT SPLIT] offered partial to ${booking.reference} on schedule ` +
|
||||
`${schedule.id} — leftover rebooks on the next train once paid.`,
|
||||
);
|
||||
this.notifyBoardChanged(schedule.id, "batch_fill");
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept an export booking into the FCFS flow. Solo bookings reserve immediately.
|
||||
* A consolidated booking reserves as a pair only once BOTH partners are ready
|
||||
@@ -800,6 +971,15 @@ export class BookingBatchService implements OnModuleInit {
|
||||
async acceptExportBooking(booking: Booking): Promise<void> {
|
||||
const partnerId = booking.consolidationPartnerId ?? null;
|
||||
if (!partnerId) {
|
||||
// Export split: when no single train carries the whole booking, offer the
|
||||
// largest fitting part instead of failing the accept. The customer pays
|
||||
// that part; on payment applySplit reduces this booking to it and the
|
||||
// leftover is auto-placed as its own booking on the next train. Pairs are
|
||||
// excluded (handled below) — a shared wagon is never split.
|
||||
if (this.exportSplitEnabled && this.isSplitEligible(booking, false)) {
|
||||
const offered = await this.tryExportPartialOffer(booking);
|
||||
if (offered) return;
|
||||
}
|
||||
const scheduleId = await this.pickExportSchedule(booking);
|
||||
await this.reserveOnExport([booking], scheduleId);
|
||||
return;
|
||||
@@ -1852,15 +2032,23 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
|
||||
/**
|
||||
* A lone commercial IMPORT booking on a GENERAL or ONE_TIME contract may be
|
||||
* offered a partial (split-on-payment). Consolidated pairs never split (both-or-
|
||||
* neither shared wagon) and government bookings never split (they preempt).
|
||||
* A lone commercial booking on a GENERAL or ONE_TIME contract may be offered a
|
||||
* partial (split-on-payment). Consolidated pairs never split (both-or-neither
|
||||
* shared wagon) and government bookings never split (they preempt).
|
||||
*
|
||||
* IMPORT is always eligible. EXPORT is eligible only when export split is
|
||||
* enabled: export historically rides one train whole, so splitting it changes
|
||||
* the FCFS money path — each split part still rides ONE train whole, and the
|
||||
* leftover becomes its own booking on the next train.
|
||||
*/
|
||||
private isSplitEligible(booking: Booking, isPair: boolean): boolean {
|
||||
const directionOk =
|
||||
booking.tradeDirection === "IMPORT" ||
|
||||
(booking.tradeDirection === "EXPORT" && this.exportSplitEnabled);
|
||||
return (
|
||||
!isPair &&
|
||||
!booking.isGovernment &&
|
||||
booking.tradeDirection === "IMPORT" &&
|
||||
directionOk &&
|
||||
(booking.contractKind === "GENERAL" || booking.contractKind === "ONE_TIME") &&
|
||||
this.splitService != null
|
||||
);
|
||||
@@ -3174,6 +3362,40 @@ export class BookingBatchService implements OnModuleInit {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* EVERY wagon-type dimension a booking may ride — its cargo/container type's
|
||||
* full allowed (many-to-many) wagon-type list, not just the first like
|
||||
* {@link dimsFor}. The remainder placer needs the whole set so a train that
|
||||
* stocks a non-primary allowed type still counts as fitting: a container type
|
||||
* mapped to both NW5 and (say) NW7 must be measured against whichever a given
|
||||
* train actually has free. Deduped by wagon-type id; falls back to the single
|
||||
* representative dims when no allowed type is configured.
|
||||
*/
|
||||
private dimsForAllowed(booking: Booking, wagonDims: WagonDims): PerWagonDims[] {
|
||||
const fallback =
|
||||
booking.freightType === "BULK" ? wagonDims.bulk : wagonDims.container;
|
||||
const ids =
|
||||
booking.freightType === "BULK"
|
||||
? (booking.cargoType?.wagonTypes ?? []).map((wt) => wt.id)
|
||||
: (booking.bookingContainers ?? [])
|
||||
.flatMap((line) => line.containerType?.wagonTypes ?? [])
|
||||
.map((wt) => wt.id);
|
||||
const seen = new Set<string>();
|
||||
const dims: PerWagonDims[] = [];
|
||||
for (const id of ids) {
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
const d = wagonDims.byWagonTypeId.get(id);
|
||||
if (d) {
|
||||
dims.push({
|
||||
...d,
|
||||
capacityTons: d.capacityTons > 0 ? d.capacityTons : fallback.capacityTons,
|
||||
});
|
||||
}
|
||||
}
|
||||
return dims.length ? dims : [fallback];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered stop yards of the schedule's route (origin → milestones →
|
||||
* destination); the legacy two-stop pseudo-route when milestones are absent.
|
||||
|
||||
@@ -150,10 +150,18 @@ export class BookingNotifierService {
|
||||
): Promise<void> {
|
||||
const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000));
|
||||
const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' });
|
||||
const leftover = totalWagons - offeredWagons;
|
||||
// With auto-placement on, the leftover is booked FOR the customer on another
|
||||
// train (its own invoice) — telling them to rebook it themselves would be
|
||||
// wrong. Without it, the leftover returns to the contract to rebook.
|
||||
const leftoverCopy =
|
||||
process.env.FREIGHT_AUTO_REMAINDER === 'true'
|
||||
? `The remaining ${leftover} will be booked for you on another train, with its own invoice. `
|
||||
: `The remaining ${leftover} return${leftover === 1 ? 's' : ''} to your contract — book them yourself in a later window. `;
|
||||
const msg =
|
||||
`Only ${offeredWagons} of ${totalWagons} wagons fit the train for booking ${b.reference ?? b.id}. ` +
|
||||
`Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to accept and ship ${offeredWagons} wagon${offeredWagons === 1 ? '' : 's'} now. ` +
|
||||
`The remaining ${totalWagons - offeredWagons} return${totalWagons - offeredWagons === 1 ? 's' : ''} to your contract — book them yourself in a later window. ` +
|
||||
leftoverCopy +
|
||||
`If you do not pay, the booking stays whole and you can rebook in the next window. Deadline: ${eat} EAT.`;
|
||||
await this.notifyContact(b, msg, 'PAY NOW (PARTIAL)');
|
||||
// HIGH: a split is a change to what the customer ordered AND a live payment
|
||||
@@ -164,6 +172,23 @@ export class BookingNotifierService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The wagons that did not fit the train the customer just paid for have been
|
||||
* auto-booked as their own booking (`remainder`) — they ride another train and
|
||||
* are billed separately. Sent instead of leaving the customer to rebook.
|
||||
*/
|
||||
remainderPlaced(remainder: Booking, parentReference: string): void {
|
||||
const msg =
|
||||
`The wagons left over from booking ${parentReference} have been booked as ` +
|
||||
`${remainder.reference ?? remainder.id} on another train. ` +
|
||||
`It carries its own invoice — pay it to secure that slot.`;
|
||||
void this.notifyContact(remainder, msg, 'REMAINDER BOOKED');
|
||||
this.inApp(remainder, 'Leftover wagons booked', msg, {
|
||||
type: NotificationType.INVOICE_ISSUED,
|
||||
priority: NotificationPriority.HIGH,
|
||||
});
|
||||
}
|
||||
|
||||
secured(b: Booking, reason: 'paid' | 'gov', scheduleId?: string | null): void {
|
||||
void (async () => {
|
||||
const label = await this.scheduleLabel(scheduleId ?? b.trainScheduleId);
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { RemainderPlacementService } from './remainder-placement.service';
|
||||
|
||||
/**
|
||||
* The remainder placer reconstructs the outstanding split remainder as a new
|
||||
* booking. The delicate parts under test: bulk sizes from the outstanding tons;
|
||||
* container recovers real numbers from the SOFT-DELETED units (never fabricates)
|
||||
* and throws on a shortfall; and nothing is placed when there's no outstanding
|
||||
* or no fitting train.
|
||||
*/
|
||||
describe('RemainderPlacementService', () => {
|
||||
const DAY = '2026-07-20';
|
||||
|
||||
function make(opts: {
|
||||
freightType: 'CONTAINER' | 'BULK';
|
||||
contractKind?: 'ONE_TIME' | 'GENERAL';
|
||||
outstanding: unknown;
|
||||
createThrows?: Error;
|
||||
deferredUnits?: Array<{
|
||||
containerNumber: string;
|
||||
vgmTons: number;
|
||||
isHazardous?: boolean;
|
||||
isReefer?: boolean;
|
||||
}>;
|
||||
fittingTrains?: Array<{ scheduleId: string }>;
|
||||
}) {
|
||||
const contract = {
|
||||
id: 'c-1',
|
||||
freightType: opts.freightType,
|
||||
contractKind: opts.contractKind ?? 'ONE_TIME',
|
||||
};
|
||||
const contractsRepository = {
|
||||
findByIdWithRelations: jest.fn().mockResolvedValue(contract),
|
||||
};
|
||||
const createUnderContract = opts.createThrows
|
||||
? jest.fn().mockRejectedValue(opts.createThrows)
|
||||
: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ booking: { id: 'rem-1', reference: 'BKG-R' }, warnings: [] });
|
||||
const contractBookingService = {
|
||||
splitOutstanding: jest.fn().mockResolvedValue(opts.outstanding),
|
||||
createUnderContract,
|
||||
};
|
||||
const bookingBatchService = {
|
||||
fittingTrainsForDay: jest
|
||||
.fn()
|
||||
.mockResolvedValue(opts.fittingTrains ?? [{ scheduleId: 's-2' }]),
|
||||
};
|
||||
// getRepository is only hit on the container path (recoverDeferredUnits).
|
||||
const lineRepo = {
|
||||
find: jest.fn().mockResolvedValue([{ id: 'line-1' }]),
|
||||
};
|
||||
const unitRepo = {
|
||||
find: jest.fn().mockResolvedValue(opts.deferredUnits ?? []),
|
||||
};
|
||||
const dataSource = {
|
||||
getRepository: jest.fn((entity: { name?: string }) => {
|
||||
const n = entity?.name ?? '';
|
||||
if (n.includes('Unit')) return unitRepo;
|
||||
return lineRepo;
|
||||
}),
|
||||
};
|
||||
const notifier = { remainderPlaced: jest.fn() };
|
||||
const service = new RemainderPlacementService(
|
||||
dataSource as never,
|
||||
contractsRepository as never,
|
||||
contractBookingService as never,
|
||||
bookingBatchService as never,
|
||||
notifier as never,
|
||||
);
|
||||
return {
|
||||
service,
|
||||
createUnderContract,
|
||||
contractBookingService,
|
||||
bookingBatchService,
|
||||
notifier,
|
||||
};
|
||||
}
|
||||
|
||||
const splitBooking = {
|
||||
id: 'bk-1',
|
||||
reference: 'BKG-1',
|
||||
contractId: 'c-1',
|
||||
scheduledDate: new Date('2026-07-20T06:00:00Z'),
|
||||
createdByUserId: 'u-1',
|
||||
} as never;
|
||||
|
||||
it('sizes a BULK remainder from the outstanding tons', async () => {
|
||||
const { service, createUnderContract } = make({
|
||||
freightType: 'BULK',
|
||||
outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } },
|
||||
});
|
||||
const id = await service.placeRemainder(splitBooking);
|
||||
expect(id).toBe('rem-1');
|
||||
const dto = createUnderContract.mock.calls[0][1];
|
||||
expect(dto.bulkLines).toEqual([{ cargoWeightTons: 40 }]);
|
||||
expect(dto.scheduledDate).toBe(DAY);
|
||||
});
|
||||
|
||||
it('rebuilds a CONTAINER remainder from the soft-deleted units', async () => {
|
||||
const deferredUnits = [
|
||||
{ containerNumber: 'ABCD1234567', vgmTons: 12, isReefer: true },
|
||||
{ containerNumber: 'ABCD7654321', vgmTons: 10, isHazardous: true },
|
||||
];
|
||||
const { service, createUnderContract } = make({
|
||||
freightType: 'CONTAINER',
|
||||
outstanding: {
|
||||
bySize: new Map([['40ft', { total: 5, outstanding: 2 }]]),
|
||||
bulk: null,
|
||||
},
|
||||
deferredUnits,
|
||||
});
|
||||
const id = await service.placeRemainder(splitBooking);
|
||||
expect(id).toBe('rem-1');
|
||||
const dto = createUnderContract.mock.calls[0][1];
|
||||
expect(dto.containers).toHaveLength(1);
|
||||
const line = dto.containers[0];
|
||||
expect(line.containerSize).toBe('40ft');
|
||||
expect(line.quantity).toBe(2);
|
||||
expect(line.units.map((u: { containerNumber: string }) => u.containerNumber)).toEqual([
|
||||
'ABCD1234567',
|
||||
'ABCD7654321',
|
||||
]);
|
||||
expect(line.reeferQuantity).toBe(1);
|
||||
expect(line.hazardousQuantity).toBe(1);
|
||||
});
|
||||
|
||||
it('throws (→ no placement) when fewer units are recoverable than outstanding — never fabricates', async () => {
|
||||
const { service, createUnderContract } = make({
|
||||
freightType: 'CONTAINER',
|
||||
outstanding: {
|
||||
bySize: new Map([['40ft', { total: 5, outstanding: 3 }]]),
|
||||
bulk: null,
|
||||
},
|
||||
deferredUnits: [{ containerNumber: 'ABCD1234567', vgmTons: 12 }], // only 1, need 3
|
||||
});
|
||||
const id = await service.placeRemainder(splitBooking);
|
||||
expect(id).toBeNull();
|
||||
expect(createUnderContract).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is a no-op when there is no outstanding remainder', async () => {
|
||||
const { service, createUnderContract } = make({
|
||||
freightType: 'BULK',
|
||||
outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 0 } },
|
||||
});
|
||||
const id = await service.placeRemainder(splitBooking);
|
||||
expect(id).toBeNull();
|
||||
expect(createUnderContract).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('tells the customer the leftover wagons were booked on another train', async () => {
|
||||
const { service, notifier } = make({
|
||||
freightType: 'BULK',
|
||||
outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } },
|
||||
});
|
||||
await service.placeRemainder(splitBooking);
|
||||
expect(notifier.remainderPlaced).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'rem-1' }),
|
||||
'BKG-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('never double-books the leftover when two payments land together', async () => {
|
||||
const { service, createUnderContract } = make({
|
||||
freightType: 'BULK',
|
||||
outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } },
|
||||
});
|
||||
// Both callers enter before either create commits.
|
||||
await Promise.all([
|
||||
service.placeRemainder(splitBooking),
|
||||
service.placeRemainder(splitBooking),
|
||||
]);
|
||||
expect(createUnderContract).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// splitOutstanding subtracts a CONTRACT-WIDE booked total from ONE booking's
|
||||
// snapshot — coherent only for ONE_TIME. On GENERAL that mixes scopes and
|
||||
// either drops a real remainder or double-draws the cap, so we must not place.
|
||||
it('never auto-places on a GENERAL contract (cap ledger mismatch)', async () => {
|
||||
const { service, createUnderContract, contractBookingService } = make({
|
||||
freightType: 'BULK',
|
||||
contractKind: 'GENERAL',
|
||||
outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } },
|
||||
});
|
||||
const id = await service.placeRemainder(splitBooking);
|
||||
expect(id).toBeNull();
|
||||
expect(createUnderContract).not.toHaveBeenCalled();
|
||||
expect(contractBookingService.splitOutstanding).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The paid booking has already boarded — a create-gate rejection (e.g. the
|
||||
// export whole-train gate) must leave the remainder rebookable, not escape.
|
||||
it('swallows a create rejection and leaves the remainder for manual rebook', async () => {
|
||||
const { service } = make({
|
||||
freightType: 'BULK',
|
||||
outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } },
|
||||
createThrows: new Error('Not enough train space for this day.'),
|
||||
});
|
||||
await expect(service.placeRemainder(splitBooking)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('is a no-op when the contract has no split chain', async () => {
|
||||
const { service, createUnderContract } = make({
|
||||
freightType: 'BULK',
|
||||
outstanding: null,
|
||||
});
|
||||
const id = await service.placeRemainder(splitBooking);
|
||||
expect(id).toBeNull();
|
||||
expect(createUnderContract).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import { IntercityService } from './intercity.service';
|
||||
import { WsAuthService } from '../notification-inbox/ws-auth.service';
|
||||
import { BookingJourneyService } from './booking-journey.service';
|
||||
import { BookingSplitService } from './booking-split.service';
|
||||
import { RemainderPlacementService } from './remainder-placement.service';
|
||||
import { BookingBatchOffer } from './entities/booking-batch-offer.entity';
|
||||
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
@@ -79,6 +80,7 @@ import { ContractsModule } from '../contracts/contracts.module';
|
||||
WsAuthService,
|
||||
BookingWindowService,
|
||||
BookingSplitService,
|
||||
RemainderPlacementService,
|
||||
IntercityService,
|
||||
BookingJourneyService,
|
||||
],
|
||||
|
||||
@@ -15,10 +15,23 @@ import {
|
||||
} from "./cookies";
|
||||
import type { AuthTokens } from "./types";
|
||||
|
||||
declare module "axios" {
|
||||
export interface AxiosRequestConfig {
|
||||
/**
|
||||
* When true, the response interceptor does NOT raise the global error modal
|
||||
* for this request's failure. For calls the caller handles itself — e.g. a
|
||||
* probe that is expected to 404 before falling back (GL clearance detail
|
||||
* tries /contracts/:id then /bookings/:id). The rejection still propagates.
|
||||
*/
|
||||
suppressErrorModal?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
type RetriableRequest = {
|
||||
_retry?: boolean;
|
||||
headers?: Record<string, string>;
|
||||
url?: string;
|
||||
suppressErrorModal?: boolean;
|
||||
};
|
||||
|
||||
const api = axios.create({
|
||||
@@ -100,8 +113,13 @@ api.interceptors.response.use(
|
||||
originalRequest.url?.includes("/auth/refresh-token")
|
||||
) {
|
||||
// Surface the server's actual error message in the global error modal
|
||||
// (401s are handled by the session-refresh flow, so skip them).
|
||||
if (error.response && error.response.status !== 401) {
|
||||
// (401s are handled by the session-refresh flow, so skip them). A request
|
||||
// may opt out via `suppressErrorModal` when it handles the failure itself.
|
||||
if (
|
||||
error.response &&
|
||||
error.response.status !== 401 &&
|
||||
!originalRequest?.suppressErrorModal
|
||||
) {
|
||||
const payload = extractApiErrorPayload(error);
|
||||
if (payload) emitApiError(payload);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useParams } from "react-router-dom";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
AlertCircle,
|
||||
ClipboardList,
|
||||
FileText,
|
||||
PackagePlus,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
@@ -61,9 +60,12 @@ type GlClearanceDetail =
|
||||
|
||||
async function loadGlClearanceDetail(id: string): Promise<GlClearanceDetail> {
|
||||
try {
|
||||
// Probe the contract endpoints first; a booking-id row 404s here by design
|
||||
// and falls back to the booking lookup below. Suppress the global error
|
||||
// modal so that expected 404 never surfaces to the user.
|
||||
const [clearance, contract] = await Promise.all([
|
||||
contractsService.getClearance(id),
|
||||
contractsService.getById(id),
|
||||
contractsService.getClearance(id, { suppressErrorModal: true }),
|
||||
contractsService.getById(id, { suppressErrorModal: true }),
|
||||
]);
|
||||
return {
|
||||
kind: "contract",
|
||||
@@ -89,7 +91,6 @@ async function loadGlClearanceDetail(id: string): Promise<GlClearanceDetail> {
|
||||
/** Djibouti GL clearance detail — RO/DO upload and read-only upstream context. */
|
||||
export default function GlClearanceDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const { view, viewer } = useFileViewer();
|
||||
const [uploadKind, setUploadKind] = useState<GlClearanceUploadKind | null>(null);
|
||||
@@ -197,19 +198,6 @@ export default function GlClearanceDetailPage() {
|
||||
{hasRo ? "Replace RO" : "Upload RO"}
|
||||
</Button>
|
||||
)}
|
||||
{canCompleteBooking && shipmentBooking ? (
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<PackagePlus size={16} />}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/dashboard/contracts/${shipmentBooking.contractId}/bookings/${id}/complete`,
|
||||
)
|
||||
}
|
||||
>
|
||||
Create booking
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -164,8 +164,11 @@ export const contractsService = {
|
||||
};
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<Freight.IContract> => {
|
||||
const response = await client.get<Freight.IContract>(C.BY_ID(id));
|
||||
getById: async (
|
||||
id: string,
|
||||
opts?: { suppressErrorModal?: boolean },
|
||||
): Promise<Freight.IContract> => {
|
||||
const response = await client.get<Freight.IContract>(C.BY_ID(id), opts);
|
||||
return unwrap(response.data) as Freight.IContract;
|
||||
},
|
||||
|
||||
@@ -258,8 +261,11 @@ export const contractsService = {
|
||||
};
|
||||
},
|
||||
|
||||
getClearance: async (id: string): Promise<Freight.ContractClearanceView> => {
|
||||
const response = await client.get(C.CLEARANCE(id));
|
||||
getClearance: async (
|
||||
id: string,
|
||||
opts?: { suppressErrorModal?: boolean },
|
||||
): Promise<Freight.ContractClearanceView> => {
|
||||
const response = await client.get(C.CLEARANCE(id), opts);
|
||||
return unwrap(response.data) as Freight.ContractClearanceView;
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user