mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 07:10:57 +00:00
Implement intercity document handling and rejection notes for contracts
This commit is contained in:
@@ -301,48 +301,57 @@ export class ContractBookingService {
|
||||
} as never),
|
||||
);
|
||||
|
||||
// Persist container lines + per-unit container numbers (container freight only).
|
||||
if (freightType === 'CONTAINER') {
|
||||
await this.persistContainers(booking.id, contract, dto);
|
||||
}
|
||||
|
||||
// Reload with containers to compute the total from contract unit rates × qty.
|
||||
const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||
if (loaded) {
|
||||
// Everything between the insert and the priced update must be all-or-nothing:
|
||||
// a throw part-way (container persist, weight rules, pricing) would otherwise
|
||||
// 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
|
||||
// previous booking, so the hub keeps offering "Rebook" against a dead draft.
|
||||
try {
|
||||
// Persist container lines + per-unit container numbers (container freight only).
|
||||
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
|
||||
// matched the route/container (or the rate is unset), so the booking is not
|
||||
// valid to ship or invoice. Roll back the just-inserted row + its lines so it
|
||||
// does NOT occupy the one-time contract's single active-booking slot — else
|
||||
// the customer's retry hits "already has an active booking" against a broken
|
||||
// draft. The customer must fix the contract's rates, then rebook.
|
||||
if (!(computed.totalAmount > 0)) {
|
||||
await this.bookingsRepository.deleteContainers(booking.id);
|
||||
await this.bookingsRepository.hardDelete(booking.id);
|
||||
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,
|
||||
pricingBreakdown: {
|
||||
lineItems: computed.lineItems,
|
||||
|
||||
// Reload with containers to compute the total from contract unit rates × qty.
|
||||
const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||
if (loaded) {
|
||||
if (freightType === 'CONTAINER') {
|
||||
await this.applyWeightResults(loaded);
|
||||
}
|
||||
const computed = await this.bookingPricingService.computePriceForBooking(loaded);
|
||||
// Reject a zero-price booking outright. A total of 0 means no contract rate
|
||||
// matched the route/container (or the rate is unset), so the booking is not
|
||||
// valid to ship or invoice. The catch below rolls back the row + its lines.
|
||||
if (!(computed.totalAmount > 0)) {
|
||||
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,
|
||||
currency: computed.currency,
|
||||
generatedAt: new Date().toISOString(),
|
||||
},
|
||||
} as never);
|
||||
await this.bookingPricingService.createPricingSnapshots(
|
||||
booking.id,
|
||||
computed.usedRates,
|
||||
computed.appliedModifiers,
|
||||
);
|
||||
warnings.push(...computed.warnings);
|
||||
priorityScore: computed.priorityScore,
|
||||
pricingBreakdown: {
|
||||
lineItems: computed.lineItems,
|
||||
totalAmount: computed.totalAmount,
|
||||
currency: computed.currency,
|
||||
generatedAt: new Date().toISOString(),
|
||||
},
|
||||
} as never);
|
||||
await this.bookingPricingService.createPricingSnapshots(
|
||||
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
|
||||
@@ -1600,17 +1609,23 @@ export class ContractBookingService {
|
||||
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 ?? [])
|
||||
.map((c) => c.containerSize)
|
||||
.filter((s): s is string => !!s),
|
||||
.map((c) => parseInt(c.containerSize ?? '', 10))
|
||||
.filter((n) => Number.isFinite(n)),
|
||||
);
|
||||
|
||||
const containerRepo = this.dataSource.getRepository(BookingContainer);
|
||||
const unitRepo = this.dataSource.getRepository(BookingContainerUnit);
|
||||
|
||||
for (const line of lines) {
|
||||
if (allowedSizes.size && !allowedSizes.has(line.containerSize)) {
|
||||
if (
|
||||
allowedSizesFt.size &&
|
||||
!allowedSizesFt.has(parseInt(line.containerSize, 10))
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`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
|
||||
// denormalized contract fields, same container-line math. No id → the
|
||||
// pricing service derives wagon counts from the in-memory lines.
|
||||
@@ -1859,7 +1892,7 @@ export class ContractBookingService {
|
||||
overweightSurchargeAmount,
|
||||
currency: computed.currency,
|
||||
pairingErrors,
|
||||
capacityErrors,
|
||||
capacityErrors: [...scopeErrors, ...capacityErrors],
|
||||
containerClashErrors,
|
||||
spaceErrors,
|
||||
lineItems: computed.lineItems,
|
||||
|
||||
Reference in New Issue
Block a user