Merge pull request #720 from Tria-plc/freight_feature/usermanagement

fix issue
This commit is contained in:
marshal
2026-07-16 03:34:14 +03:00
committed by GitHub
51 changed files with 1895 additions and 206 deletions

View File

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

View File

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

View File

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

View File

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

View File

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