mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 00:10:57 +00:00
Merge pull request #942 from Tria-plc/freight_feature/usermanagement
revert back the clerance payment
This commit is contained in:
@@ -16,7 +16,6 @@ import {
|
||||
InvoiceLineInput,
|
||||
} from "../billing/billing.service";
|
||||
import { Invoice } from "../billing/entities/invoice.entity";
|
||||
import { CLEARANCE_BOOKING_INVOICE_TYPE } from "../contracts/clearance-fee.service";
|
||||
import { FirstMileService } from "../first-mile/first-mile.service";
|
||||
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
|
||||
import { PriceLineItemDto } from "./dto/generate-price-response.dto";
|
||||
@@ -121,8 +120,7 @@ export class BookingInvoiceService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Expire the booking's currently-open invoices (freight PREPAID and the
|
||||
* per-shipment clearance fee) when the booking is
|
||||
* Expire the booking's currently-open freight (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
|
||||
@@ -133,15 +131,6 @@ export class BookingInvoiceService {
|
||||
bookingId: string,
|
||||
manager?: EntityManager,
|
||||
): Promise<Invoice | null> {
|
||||
// The per-shipment clearance fee (GENERAL contracts) bills this same booking
|
||||
// id under its own source/type — retire it alongside the freight invoice, or
|
||||
// a cancelled shipment keeps a payable clearance invoice open.
|
||||
await this.billing.expirePayable(
|
||||
Freight.InvoiceSource.Clearance,
|
||||
bookingId,
|
||||
CLEARANCE_BOOKING_INVOICE_TYPE,
|
||||
manager,
|
||||
);
|
||||
return this.billing.expirePayable(
|
||||
Freight.InvoiceSource.Booking,
|
||||
bookingId,
|
||||
|
||||
@@ -56,6 +56,7 @@ describe('BookingPricingService — domestic corridor', () => {
|
||||
ratesService as never,
|
||||
exchangeService as never,
|
||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||
{} as never,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -240,3 +241,216 @@ describe('BookingPricingService — domestic corridor', () => {
|
||||
expect(result.blocked[0]).toContain('rate is configured');
|
||||
});
|
||||
});
|
||||
|
||||
describe('BookingPricingService — customs clearance fee billed on the booking price', () => {
|
||||
const DJ = 'yard-dj';
|
||||
|
||||
const containerFee20: Rate = {
|
||||
id: 'rate-cc-20',
|
||||
rateType: 'CUSTOMS_CLEARANCE',
|
||||
trigger: 'CUSTOMS_CLEARANCE',
|
||||
currency: 'USD',
|
||||
rateValue: 100,
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
status: 'LIVE',
|
||||
containerTypeId: 'ct-20',
|
||||
tradeDirection: 'IMPORT',
|
||||
originYardId: DJ,
|
||||
destinationYardId: DIRE,
|
||||
} as Rate;
|
||||
|
||||
const bulkFeePerTon: Rate = {
|
||||
...containerFee20,
|
||||
id: 'rate-cc-bulk',
|
||||
rateValue: 5,
|
||||
rateUnit: 'PER_TON',
|
||||
containerTypeId: null,
|
||||
} as Rate;
|
||||
|
||||
const emptyEval = {
|
||||
priorityScore: 0,
|
||||
appliedModifiers: [],
|
||||
containerWeightResults: [],
|
||||
warnings: [],
|
||||
hardBlocked: [],
|
||||
requiresDirectorApproval: false,
|
||||
};
|
||||
|
||||
const makeService = (opts: {
|
||||
snapshots?: unknown[];
|
||||
liveRates?: Rate[];
|
||||
wagonCapacity?: number;
|
||||
}) =>
|
||||
new BookingPricingService(
|
||||
{
|
||||
calculateWagonCount: jest.fn().mockResolvedValue(0),
|
||||
findContractRateSnapshots: jest.fn().mockResolvedValue(opts.snapshots ?? []),
|
||||
} as never,
|
||||
{ evaluate: jest.fn().mockResolvedValue(emptyEval) } as never,
|
||||
{
|
||||
findById: jest.fn(async (id: string) => ({
|
||||
id,
|
||||
sizeFt: id === 'ct-40' ? 40 : 20,
|
||||
isReefer: false,
|
||||
code: id === 'ct-40' ? 'C40' : 'C20',
|
||||
})),
|
||||
} as never,
|
||||
{ findLiveRates: jest.fn().mockResolvedValue(opts.liveRates ?? []) } as never,
|
||||
{ getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never,
|
||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||
{
|
||||
findById: jest.fn().mockResolvedValue({
|
||||
wagonTypes:
|
||||
opts.wagonCapacity !== undefined
|
||||
? [{ capacityTons: opts.wagonCapacity }]
|
||||
: [],
|
||||
}),
|
||||
} as never,
|
||||
);
|
||||
|
||||
const containerBooking = (overrides: Record<string, unknown> = {}) =>
|
||||
({
|
||||
id: 'b-cc',
|
||||
freightType: 'CONTAINER',
|
||||
tradeDirection: 'IMPORT',
|
||||
paymentCurrency: 'USD',
|
||||
customsClearingEnabled: true,
|
||||
originYardId: DJ,
|
||||
destinationYardId: DIRE,
|
||||
bookingContainers: [
|
||||
{ containerTypeId: 'ct-20', quantity: 4, vgmPerUnitTons: 10, wagonsRequired: 2 },
|
||||
],
|
||||
...overrides,
|
||||
}) as unknown as Booking;
|
||||
|
||||
const bulkBooking = (overrides: Record<string, unknown> = {}) =>
|
||||
({
|
||||
id: 'b-cc-bulk',
|
||||
freightType: 'BULK',
|
||||
tradeDirection: 'IMPORT',
|
||||
paymentCurrency: 'USD',
|
||||
customsClearingEnabled: true,
|
||||
cargoTypeId: 'cargo-1',
|
||||
cargoTotalWeightVgm: 120,
|
||||
originYardId: DJ,
|
||||
destinationYardId: DIRE,
|
||||
bookingContainers: [],
|
||||
...overrides,
|
||||
}) as unknown as Booking;
|
||||
|
||||
it('bills a container booking per box at its own container type fee', async () => {
|
||||
const service = makeService({ liveRates: [containerFee20] });
|
||||
const result = await service.computePriceForBooking(containerBooking());
|
||||
|
||||
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE_20FT');
|
||||
expect(line).toBeDefined();
|
||||
expect(line!.unit).toBe('PER_CONTAINER');
|
||||
expect(line!.quantity).toBe(4);
|
||||
expect(line!.amount).toBe(400);
|
||||
});
|
||||
|
||||
it('bills a PER_WAGON container fee on the wagons the boxes occupy (two 20ft share one)', async () => {
|
||||
const service = makeService({
|
||||
liveRates: [{ ...containerFee20, rateUnit: 'PER_WAGON' } as Rate],
|
||||
});
|
||||
const result = await service.computePriceForBooking(containerBooking());
|
||||
|
||||
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE_20FT');
|
||||
expect(line!.unit).toBe('PER_WAGON');
|
||||
expect(line!.quantity).toBe(2);
|
||||
expect(line!.amount).toBe(200);
|
||||
});
|
||||
|
||||
it('hard-blocks a container type with no fee configured (never free clearance)', async () => {
|
||||
const service = makeService({ liveRates: [bulkFeePerTon] });
|
||||
const result = await service.computePriceForBooking(containerBooking());
|
||||
|
||||
expect(result.lineItems.some((l) => l.code.startsWith('CUSTOMS_CLEARANCE'))).toBe(false);
|
||||
expect(result.hardBlocked.some((m) => m.includes('customs clearance'))).toBe(true);
|
||||
});
|
||||
|
||||
it('bills a bulk booking per ton at the route bulk fee', async () => {
|
||||
const service = makeService({ liveRates: [bulkFeePerTon] });
|
||||
const result = await service.computePriceForBooking(bulkBooking());
|
||||
|
||||
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE');
|
||||
expect(line!.unit).toBe('PER_TON');
|
||||
expect(line!.quantity).toBe(120);
|
||||
expect(line!.amount).toBe(600);
|
||||
});
|
||||
|
||||
it('bills a PER_WAGON bulk fee on ceil(tons ÷ wagon capacity)', async () => {
|
||||
const service = makeService({
|
||||
liveRates: [{ ...bulkFeePerTon, rateUnit: 'PER_WAGON', rateValue: 50 } as Rate],
|
||||
wagonCapacity: 60,
|
||||
});
|
||||
const result = await service.computePriceForBooking(bulkBooking());
|
||||
|
||||
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE');
|
||||
expect(line!.unit).toBe('PER_WAGON');
|
||||
expect(line!.quantity).toBe(2); // 120 t ÷ 60 t per wagon
|
||||
expect(line!.amount).toBe(100);
|
||||
});
|
||||
|
||||
it('blocks a PER_WAGON bulk fee when no wagon capacity is configured', async () => {
|
||||
const service = makeService({
|
||||
liveRates: [{ ...bulkFeePerTon, rateUnit: 'PER_WAGON' } as Rate],
|
||||
});
|
||||
const result = await service.computePriceForBooking(bulkBooking());
|
||||
|
||||
expect(result.hardBlocked.some((m) => m.includes('wagon'))).toBe(true);
|
||||
});
|
||||
|
||||
it('prefers the contract frozen per-size snapshot over the live rate', async () => {
|
||||
const service = makeService({
|
||||
liveRates: [containerFee20],
|
||||
snapshots: [
|
||||
{
|
||||
rateCode: 'CUSTOMS_CLEARANCE_20FT',
|
||||
unitPrice: 80,
|
||||
currency: 'USD',
|
||||
unitOfMeasure: 'per_container',
|
||||
isClearance: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = await service.computePriceForBooking(
|
||||
containerBooking({ contractId: 'c-1' }),
|
||||
);
|
||||
|
||||
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE_20FT');
|
||||
expect(line!.amount).toBe(320); // 4 × frozen 80, not live 100
|
||||
});
|
||||
|
||||
it('honours a legacy FLAT snapshot once for the whole container booking', async () => {
|
||||
const service = makeService({
|
||||
liveRates: [],
|
||||
snapshots: [
|
||||
{
|
||||
rateCode: 'CUSTOMS_CLEARANCE',
|
||||
unitPrice: 500,
|
||||
currency: 'USD',
|
||||
unitOfMeasure: 'flat',
|
||||
isClearance: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = await service.computePriceForBooking(
|
||||
containerBooking({ contractId: 'c-legacy' }),
|
||||
);
|
||||
|
||||
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE');
|
||||
expect(line!.unit).toBe('FLAT');
|
||||
expect(line!.amount).toBe(500);
|
||||
expect(result.hardBlocked.some((m) => m.includes('customs clearance'))).toBe(false);
|
||||
});
|
||||
|
||||
it('adds no fee line when customs clearing is disabled', async () => {
|
||||
const service = makeService({ liveRates: [containerFee20] });
|
||||
const result = await service.computePriceForBooking(
|
||||
containerBooking({ customsClearingEnabled: false }),
|
||||
);
|
||||
|
||||
expect(result.lineItems.some((l) => l.code.startsWith('CUSTOMS_CLEARANCE'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
|
||||
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';
|
||||
@@ -10,7 +11,10 @@ import {
|
||||
BookingEvaluationInput,
|
||||
RuleEngineService,
|
||||
} from '../rule-engine/rule-engine.service';
|
||||
import { containersPerWagonForSize } from '../rule-engine/container-type.util';
|
||||
import {
|
||||
containersPerWagonForSize,
|
||||
wagonsPerUnitForSize,
|
||||
} from '../rule-engine/container-type.util';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { wagonRemainder } from './consolidation.service';
|
||||
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
|
||||
@@ -76,6 +80,7 @@ export class BookingPricingService {
|
||||
private readonly ratesService: RatesService,
|
||||
private readonly exchangeService: ExchangeService,
|
||||
private readonly containerValidationService: ContainerValidationService,
|
||||
private readonly cargoTypesService: CargoTypesService,
|
||||
) {}
|
||||
|
||||
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
|
||||
@@ -223,6 +228,23 @@ export class BookingPricingService {
|
||||
if (rate) usedRatesMap.set(rate.id, rate);
|
||||
}
|
||||
|
||||
// Customs clearance service fee (Path B) — billed HERE, on the booking
|
||||
// invoice with the freight; no separate prepaid clearance invoice. Sold per
|
||||
// cargo kind: container bookings bill each container type's own fee (per
|
||||
// box or per wagon), bulk bookings the route's bulk fee (per ton or per
|
||||
// wagon). Frozen contract snapshots win over live rates; a customs booking
|
||||
// with nothing configured hard-blocks — clearance never ships for free.
|
||||
const clearanceBlocked: string[] = [];
|
||||
if (booking.customsClearingEnabled) {
|
||||
const clearance = await this.customsClearanceLines(booking, frozenRates, liveRates);
|
||||
for (const line of clearance.lineItems) {
|
||||
lineItems.push(line);
|
||||
total += line.amount;
|
||||
}
|
||||
for (const rate of clearance.usedRates) usedRatesMap.set(rate.id, rate);
|
||||
clearanceBlocked.push(...clearance.blocked);
|
||||
}
|
||||
|
||||
// Overweight detail for the customer: map the engine's per-line results back
|
||||
// to the booking's container lines (same order) for code + weights. maxAllowed
|
||||
// is derived from the line total minus the excess the engine computed.
|
||||
@@ -260,7 +282,7 @@ export class BookingPricingService {
|
||||
appliedModifiers: ruleResult.appliedModifiers,
|
||||
priorityScore: ruleResult.priorityScore,
|
||||
warnings: [...ruleResult.warnings, ...baseWarnings],
|
||||
hardBlocked: [...ruleResult.hardBlocked, ...baseBlocked],
|
||||
hardBlocked: [...ruleResult.hardBlocked, ...baseBlocked, ...clearanceBlocked],
|
||||
overweightLines,
|
||||
};
|
||||
}
|
||||
@@ -321,6 +343,8 @@ export class BookingPricingService {
|
||||
hazardousQuantity: Number(bc.hazardousQuantity ?? 0),
|
||||
reeferQuantity: Number(bc.reeferQuantity ?? 0),
|
||||
returnQuantity: Number(bc.returnQuantity ?? 0),
|
||||
// Wagon share per box — a PER_WAGON empty-return rate bills on it.
|
||||
wagonsPerUnit: wagonsPerUnitForSize(ct.sizeFt),
|
||||
},
|
||||
perWagon: containersPerWagonForSize(ct.sizeFt),
|
||||
quantity: qty,
|
||||
@@ -338,6 +362,13 @@ export class BookingPricingService {
|
||||
),
|
||||
)
|
||||
: 0;
|
||||
// Bulk wagon estimate for PER_WAGON kind-scoped surcharges (lashing).
|
||||
// Deliberately NOT totalWagons — that would shift wagon-count priority
|
||||
// scoring for bulk bookings.
|
||||
const bulkWagons =
|
||||
booking.freightType === 'BULK'
|
||||
? ((await this.bulkWagonCount(booking)) ?? 0)
|
||||
: 0;
|
||||
|
||||
// Consolidation is system-managed: the CONSOLIDATION surcharge fires whenever
|
||||
// a container type leaves a wagon partially filled. Aggregate by type first —
|
||||
@@ -386,6 +417,7 @@ export class BookingPricingService {
|
||||
booking.freightType === 'BULK'
|
||||
? Number(booking.cargoTotalWeightVgm ?? 0)
|
||||
: 0,
|
||||
bulkWagons,
|
||||
containers,
|
||||
};
|
||||
}
|
||||
@@ -894,6 +926,184 @@ export class BookingPricingService {
|
||||
return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency);
|
||||
}
|
||||
|
||||
/**
|
||||
* Customs clearance service fee lines for a customs booking (Path B), billed
|
||||
* with the freight. Container bookings bill each container line at its own
|
||||
* container type's fee — PER_CONTAINER × boxes or PER_WAGON × the wagons the
|
||||
* line occupies (two 20ft share one). Bulk bookings bill the route's type-less
|
||||
* fee — PER_TON × tonnage or PER_WAGON × wagons the bulk occupies. Frozen
|
||||
* contract snapshots (CUSTOMS_CLEARANCE_20FT / _40FT / CUSTOMS_CLEARANCE)
|
||||
* win over live rates; contracts frozen before the per-kind model carry one
|
||||
* FLAT CUSTOMS_CLEARANCE snapshot, honoured once for the whole booking.
|
||||
*/
|
||||
private async customsClearanceLines(
|
||||
booking: Booking,
|
||||
frozenRates: Map<string, ContractRateSnapshot> | null,
|
||||
liveRates: Rate[],
|
||||
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[]; blocked: string[] }> {
|
||||
const lineItems: PriceLineItemDto[] = [];
|
||||
const usedRates: Rate[] = [];
|
||||
const blocked: string[] = [];
|
||||
const currency = booking.paymentCurrency;
|
||||
const isEtb = currency === 'ETB';
|
||||
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||||
const convert = (usd: number): number => (isEtb ? Math.round(usd * usdToEtb) : usd);
|
||||
|
||||
const onLeg = liveRates.filter(
|
||||
(r) =>
|
||||
r.rateType === 'CUSTOMS_CLEARANCE' &&
|
||||
r.currency === 'USD' &&
|
||||
r.tradeDirection === booking.tradeDirection &&
|
||||
r.originYardId === booking.originYardId &&
|
||||
r.destinationYardId === booking.destinationYardId,
|
||||
);
|
||||
const missingRateMessage = (scope: string): string =>
|
||||
`No customs clearance service fee is configured for ${scope} on this ` +
|
||||
'origin → destination. Ask EDR to configure the CUSTOMS_CLEARANCE rate for this route.';
|
||||
|
||||
if (booking.freightType === 'CONTAINER') {
|
||||
// Legacy short-circuit: an old contract froze one flat fee — bill it once.
|
||||
const hasPerSizeSnapshot =
|
||||
frozenRates?.has('CUSTOMS_CLEARANCE_20FT') ||
|
||||
frozenRates?.has('CUSTOMS_CLEARANCE_40FT');
|
||||
const legacyFlat = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency);
|
||||
if (legacyFlat && !hasPerSizeSnapshot) {
|
||||
const amount = Number(legacyFlat.unitPrice);
|
||||
if (amount > 0) {
|
||||
lineItems.push({
|
||||
code: 'CUSTOMS_CLEARANCE',
|
||||
description: 'Customs clearance service',
|
||||
amount,
|
||||
unitAmount: amount,
|
||||
unit: 'FLAT',
|
||||
quantity: 1,
|
||||
currency,
|
||||
});
|
||||
}
|
||||
return { lineItems, usedRates, blocked };
|
||||
}
|
||||
|
||||
for (const bc of booking.bookingContainers ?? []) {
|
||||
if (!bc.containerTypeId) continue;
|
||||
const qty = Number(bc.quantity || 0);
|
||||
if (!(qty > 0)) continue;
|
||||
let sizeFt = 0;
|
||||
try {
|
||||
sizeFt =
|
||||
Number((await this.containerTypesService.findById(bc.containerTypeId)).sizeFt) || 0;
|
||||
} catch {
|
||||
// unknown type — falls through to the live per-type lookup below
|
||||
}
|
||||
const frozen = sizeFt
|
||||
? this.frozenRateByCode(frozenRates, `CUSTOMS_CLEARANCE_${sizeFt}FT`, currency)
|
||||
: null;
|
||||
const live = onLeg.find((r) => r.containerTypeId === bc.containerTypeId);
|
||||
if (!frozen && !live) {
|
||||
blocked.push(missingRateMessage(`${sizeFt || '?'}ft containers`));
|
||||
continue;
|
||||
}
|
||||
const unit = frozen
|
||||
? this.rateUnitFromSnapshot(frozen.unitOfMeasure)
|
||||
: live!.rateUnit;
|
||||
const unitAmount = frozen
|
||||
? Number(frozen.unitPrice)
|
||||
: convert(Number(live!.rateValue));
|
||||
const billedQty =
|
||||
unit === 'PER_WAGON' ? Math.ceil(qty * wagonsPerUnitForSize(sizeFt)) : qty;
|
||||
const amount = unit === 'FLAT' ? unitAmount : unitAmount * billedQty;
|
||||
if (!(amount > 0)) continue;
|
||||
lineItems.push({
|
||||
code: sizeFt ? `CUSTOMS_CLEARANCE_${sizeFt}FT` : 'CUSTOMS_CLEARANCE',
|
||||
description: `Customs clearance service${sizeFt ? ` (${sizeFt}ft)` : ''}`,
|
||||
amount,
|
||||
unitAmount,
|
||||
unit,
|
||||
quantity: unit === 'FLAT' ? 1 : billedQty,
|
||||
currency,
|
||||
});
|
||||
if (live && !frozen) usedRates.push(live);
|
||||
}
|
||||
return { lineItems, usedRates, blocked };
|
||||
}
|
||||
|
||||
// Bulk — one fee for the whole booking. The bulk snapshot and the legacy
|
||||
// flat snapshot share the CUSTOMS_CLEARANCE code; both are the agreed fee.
|
||||
const frozen = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency);
|
||||
const live = onLeg.find((r) => !r.containerTypeId);
|
||||
if (!frozen && !live) {
|
||||
blocked.push(missingRateMessage('bulk cargo'));
|
||||
return { lineItems, usedRates, blocked };
|
||||
}
|
||||
const unit = frozen ? this.rateUnitFromSnapshot(frozen.unitOfMeasure) : live!.rateUnit;
|
||||
const unitAmount = frozen ? Number(frozen.unitPrice) : convert(Number(live!.rateValue));
|
||||
let billedQty = 1;
|
||||
if (unit === 'PER_TON') {
|
||||
billedQty = Math.max(0, Number(booking.cargoTotalWeightVgm ?? 0));
|
||||
} else if (unit === 'PER_WAGON') {
|
||||
const wagons = await this.bulkWagonCount(booking);
|
||||
if (wagons == null) {
|
||||
blocked.push(
|
||||
'The bulk customs clearance fee is per wagon, but this cargo type has ' +
|
||||
'no wagon type with a capacity configured — the wagon count cannot ' +
|
||||
'be derived. Ask EDR to configure the cargo type’s wagon types.',
|
||||
);
|
||||
return { lineItems, usedRates, blocked };
|
||||
}
|
||||
billedQty = wagons;
|
||||
}
|
||||
const amount = unit === 'FLAT' ? unitAmount : unitAmount * billedQty;
|
||||
if (amount > 0) {
|
||||
lineItems.push({
|
||||
code: 'CUSTOMS_CLEARANCE',
|
||||
description: 'Customs clearance service (bulk)',
|
||||
amount,
|
||||
unitAmount,
|
||||
unit,
|
||||
quantity: unit === 'FLAT' ? 1 : billedQty,
|
||||
currency,
|
||||
});
|
||||
if (live && !frozen) usedRates.push(live);
|
||||
}
|
||||
return { lineItems, usedRates, blocked };
|
||||
}
|
||||
|
||||
/** Snapshot unit-of-measure → the rate unit the billing math applies. */
|
||||
private rateUnitFromSnapshot(unitOfMeasure: string): string {
|
||||
switch (unitOfMeasure) {
|
||||
case 'per_wagon':
|
||||
return 'PER_WAGON';
|
||||
case 'per_ton':
|
||||
return 'PER_TON';
|
||||
case 'per_container':
|
||||
return 'PER_CONTAINER';
|
||||
default:
|
||||
return 'FLAT';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wagons a bulk booking occupies — ceil(tons ÷ rated capacity), using the
|
||||
* largest-capacity wagon type its cargo type allows. Null when the chain is
|
||||
* unconfigured (no cargo type, no wagon types, no capacity).
|
||||
* ponytail: pricing-time estimate off the biggest allowed wagon; scheduling
|
||||
* may stock a smaller type and use more wagons.
|
||||
*/
|
||||
private async bulkWagonCount(booking: Booking): Promise<number | null> {
|
||||
const tons = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
if (!(tons > 0) || !booking.cargoTypeId) return null;
|
||||
try {
|
||||
const cargo = await this.cargoTypesService.findById(booking.cargoTypeId);
|
||||
const capacity = Math.max(
|
||||
0,
|
||||
...(cargo.wagonTypes ?? []).map((w) => Number(w.capacityTons) || 0),
|
||||
);
|
||||
if (!(capacity > 0)) return null;
|
||||
return Math.max(1, Math.ceil(tons / capacity));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private lineItemsSignature(items: PriceLineItemDto[]): string {
|
||||
return JSON.stringify(
|
||||
[...items]
|
||||
|
||||
@@ -605,11 +605,6 @@ export class BookingTransitionService {
|
||||
files: Express.Multer.File[],
|
||||
): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
if (booking.status === "AWAITING_CLEARANCE_PAYMENT") {
|
||||
throw new ConflictException(
|
||||
"The customs clearance service fee for this shipment has not been paid yet — pay it from the portal to unlock document upload.",
|
||||
);
|
||||
}
|
||||
assertBookingStatus(booking, [
|
||||
"AWAITING_DOCUMENTS",
|
||||
"DOCUMENTS_UNDER_REVIEW",
|
||||
|
||||
@@ -45,7 +45,6 @@ export const BOOKING_STATUSES = [
|
||||
'CONTRACT_ACTIVE',
|
||||
'CONTRACT_CLOSED',
|
||||
// Post counter-sign document-clearance gate (GL workflow).
|
||||
'AWAITING_CLEARANCE_PAYMENT', // clearance fee invoiced, unpaid — docs locked
|
||||
'AWAITING_DOCUMENTS',
|
||||
'DOCUMENTS_UNDER_REVIEW',
|
||||
'CLEARANCE_READY',
|
||||
@@ -521,10 +520,6 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'clearance_current_phase', type: 'varchar', length: 40, nullable: true })
|
||||
clearanceCurrentPhase?: string | null;
|
||||
|
||||
/** When the prepaid customs clearance service fee settled (GENERAL + customs). */
|
||||
@Column({ name: 'clearance_fee_paid_at', type: 'timestamptz', nullable: true })
|
||||
clearanceFeePaidAt?: Date | null;
|
||||
|
||||
@Column({ name: 'duty_required', type: 'boolean', nullable: true })
|
||||
dutyRequired?: boolean | null;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user