mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 00:45:41 +00:00
Implement intercity document handling and rejection notes for contracts
This commit is contained in:
@@ -137,7 +137,7 @@ export class BookingPricingService {
|
|||||||
const lineItems: PriceLineItemDto[] = [];
|
const lineItems: PriceLineItemDto[] = [];
|
||||||
let total = 0;
|
let total = 0;
|
||||||
|
|
||||||
const { lineItems: baseLines, usedRates: baseRates } =
|
const { lineItems: baseLines, usedRates: baseRates, warnings: baseWarnings } =
|
||||||
await this.computeBaseRailLinesWithRates(booking, evalInput, frozenRates);
|
await this.computeBaseRailLinesWithRates(booking, evalInput, frozenRates);
|
||||||
for (const line of baseLines) {
|
for (const line of baseLines) {
|
||||||
lineItems.push(line);
|
lineItems.push(line);
|
||||||
@@ -247,7 +247,7 @@ export class BookingPricingService {
|
|||||||
usedRates: [...usedRatesMap.values()],
|
usedRates: [...usedRatesMap.values()],
|
||||||
appliedModifiers: ruleResult.appliedModifiers,
|
appliedModifiers: ruleResult.appliedModifiers,
|
||||||
priorityScore: ruleResult.priorityScore,
|
priorityScore: ruleResult.priorityScore,
|
||||||
warnings: ruleResult.warnings,
|
warnings: [...ruleResult.warnings, ...baseWarnings],
|
||||||
hardBlocked: ruleResult.hardBlocked,
|
hardBlocked: ruleResult.hardBlocked,
|
||||||
overweightLines,
|
overweightLines,
|
||||||
};
|
};
|
||||||
@@ -454,7 +454,7 @@ export class BookingPricingService {
|
|||||||
booking: Booking,
|
booking: Booking,
|
||||||
evalInput: BookingEvaluationInput,
|
evalInput: BookingEvaluationInput,
|
||||||
frozenRates: Map<string, ContractRateSnapshot> | null = null,
|
frozenRates: Map<string, ContractRateSnapshot> | null = null,
|
||||||
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> {
|
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[]; warnings: string[] }> {
|
||||||
const liveRates = await this.ratesService.findLiveRates();
|
const liveRates = await this.ratesService.findLiveRates();
|
||||||
const paymentCurrency = booking.paymentCurrency;
|
const paymentCurrency = booking.paymentCurrency;
|
||||||
const isEtbBooking = paymentCurrency === 'ETB';
|
const isEtbBooking = paymentCurrency === 'ETB';
|
||||||
@@ -476,6 +476,7 @@ export class BookingPricingService {
|
|||||||
|
|
||||||
const lines: PriceLineItemDto[] = [];
|
const lines: PriceLineItemDto[] = [];
|
||||||
const usedRatesMap = new Map<string, Rate>();
|
const usedRatesMap = new Map<string, Rate>();
|
||||||
|
const warnings: string[] = [];
|
||||||
const wagonCount = await this.resolveWagonCount(booking);
|
const wagonCount = await this.resolveWagonCount(booking);
|
||||||
|
|
||||||
for (const container of evalInput.containers) {
|
for (const container of evalInput.containers) {
|
||||||
@@ -487,47 +488,63 @@ export class BookingPricingService {
|
|||||||
booking.originYardId,
|
booking.originYardId,
|
||||||
booking.destinationYardId,
|
booking.destinationYardId,
|
||||||
);
|
);
|
||||||
if (!rate) continue;
|
|
||||||
|
|
||||||
usedRatesMap.set(rate.id, rate);
|
|
||||||
const unitUsd = Number(rate.rateValue);
|
|
||||||
// H15: frozen contract rate for this container size, when present — its
|
// H15: frozen contract rate for this container size, when present — its
|
||||||
// unitPrice is already in the booking currency (no USD→currency convert).
|
// unitPrice is already in the booking currency (no USD→currency convert).
|
||||||
|
// It also stands on its own: a contract line prices off the agreed rate
|
||||||
|
// even when nobody configured a live rate for this leg + type yet.
|
||||||
const frozen = await this.frozenRateForContainer(
|
const frozen = await this.frozenRateForContainer(
|
||||||
frozenRates,
|
frozenRates,
|
||||||
container.containerTypeId,
|
container.containerTypeId,
|
||||||
paymentCurrency,
|
paymentCurrency,
|
||||||
);
|
);
|
||||||
|
const label = await this.containerTypeLabel(container.containerTypeId);
|
||||||
|
if (!rate && !frozen) {
|
||||||
|
// Never price this line off another container type's (or another
|
||||||
|
// route's) rate — an unpriced line with a warning is recoverable; a
|
||||||
|
// silently mischarged one is not.
|
||||||
|
warnings.push(
|
||||||
|
`No ${rateType} rate is configured for ${label} on this route — ` +
|
||||||
|
'the line was not priced.',
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rateUnit = rate?.rateUnit ?? 'PER_CONTAINER';
|
||||||
let amount: number;
|
let amount: number;
|
||||||
let unitAmount: number;
|
let unitAmount: number;
|
||||||
if (frozen) {
|
if (frozen) {
|
||||||
unitAmount = Number(frozen.unitPrice);
|
unitAmount = Number(frozen.unitPrice);
|
||||||
amount = this.amountForUnit(
|
amount = this.amountForUnit(
|
||||||
rate.rateUnit,
|
rateUnit,
|
||||||
unitAmount,
|
unitAmount,
|
||||||
container.quantity,
|
container.quantity,
|
||||||
wagonCount,
|
wagonCount,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const usdAmount = this.amountForRate(rate, container.quantity, wagonCount);
|
const unitUsd = Number(rate!.rateValue);
|
||||||
|
const usdAmount = this.amountForRate(rate!, container.quantity, wagonCount);
|
||||||
amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||||
unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd;
|
unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd;
|
||||||
}
|
}
|
||||||
const label = await this.containerTypeLabel(container.containerTypeId);
|
if (rate) usedRatesMap.set(rate.id, rate);
|
||||||
lines.push({
|
lines.push({
|
||||||
code: rateType,
|
code: rateType,
|
||||||
description: `${label} rail freight`,
|
description: `${label} rail freight`,
|
||||||
amount,
|
amount,
|
||||||
unitAmount,
|
unitAmount,
|
||||||
unit: rate.rateUnit,
|
unit: rateUnit,
|
||||||
quantity: this.effectiveUnitQuantity(rate.rateUnit, container.quantity, wagonCount),
|
quantity: this.effectiveUnitQuantity(rateUnit, container.quantity, wagonCount),
|
||||||
currency: paymentCurrency,
|
currency: paymentCurrency,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lines.length === 0) {
|
if (lines.length === 0 && evalInput.containers.length === 0) {
|
||||||
// Bulk (and any booking with no container lines) still has to price off a
|
// Bulk (and any booking with no container lines) still has to price off a
|
||||||
// rate configured for this leg — never one belonging to another route.
|
// rate configured for this leg — never one belonging to another route.
|
||||||
|
// Container bookings never reach this fallback: their lines price per
|
||||||
|
// container type above or stay unpriced with a warning — falling back to
|
||||||
|
// a corridor rate of a DIFFERENT container type billed once (qty 1) is
|
||||||
|
// how a 38-container booking was invoiced 40 USD instead of 1900.
|
||||||
const fallback = liveRates.find(
|
const fallback = liveRates.find(
|
||||||
(r) =>
|
(r) =>
|
||||||
r.rateType === rateType &&
|
r.rateType === rateType &&
|
||||||
@@ -573,7 +590,7 @@ export class BookingPricingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { lineItems: lines, usedRates: [...usedRatesMap.values()] };
|
return { lineItems: lines, usedRates: [...usedRatesMap.values()], warnings };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -301,48 +301,57 @@ export class ContractBookingService {
|
|||||||
} as never),
|
} as never),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Persist container lines + per-unit container numbers (container freight only).
|
// Everything between the insert and the priced update must be all-or-nothing:
|
||||||
if (freightType === 'CONTAINER') {
|
// a throw part-way (container persist, weight rules, pricing) would otherwise
|
||||||
await this.persistContainers(booking.id, contract, dto);
|
// leave a 0-price, container-less row in OPERATION_REQUEST_PENDING that
|
||||||
}
|
// occupies the one-time contract's single active-booking slot until the
|
||||||
|
// doc-review sweep expires it — and the clearance cycle still points at the
|
||||||
// Reload with containers to compute the total from contract unit rates × qty.
|
// previous booking, so the hub keeps offering "Rebook" against a dead draft.
|
||||||
const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
try {
|
||||||
if (loaded) {
|
// Persist container lines + per-unit container numbers (container freight only).
|
||||||
if (freightType === 'CONTAINER') {
|
if (freightType === 'CONTAINER') {
|
||||||
await this.applyWeightResults(loaded);
|
await this.persistContainers(booking.id, contract, dto);
|
||||||
}
|
}
|
||||||
const computed = await this.bookingPricingService.computePriceForBooking(loaded);
|
|
||||||
// Reject a zero-price booking outright. A total of 0 means no contract rate
|
// Reload with containers to compute the total from contract unit rates × qty.
|
||||||
// matched the route/container (or the rate is unset), so the booking is not
|
const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||||
// valid to ship or invoice. Roll back the just-inserted row + its lines so it
|
if (loaded) {
|
||||||
// does NOT occupy the one-time contract's single active-booking slot — else
|
if (freightType === 'CONTAINER') {
|
||||||
// the customer's retry hits "already has an active booking" against a broken
|
await this.applyWeightResults(loaded);
|
||||||
// draft. The customer must fix the contract's rates, then rebook.
|
}
|
||||||
if (!(computed.totalAmount > 0)) {
|
const computed = await this.bookingPricingService.computePriceForBooking(loaded);
|
||||||
await this.bookingsRepository.deleteContainers(booking.id);
|
// Reject a zero-price booking outright. A total of 0 means no contract rate
|
||||||
await this.bookingsRepository.hardDelete(booking.id);
|
// matched the route/container (or the rate is unset), so the booking is not
|
||||||
throw new BadRequestException(
|
// valid to ship or invoice. The catch below rolls back the row + its lines.
|
||||||
'Booking price came out as 0 — no contract rate matches this ' +
|
if (!(computed.totalAmount > 0)) {
|
||||||
'route/cargo. Set the contract rate and try again.',
|
throw new BadRequestException(
|
||||||
);
|
'Booking price came out as 0 — no contract rate matches this ' +
|
||||||
}
|
'route/cargo. Set the contract rate and try again.',
|
||||||
await this.bookingsRepository.update(booking.id, {
|
);
|
||||||
totalAmount: computed.totalAmount,
|
}
|
||||||
priorityScore: computed.priorityScore,
|
await this.bookingsRepository.update(booking.id, {
|
||||||
pricingBreakdown: {
|
|
||||||
lineItems: computed.lineItems,
|
|
||||||
totalAmount: computed.totalAmount,
|
totalAmount: computed.totalAmount,
|
||||||
currency: computed.currency,
|
priorityScore: computed.priorityScore,
|
||||||
generatedAt: new Date().toISOString(),
|
pricingBreakdown: {
|
||||||
},
|
lineItems: computed.lineItems,
|
||||||
} as never);
|
totalAmount: computed.totalAmount,
|
||||||
await this.bookingPricingService.createPricingSnapshots(
|
currency: computed.currency,
|
||||||
booking.id,
|
generatedAt: new Date().toISOString(),
|
||||||
computed.usedRates,
|
},
|
||||||
computed.appliedModifiers,
|
} as never);
|
||||||
);
|
await this.bookingPricingService.createPricingSnapshots(
|
||||||
warnings.push(...computed.warnings);
|
booking.id,
|
||||||
|
computed.usedRates,
|
||||||
|
computed.appliedModifiers,
|
||||||
|
);
|
||||||
|
warnings.push(...computed.warnings);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
await this.bookingsRepository
|
||||||
|
.deleteContainers(booking.id)
|
||||||
|
.catch(() => undefined);
|
||||||
|
await this.bookingsRepository.hardDelete(booking.id).catch(() => undefined);
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wagon consolidation gate. A container drawdown whose lines leave a partial
|
// Wagon consolidation gate. A container drawdown whose lines leave a partial
|
||||||
@@ -1600,17 +1609,23 @@ export class ContractBookingService {
|
|||||||
throw new BadRequestException('At least one container line is required.');
|
throw new BadRequestException('At least one container line is required.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowedSizes = new Set(
|
// Size strings arrive in mixed formats ("20ft" from the contract scope,
|
||||||
|
// bare "20" from the rebook seed) — compare numerically so format never
|
||||||
|
// fails a size that IS in scope.
|
||||||
|
const allowedSizesFt = new Set(
|
||||||
(contract.cargoScope ?? [])
|
(contract.cargoScope ?? [])
|
||||||
.map((c) => c.containerSize)
|
.map((c) => parseInt(c.containerSize ?? '', 10))
|
||||||
.filter((s): s is string => !!s),
|
.filter((n) => Number.isFinite(n)),
|
||||||
);
|
);
|
||||||
|
|
||||||
const containerRepo = this.dataSource.getRepository(BookingContainer);
|
const containerRepo = this.dataSource.getRepository(BookingContainer);
|
||||||
const unitRepo = this.dataSource.getRepository(BookingContainerUnit);
|
const unitRepo = this.dataSource.getRepository(BookingContainerUnit);
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (allowedSizes.size && !allowedSizes.has(line.containerSize)) {
|
if (
|
||||||
|
allowedSizesFt.size &&
|
||||||
|
!allowedSizesFt.has(parseInt(line.containerSize, 10))
|
||||||
|
) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Container size ${line.containerSize} is outside the contract scope.`,
|
`Container size ${line.containerSize} is outside the contract scope.`,
|
||||||
);
|
);
|
||||||
@@ -1747,6 +1762,24 @@ export class ContractBookingService {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Same size-scope gate persistContainers enforces at create, surfaced as a
|
||||||
|
// blocking preview error so the form can't confirm a size the contract does
|
||||||
|
// not cover. Numeric compare — "20" and "20ft" are the same size.
|
||||||
|
const allowedSizesFt = new Set(
|
||||||
|
(contract.cargoScope ?? [])
|
||||||
|
.map((c) => parseInt(c.containerSize ?? '', 10))
|
||||||
|
.filter((n) => Number.isFinite(n)),
|
||||||
|
);
|
||||||
|
const scopeErrors = allowedSizesFt.size
|
||||||
|
? [
|
||||||
|
...new Set(
|
||||||
|
lines
|
||||||
|
.map((l) => l.containerSize)
|
||||||
|
.filter((s) => !allowedSizesFt.has(parseInt(s, 10))),
|
||||||
|
),
|
||||||
|
].map((s) => `Container size ${s} is outside the contract scope.`)
|
||||||
|
: [];
|
||||||
|
|
||||||
// The unsaved twin of the booking createUnderContract would write: same
|
// The unsaved twin of the booking createUnderContract would write: same
|
||||||
// denormalized contract fields, same container-line math. No id → the
|
// denormalized contract fields, same container-line math. No id → the
|
||||||
// pricing service derives wagon counts from the in-memory lines.
|
// pricing service derives wagon counts from the in-memory lines.
|
||||||
@@ -1859,7 +1892,7 @@ export class ContractBookingService {
|
|||||||
overweightSurchargeAmount,
|
overweightSurchargeAmount,
|
||||||
currency: computed.currency,
|
currency: computed.currency,
|
||||||
pairingErrors,
|
pairingErrors,
|
||||||
capacityErrors,
|
capacityErrors: [...scopeErrors, ...capacityErrors],
|
||||||
containerClashErrors,
|
containerClashErrors,
|
||||||
spaceErrors,
|
spaceErrors,
|
||||||
lineItems: computed.lineItems,
|
lineItems: computed.lineItems,
|
||||||
|
|||||||
@@ -447,6 +447,17 @@ export default function GlCreateBookingForm() {
|
|||||||
if (!copyFromBooking || prefilled) return;
|
if (!copyFromBooking || prefilled) return;
|
||||||
const lines = copyFromBooking.bookingContainers ?? [];
|
const lines = copyFromBooking.bookingContainers ?? [];
|
||||||
if (!lines.length) return;
|
if (!lines.length) return;
|
||||||
|
// The booking stores a numeric sizeFt (20) but the contract scope — and the
|
||||||
|
// create payload the server validates — uses its own size strings ("20ft").
|
||||||
|
// Seed with the scope's string so the rebook payload matches what a fresh
|
||||||
|
// form entry would send.
|
||||||
|
const scopeSizeForFt = (sizeFt: number | null | undefined): string => {
|
||||||
|
if (sizeFt == null) return "";
|
||||||
|
return (
|
||||||
|
containerSizes.find((s) => parseInt(s, 10) === Number(sizeFt)) ??
|
||||||
|
`${sizeFt}ft`
|
||||||
|
);
|
||||||
|
};
|
||||||
setPrefilled(true);
|
setPrefilled(true);
|
||||||
setContainerLines(
|
setContainerLines(
|
||||||
lines.map((c) => {
|
lines.map((c) => {
|
||||||
@@ -466,7 +477,7 @@ export default function GlCreateBookingForm() {
|
|||||||
}))
|
}))
|
||||||
: Array.from({ length: qty }, emptyUnit);
|
: Array.from({ length: qty }, emptyUnit);
|
||||||
return {
|
return {
|
||||||
containerSize: String(c.containerType?.sizeFt ?? ""),
|
containerSize: scopeSizeForFt(c.containerType?.sizeFt),
|
||||||
quantity: String(qty),
|
quantity: String(qty),
|
||||||
hazardousQuantity: String(units.filter((u) => u.isHazardous).length),
|
hazardousQuantity: String(units.filter((u) => u.isHazardous).length),
|
||||||
reeferQuantity: String(units.filter((u) => u.isReefer).length),
|
reeferQuantity: String(units.filter((u) => u.isReefer).length),
|
||||||
@@ -475,7 +486,7 @@ export default function GlCreateBookingForm() {
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}, [copyFromBooking, prefilled]);
|
}, [copyFromBooking, prefilled, containerSizes]);
|
||||||
|
|
||||||
// Seed one shipment line per contracted size exactly once — same seeding the
|
// Seed one shipment line per contracted size exactly once — same seeding the
|
||||||
// portal form does. Subsequent renders reuse the lines.
|
// portal form does. Subsequent renders reuse the lines.
|
||||||
|
|||||||
Reference in New Issue
Block a user