mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 15:18:11 +00:00
Merge pull request #720 from Tria-plc/freight_feature/usermanagement
fix issue
This commit is contained in:
@@ -119,6 +119,26 @@ export class BookingInvoiceService {
|
||||
return this.billing.updateStatus(invoiceId, status, manager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expire the booking's currently-open prepaid invoice when the booking is
|
||||
* cancelled or rejected — the counterpart to the pay-window-expiry path
|
||||
* (which also calls {@link BillingService.expirePayable}). Stops a terminated
|
||||
* booking from leaving a payable invoice open. No-op when the booking has no
|
||||
* open invoice (never invoiced, already paid/cancelled/expired). Pass a
|
||||
* caller `manager` to enlist in its transaction.
|
||||
*/
|
||||
expireOpenInvoices(
|
||||
bookingId: string,
|
||||
manager?: EntityManager,
|
||||
): Promise<Invoice | null> {
|
||||
return this.billing.expirePayable(
|
||||
Freight.InvoiceSource.Booking,
|
||||
bookingId,
|
||||
"PREPAID",
|
||||
manager,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance a booking once its prepaid invoice settles — the domain side-effect
|
||||
* of payment, relocated out of the payment service: the booking becomes PAID
|
||||
@@ -138,7 +158,32 @@ export class BookingInvoiceService {
|
||||
);
|
||||
return;
|
||||
}
|
||||
// if (booking.paymentStatus === "PAID") return;
|
||||
|
||||
// Idempotency + state-machine guard (restored). The prepaid-invoice paid
|
||||
// event can be delivered more than once (retries / re-emit), and a booking
|
||||
// may have moved on or been terminated between invoicing and settlement.
|
||||
// Only advance one that is still awaiting payment: no-op when already PAID,
|
||||
// and refuse to advance a booking in a terminal/advanced status
|
||||
// (CANCELLED/REJECTED/EXPIRED or already past the payment gate) so we never
|
||||
// rewrite its status or re-run allocation.
|
||||
if (booking.paymentStatus === "PAID" || booking.status === "PAID") {
|
||||
return;
|
||||
}
|
||||
const TERMINAL_OR_ADVANCED_STATUSES: string[] = [
|
||||
"CANCELLED",
|
||||
"REJECTED",
|
||||
"EXPIRED",
|
||||
"IN_TRANSIT",
|
||||
"ARRIVED",
|
||||
"COMPLETED",
|
||||
"CONTRACT_CLOSED",
|
||||
];
|
||||
if (TERMINAL_OR_ADVANCED_STATUSES.includes(booking.status)) {
|
||||
this.logger.warn(
|
||||
`Skipping advance of booking ${bookingId} on payment: status ${booking.status} is terminal/advanced.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.dataSource.transaction(async (mg) => {
|
||||
await mg.update(
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||
import { RatesService } from '../rule-engine/services/rates.service';
|
||||
import { Rate } from '../rule-engine/entities/rate.entity';
|
||||
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
|
||||
import { ExchangeService } from '@edr/api-common';
|
||||
import {
|
||||
AppliedCargoModifier,
|
||||
@@ -128,11 +129,18 @@ export class BookingPricingService {
|
||||
const isEtbBooking = paymentCurrency === 'ETB';
|
||||
const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||||
|
||||
// H15: a booking created under a contract prices from that contract's FROZEN
|
||||
// rate snapshots (the agreed rates), not the live rate of the day. Loaded
|
||||
// once and threaded through the line builders; each rate code that has a
|
||||
// snapshot uses it, and any code without one falls back to the live rate.
|
||||
// Non-contract bookings resolve to null and keep the live-rate path.
|
||||
const frozenRates = await this.loadFrozenContractRates(booking);
|
||||
|
||||
const lineItems: PriceLineItemDto[] = [];
|
||||
let total = 0;
|
||||
|
||||
const { lineItems: baseLines, usedRates: baseRates } =
|
||||
await this.computeBaseRailLinesWithRates(booking, evalInput);
|
||||
await this.computeBaseRailLinesWithRates(booking, evalInput, frozenRates);
|
||||
for (const line of baseLines) {
|
||||
lineItems.push(line);
|
||||
total += line.amount;
|
||||
@@ -141,7 +149,7 @@ export class BookingPricingService {
|
||||
// First / last mile trucking — billed per the rate's unit (km / container /
|
||||
// ton / flat), only for legs the booking actually carries.
|
||||
const { lineItems: mileLines, usedRates: mileRates } =
|
||||
await this.computeFirstLastMileLines(booking, evalInput);
|
||||
await this.computeFirstLastMileLines(booking, evalInput, frozenRates);
|
||||
for (const line of mileLines) {
|
||||
lineItems.push(line);
|
||||
total += line.amount;
|
||||
@@ -153,15 +161,14 @@ export class BookingPricingService {
|
||||
|
||||
for (const mod of ruleResult.appliedModifiers) {
|
||||
const usdAmount = mod.calculatedAmount;
|
||||
const convertedAmount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
|
||||
const rate = rateById.get(mod.rateId);
|
||||
const unit = rate?.rateUnit ?? 'FLAT';
|
||||
const unitUsd = rate ? Number(rate.rateValue) : usdAmount;
|
||||
const unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd;
|
||||
// Per-unit count: FLAT and PER_INVOICE are billed once (qty 1); an
|
||||
// explicit trigger (e.g. overweight tons) wins when present; otherwise
|
||||
// derive from total ÷ unit price.
|
||||
// derive from total ÷ unit price (the live unit price — a count, not a
|
||||
// currency amount, so it is snapshot-independent).
|
||||
const quantity =
|
||||
unit === 'FLAT' || unit === 'PER_INVOICE'
|
||||
? 1
|
||||
@@ -171,6 +178,26 @@ export class BookingPricingService {
|
||||
? Math.max(1, Math.round(usdAmount / unitUsd))
|
||||
: 1;
|
||||
|
||||
// H15: bill the frozen contract surcharge rate (already in the booking
|
||||
// currency) when this code has a snapshot; else keep the live amount.
|
||||
const frozen = this.frozenRateByCode(
|
||||
frozenRates,
|
||||
mod.surchargeCode,
|
||||
paymentCurrency,
|
||||
);
|
||||
const unitAmount = frozen
|
||||
? Number(frozen.unitPrice)
|
||||
: isEtbBooking
|
||||
? Math.round(unitUsd * usdToEtb)
|
||||
: unitUsd;
|
||||
const convertedAmount = frozen
|
||||
? isEtbBooking
|
||||
? Math.round(unitAmount * quantity)
|
||||
: unitAmount * quantity
|
||||
: isEtbBooking
|
||||
? Math.round(usdAmount * usdToEtb)
|
||||
: usdAmount;
|
||||
|
||||
const item: PriceLineItemDto = {
|
||||
code: mod.surchargeCode,
|
||||
description: surchargeLabel(mod.surchargeCode),
|
||||
@@ -424,6 +451,7 @@ export class BookingPricingService {
|
||||
private async computeBaseRailLinesWithRates(
|
||||
booking: Booking,
|
||||
evalInput: BookingEvaluationInput,
|
||||
frozenRates: Map<string, ContractRateSnapshot> | null = null,
|
||||
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> {
|
||||
const liveRates = await this.ratesService.findLiveRates();
|
||||
const paymentCurrency = booking.paymentCurrency;
|
||||
@@ -453,15 +481,35 @@ export class BookingPricingService {
|
||||
if (!rate) continue;
|
||||
|
||||
usedRatesMap.set(rate.id, rate);
|
||||
const usdAmount = this.amountForRate(rate, container.quantity, wagonCount);
|
||||
const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
const unitUsd = Number(rate.rateValue);
|
||||
// H15: frozen contract rate for this container size, when present — its
|
||||
// unitPrice is already in the booking currency (no USD→currency convert).
|
||||
const frozen = await this.frozenRateForContainer(
|
||||
frozenRates,
|
||||
container.containerTypeId,
|
||||
paymentCurrency,
|
||||
);
|
||||
let amount: number;
|
||||
let unitAmount: number;
|
||||
if (frozen) {
|
||||
unitAmount = Number(frozen.unitPrice);
|
||||
amount = this.amountForUnit(
|
||||
rate.rateUnit,
|
||||
unitAmount,
|
||||
container.quantity,
|
||||
wagonCount,
|
||||
);
|
||||
} else {
|
||||
const usdAmount = this.amountForRate(rate, container.quantity, wagonCount);
|
||||
amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd;
|
||||
}
|
||||
const label = await this.containerTypeLabel(container.containerTypeId);
|
||||
lines.push({
|
||||
code: rateType,
|
||||
description: `${label} rail freight`,
|
||||
amount,
|
||||
unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd,
|
||||
unitAmount,
|
||||
unit: rate.rateUnit,
|
||||
quantity: this.effectiveUnitQuantity(rate.rateUnit, container.quantity, wagonCount),
|
||||
currency: paymentCurrency,
|
||||
@@ -477,14 +525,31 @@ export class BookingPricingService {
|
||||
const bulkTons = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
const quantity =
|
||||
isBulk && fallback.rateUnit === 'PER_TON' ? Math.max(bulkTons, 0) : 1;
|
||||
const usdAmount = this.amountForRate(fallback, quantity, wagonCount);
|
||||
const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
const unitUsd = Number(fallback.rateValue);
|
||||
// H15: bulk freight uses the frozen BULK_FREIGHT snapshot when present.
|
||||
const frozen = isBulk
|
||||
? this.frozenRateByCode(frozenRates, 'BULK_FREIGHT', paymentCurrency)
|
||||
: null;
|
||||
let amount: number;
|
||||
let unitAmount: number;
|
||||
if (frozen) {
|
||||
unitAmount = Number(frozen.unitPrice);
|
||||
amount = this.amountForUnit(
|
||||
fallback.rateUnit,
|
||||
unitAmount,
|
||||
quantity,
|
||||
wagonCount,
|
||||
);
|
||||
} else {
|
||||
const usdAmount = this.amountForRate(fallback, quantity, wagonCount);
|
||||
amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd;
|
||||
}
|
||||
lines.push({
|
||||
code: rateType,
|
||||
description: isBulk ? 'Bulk rail freight' : 'Container rail freight',
|
||||
amount,
|
||||
unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd,
|
||||
unitAmount,
|
||||
unit: fallback.rateUnit,
|
||||
quantity: this.effectiveUnitQuantity(fallback.rateUnit, quantity, wagonCount),
|
||||
currency: paymentCurrency,
|
||||
@@ -508,6 +573,7 @@ export class BookingPricingService {
|
||||
private async computeFirstLastMileLines(
|
||||
booking: Booking,
|
||||
evalInput: BookingEvaluationInput,
|
||||
frozenRates: Map<string, ContractRateSnapshot> | null = null,
|
||||
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> {
|
||||
const legs: Array<{ rateType: 'FIRST_MILE' | 'LAST_MILE'; label: string; active: boolean }> = [
|
||||
{
|
||||
@@ -565,18 +631,34 @@ export class BookingPricingService {
|
||||
break;
|
||||
}
|
||||
|
||||
const usdAmount = value * quantity;
|
||||
// H15: frozen mile rate (already in booking currency) when the contract
|
||||
// has one; else the live USD rate converted as before.
|
||||
const frozen = this.frozenRateByCode(
|
||||
frozenRates,
|
||||
leg.rateType,
|
||||
paymentCurrency,
|
||||
);
|
||||
let amount: number;
|
||||
let unitAmount: number;
|
||||
if (frozen) {
|
||||
unitAmount = Number(frozen.unitPrice);
|
||||
amount = isEtbBooking
|
||||
? Math.round(unitAmount * quantity)
|
||||
: unitAmount * quantity;
|
||||
} else {
|
||||
const usdAmount = value * quantity;
|
||||
amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
unitAmount = isEtbBooking ? Math.round(value * usdToEtb) : value;
|
||||
}
|
||||
// Skip legs that resolve to nothing (zero rate, or zero km / count / tons).
|
||||
if (!(usdAmount > 0)) continue;
|
||||
if (!(amount > 0)) continue;
|
||||
|
||||
const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
const unitUsd = value;
|
||||
usedRatesMap.set(rate.id, rate);
|
||||
lines.push({
|
||||
code: leg.rateType,
|
||||
description: leg.label,
|
||||
amount,
|
||||
unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd,
|
||||
unitAmount,
|
||||
unit: rate.rateUnit,
|
||||
quantity,
|
||||
currency: paymentCurrency,
|
||||
@@ -649,21 +731,93 @@ export class BookingPricingService {
|
||||
}
|
||||
|
||||
private amountForRate(rate: Rate, quantity: number, wagonCount: number): number {
|
||||
const value = Number(rate.rateValue);
|
||||
switch (rate.rateUnit) {
|
||||
return this.amountForUnit(
|
||||
rate.rateUnit,
|
||||
Number(rate.rateValue),
|
||||
quantity,
|
||||
wagonCount,
|
||||
);
|
||||
}
|
||||
|
||||
/** Apply a unit value by rate unit — shared by live and frozen-snapshot lines. */
|
||||
private amountForUnit(
|
||||
rateUnit: string,
|
||||
unitValue: number,
|
||||
quantity: number,
|
||||
wagonCount: number,
|
||||
): number {
|
||||
switch (rateUnit) {
|
||||
case 'PER_CONTAINER':
|
||||
return value * quantity;
|
||||
return unitValue * quantity;
|
||||
case 'PER_WAGON':
|
||||
return value * wagonCount;
|
||||
return unitValue * wagonCount;
|
||||
case 'PER_TON':
|
||||
return value * quantity;
|
||||
return unitValue * quantity;
|
||||
case 'FLAT':
|
||||
return value;
|
||||
return unitValue;
|
||||
default:
|
||||
return value * quantity;
|
||||
return unitValue * quantity;
|
||||
}
|
||||
}
|
||||
|
||||
// ── H15: frozen contract rate snapshots ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Load a contract's frozen rate snapshots into a by-rate-code lookup, or null
|
||||
* for a non-contract booking (or a contract with no snapshots). The pricing
|
||||
* line builders prefer a matching snapshot's unit price over the live rate.
|
||||
*/
|
||||
private async loadFrozenContractRates(
|
||||
booking: Booking,
|
||||
): Promise<Map<string, ContractRateSnapshot> | null> {
|
||||
if (!booking.contractId) return null;
|
||||
const snapshots = await this.bookingsRepository.findContractRateSnapshots(
|
||||
booking.contractId,
|
||||
);
|
||||
if (!snapshots.length) return null;
|
||||
const byCode = new Map<string, ContractRateSnapshot>();
|
||||
for (const snap of snapshots) byCode.set(snap.rateCode, snap);
|
||||
return byCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The frozen snapshot for a rate code, or null when there is none, its price
|
||||
* is negative, or it is in a different currency than the booking (in which
|
||||
* case the live-rate path is safer than a mis-converted frozen price).
|
||||
*/
|
||||
private frozenRateByCode(
|
||||
frozenRates: Map<string, ContractRateSnapshot> | null,
|
||||
code: string,
|
||||
bookingCurrency: string,
|
||||
): ContractRateSnapshot | null {
|
||||
const snap = frozenRates?.get(code);
|
||||
if (!snap) return null;
|
||||
if (snap.currency !== bookingCurrency) return null;
|
||||
if (!(Number(snap.unitPrice) >= 0)) return null;
|
||||
return snap;
|
||||
}
|
||||
|
||||
/**
|
||||
* The frozen base-rail snapshot for a container line, matched by the
|
||||
* container's size (CONTAINER_20FT / CONTAINER_40FT — the codes
|
||||
* ContractPricingService freezes). Null when there is no snapshot.
|
||||
*/
|
||||
private async frozenRateForContainer(
|
||||
frozenRates: Map<string, ContractRateSnapshot> | null,
|
||||
containerTypeId: string,
|
||||
bookingCurrency: string,
|
||||
): Promise<ContractRateSnapshot | null> {
|
||||
if (!frozenRates) return null;
|
||||
let sizeFt: number | null = null;
|
||||
try {
|
||||
sizeFt = Number((await this.containerTypesService.findById(containerTypeId)).sizeFt) || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!sizeFt) return null;
|
||||
return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency);
|
||||
}
|
||||
|
||||
private lineItemsSignature(items: PriceLineItemDto[]): string {
|
||||
return JSON.stringify(
|
||||
[...items]
|
||||
|
||||
@@ -534,6 +534,10 @@ export class BookingTransitionService {
|
||||
"REJECTION",
|
||||
);
|
||||
|
||||
// Stop the open-invoice leak: a cancelled booking must not leave a payable
|
||||
// invoice open. Mirror the pay-window-expiry path (billing.expirePayable).
|
||||
await this.invoiceService.expireOpenInvoices(bookingId);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: "CANCELLED",
|
||||
} as never);
|
||||
@@ -562,6 +566,10 @@ export class BookingTransitionService {
|
||||
"REJECTION",
|
||||
);
|
||||
|
||||
// Stop the open-invoice leak: a rejected booking must not leave a payable
|
||||
// invoice open. Mirror the pay-window-expiry path (billing.expirePayable).
|
||||
await this.invoiceService.expireOpenInvoices(bookingId);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: "REJECTED",
|
||||
} as never);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQuer
|
||||
|
||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||
import { Contract } from '../contracts/entities/contract.entity';
|
||||
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
|
||||
import { ContractRoute } from '../contracts/entities/contract-route.entity';
|
||||
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||
@@ -200,6 +201,19 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
return Number(route?.km ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Frozen contract unit-rate snapshots for a contract (H15). A booking created
|
||||
* under a contract prices from these agreed, frozen rates rather than the live
|
||||
* rate of the day; the pricing service matches them by rate code.
|
||||
*/
|
||||
findContractRateSnapshots(
|
||||
contractId: string,
|
||||
): Promise<ContractRateSnapshot[]> {
|
||||
return this.dataSource
|
||||
.getRepository(ContractRateSnapshot)
|
||||
.find({ where: { contractId } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find another booking whose container quantity complements this one to fill whole wagon(s)
|
||||
* (same route, same container type, partial wagon on both sides). Only 20ft lines ever
|
||||
@@ -217,10 +231,12 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
quantity: number;
|
||||
containersPerWagon: number;
|
||||
},
|
||||
manager?: EntityManager,
|
||||
): Promise<Booking | null> {
|
||||
const { containerTypeId, quantity, containersPerWagon: perWagon } = slot;
|
||||
|
||||
const qb = this.repository
|
||||
const repo = manager ? manager.getRepository(Booking) : this.repository;
|
||||
const qb = repo
|
||||
.createQueryBuilder('b')
|
||||
.innerJoinAndSelect('b.bookingContainers', 'bc')
|
||||
.innerJoin('bc.containerType', 'ct')
|
||||
@@ -257,7 +273,18 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
);
|
||||
}
|
||||
|
||||
return qb.orderBy('b.createdAt', 'ASC').getOne();
|
||||
qb.orderBy('b.createdAt', 'ASC');
|
||||
|
||||
// H9: under the caller's transaction, take a write lock on the matched
|
||||
// partner booking row (FOR UPDATE OF b — booking rows only, not the joined
|
||||
// reference tables) so a concurrent consolidation cannot claim the same
|
||||
// partner between this find and the pair write. Only when a transaction
|
||||
// manager is supplied — a pessimistic lock requires an open transaction.
|
||||
if (manager) {
|
||||
qb.setLock('pessimistic_write', undefined, ['b']);
|
||||
}
|
||||
|
||||
return qb.getOne();
|
||||
}
|
||||
|
||||
/** Try each partial-wagon line until a complementary partner booking is found. */
|
||||
@@ -268,9 +295,14 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
quantity: number;
|
||||
containersPerWagon: number;
|
||||
}>,
|
||||
manager?: EntityManager,
|
||||
): Promise<Booking | null> {
|
||||
for (const slot of slots) {
|
||||
const partner = await this.findComplementaryConsolidationPartner(booking, slot);
|
||||
const partner = await this.findComplementaryConsolidationPartner(
|
||||
booking,
|
||||
slot,
|
||||
manager,
|
||||
);
|
||||
if (partner) return partner;
|
||||
}
|
||||
return null;
|
||||
@@ -308,6 +340,63 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
} as never);
|
||||
}
|
||||
|
||||
/**
|
||||
* Race-safe pairing (H9): the transactional counterpart of
|
||||
* {@link pairConsolidation}. Must run inside the caller's transaction
|
||||
* (`manager`), which should already hold the partner-row write lock taken by
|
||||
* {@link findComplementaryConsolidationPartner}. Re-reads both rows and
|
||||
* re-asserts `consolidationPartnerId IS NULL` on each before writing; returns
|
||||
* `false` (no write) when either booking was already paired by a concurrent
|
||||
* flow, so the caller can fall back to parking.
|
||||
*/
|
||||
async pairConsolidationIfUnpaired(
|
||||
bookingId: string,
|
||||
partnerId: string,
|
||||
manager: EntityManager,
|
||||
): Promise<boolean> {
|
||||
const repo = manager.getRepository(Booking);
|
||||
// Sequential (one connection per transaction) — never Promise.all here.
|
||||
const booking = await repo.findOne({
|
||||
where: { id: bookingId },
|
||||
select: {
|
||||
id: true,
|
||||
consolidationPartnerId: true,
|
||||
consolidationResumeStatus: true,
|
||||
},
|
||||
});
|
||||
const partner = await repo.findOne({
|
||||
where: { id: partnerId },
|
||||
select: {
|
||||
id: true,
|
||||
consolidationPartnerId: true,
|
||||
consolidationResumeStatus: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Re-assert both are still unpaired before writing (the partner row is held
|
||||
// under the finder's write lock, so its state is stable here).
|
||||
if (
|
||||
!booking ||
|
||||
!partner ||
|
||||
booking.consolidationPartnerId != null ||
|
||||
partner.consolidationPartnerId != null
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await repo.update(bookingId, {
|
||||
consolidationPartnerId: partnerId,
|
||||
status: booking.consolidationResumeStatus ?? 'SUBMITTED',
|
||||
consolidationResumeStatus: null,
|
||||
} as never);
|
||||
await repo.update(partnerId, {
|
||||
consolidationPartnerId: bookingId,
|
||||
status: partner.consolidationResumeStatus ?? 'SUBMITTED',
|
||||
consolidationResumeStatus: null,
|
||||
} as never);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Park a booking that needs consolidation but has no partner yet. The optional
|
||||
* resumeStatus is where the booking returns once it pairs — pass it for a
|
||||
|
||||
@@ -508,13 +508,28 @@ export class BookingsService {
|
||||
return { booking, messages };
|
||||
}
|
||||
|
||||
const partner = await this.bookingsRepository.findConsolidationPartner(
|
||||
booking,
|
||||
slots,
|
||||
);
|
||||
// H9: find + pair must be atomic. Run both inside one transaction where the
|
||||
// finder holds a write lock on the candidate partner row and pairing
|
||||
// re-asserts both rows are still unpaired before writing — otherwise two
|
||||
// concurrent bookings can claim the same partner (or pair an
|
||||
// already-paired booking). `didPair` is false when a concurrent flow won
|
||||
// the partner, in which case we fall through to parking below.
|
||||
const partner = await this.dataSource.transaction(async (manager) => {
|
||||
const candidate = await this.bookingsRepository.findConsolidationPartner(
|
||||
booking,
|
||||
slots,
|
||||
manager,
|
||||
);
|
||||
if (!candidate) return null;
|
||||
const didPair = await this.bookingsRepository.pairConsolidationIfUnpaired(
|
||||
booking.id,
|
||||
candidate.id,
|
||||
manager,
|
||||
);
|
||||
return didPair ? candidate : null;
|
||||
});
|
||||
|
||||
if (partner) {
|
||||
await this.bookingsRepository.pairConsolidation(booking.id, partner.id);
|
||||
const paired = await this.findById(booking.id);
|
||||
messages.push(
|
||||
this.consolidationService.describePaired(partner.reference, slots),
|
||||
|
||||
Reference in New Issue
Block a user