split export

This commit is contained in:
Marshal
2026-07-18 09:12:52 +00:00
parent 6100a121d2
commit 406fbf6c45
14 changed files with 1092 additions and 41 deletions

View File

@@ -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);
});
});

View File

@@ -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
}
}

View File

@@ -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, {

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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.

View File

@@ -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);

View File

@@ -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();
});
});

View File

@@ -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,
}));
}
}

View File

@@ -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,
],

View File

@@ -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);
}

View File

@@ -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>
}
/>

View File

@@ -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;
},