mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat(contracts): lazily expire lapsed contracts and price fuel surcharge
This commit is contained in:
@@ -157,13 +157,9 @@ export class ContractBookingService {
|
||||
actorPermissions != null &&
|
||||
hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking);
|
||||
|
||||
await this.assertNotExpired(contract);
|
||||
const createdByRole = await this.assertGate(contract, isGlActor);
|
||||
|
||||
// Validity window must still be open.
|
||||
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
|
||||
throw new BadRequestException('Contract validity has expired — no new bookings.');
|
||||
}
|
||||
|
||||
// ONE_TIME: a single shipment at a time. The slot frees only if the prior
|
||||
// booking reached a terminal state (e.g. payment expired without shipping),
|
||||
// letting the customer re-book within contract validity (doc §10.4).
|
||||
@@ -441,12 +437,9 @@ export class ContractBookingService {
|
||||
// The customer initiates his own shipment instance on ONE_TIME contracts
|
||||
// (customs or self-clearance); GL may also initiate on a customs contract.
|
||||
// GENERAL customs instances come from a shipment request, not from here.
|
||||
await this.assertNotExpired(contract);
|
||||
const createdByRole = await this.assertGate(contract, isGlActor, true);
|
||||
|
||||
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
|
||||
throw new BadRequestException('Contract validity has expired — no new bookings.');
|
||||
}
|
||||
|
||||
// ONE_TIME carries a single shipment at a time; a bare instance occupies the
|
||||
// slot from the moment it is initiated (it is not a terminal status). The
|
||||
// split chain is the one exception — a paid partial frees the slot and
|
||||
@@ -545,9 +538,7 @@ export class ContractBookingService {
|
||||
'Shipment-request initiation applies only to general customs contracts.',
|
||||
);
|
||||
}
|
||||
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
|
||||
throw new BadRequestException('Contract validity has expired — no new bookings.');
|
||||
}
|
||||
await this.assertNotExpired(contract);
|
||||
|
||||
const route = await this.resolveRoute(contract, opts.contractRouteId);
|
||||
|
||||
@@ -681,9 +672,11 @@ export class ContractBookingService {
|
||||
if (!dto.scheduledDate) {
|
||||
throw new BadRequestException('A binding shipment day is required');
|
||||
}
|
||||
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
|
||||
throw new BadRequestException('Contract validity has expired — no new bookings.');
|
||||
}
|
||||
// No expiry gate here on purpose: this booking was already initiated
|
||||
// before the contract lapsed (createUnderContract/initiateUnderContract
|
||||
// already checked expiry at start). Finishing an in-flight booking must
|
||||
// proceed even if the contract expires meanwhile — only starting a NEW
|
||||
// booking is blocked (see assertNotExpired).
|
||||
|
||||
// Completion is booking time: the route's booking window must be open —
|
||||
// the same config-driven gate a direct one-time booking passes at create.
|
||||
@@ -1019,6 +1012,22 @@ export class ContractBookingService {
|
||||
* Returns the role to stamp on the booking, or throws if the caller is not
|
||||
* allowed to create one for this contract's execution path.
|
||||
*/
|
||||
/**
|
||||
* Blocks starting a NEW booking (create/initiate) once the contract has
|
||||
* lapsed, and lazily flips the stored status to EXPIRED so it doesn't wait
|
||||
* for the nightly sweep. Only for the "start something new" entry points —
|
||||
* a booking already underway (completeUnderContract) must be allowed to
|
||||
* finish even if the contract expires mid-flight.
|
||||
*/
|
||||
private async assertNotExpired(contract: Contract): Promise<void> {
|
||||
if (!isEffectivelyExpired(contract)) return;
|
||||
if (contract.status !== 'EXPIRED') {
|
||||
const flipped = await this.contractsRepository.expireIfLapsed(contract.id);
|
||||
if (flipped) contract.status = 'EXPIRED';
|
||||
}
|
||||
throw new BadRequestException('Contract validity has expired — no new bookings.');
|
||||
}
|
||||
|
||||
private async assertGate(
|
||||
contract: Contract,
|
||||
isGlActor: boolean,
|
||||
|
||||
@@ -11,7 +11,14 @@ import { Contract } from './entities/contract.entity';
|
||||
export interface ContractUnitRateLineItem {
|
||||
code: string;
|
||||
label: string;
|
||||
unit: 'per_container' | 'per_wagon' | 'per_ton' | 'per_item' | 'per_km' | 'flat';
|
||||
unit:
|
||||
| 'per_container'
|
||||
| 'per_wagon'
|
||||
| 'per_ton'
|
||||
| 'per_item'
|
||||
| 'per_km'
|
||||
| 'per_liter'
|
||||
| 'flat';
|
||||
unitPrice: number;
|
||||
containerSize?: string | null;
|
||||
conditionalOn?: string | null;
|
||||
@@ -44,6 +51,8 @@ function toContractUnit(rateUnit: string): ContractUnitRateLineItem['unit'] {
|
||||
return 'per_wagon';
|
||||
case 'PER_CONTAINER':
|
||||
return 'per_container';
|
||||
case 'PER_LITER':
|
||||
return 'per_liter';
|
||||
default:
|
||||
return 'flat';
|
||||
}
|
||||
@@ -276,6 +285,39 @@ export class ContractPricingService {
|
||||
}
|
||||
}
|
||||
|
||||
// Fuel surcharge — shown when the contract's commodity incurs fuel
|
||||
// (cargoType.hasFuel), sold per lane + commodity. Billed at booking on the
|
||||
// frozen/live rate (per wagon × wagons, or per liter × base liters, once);
|
||||
// this line freezes the agreed unit price.
|
||||
{
|
||||
const scope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
|
||||
if (scope?.cargoType?.hasFuel && route) {
|
||||
const fuel = liveRates.find(
|
||||
(r) =>
|
||||
r.trigger === 'FUEL' &&
|
||||
r.currency === 'USD' &&
|
||||
r.tradeDirection === contract.tradeDirection &&
|
||||
r.originYardId === route.originYardId &&
|
||||
r.destinationYardId === route.destinationYardId &&
|
||||
r.cargoTypeId === scope.cargoTypeId,
|
||||
);
|
||||
if (fuel && Number(fuel.rateValue) > 0) {
|
||||
const base = Number(fuel.baseLiters ?? 0);
|
||||
lineItems.push({
|
||||
code: 'FUEL_SURCHARGE',
|
||||
label:
|
||||
fuel.rateUnit === 'PER_LITER'
|
||||
? `Fuel surcharge (${scope.cargoType.cargoTypeName}, ${base} liters)`
|
||||
: `Fuel surcharge (${scope.cargoType.cargoTypeName})`,
|
||||
unit: toContractUnit(fuel.rateUnit),
|
||||
unitPrice: convert(Number(fuel.rateValue)),
|
||||
cargoTypeCode: scope.cargoType.code ?? null,
|
||||
conditionalOn: 'has_fuel',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Empty-container return service — container contracts only, toggled on the
|
||||
// contract like hazard/reefer. Billed at booking per WITH_RETURN container.
|
||||
if (
|
||||
|
||||
@@ -140,6 +140,27 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
return result.affected ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same-row version of expireLapsedContracts, for lazy flips on read/booking
|
||||
* paths — flips this one contract to EXPIRED if it's lapsed and not already
|
||||
* terminal. No-op (returns false) if the contract isn't actually lapsed, so
|
||||
* callers can call this unconditionally without a pre-check.
|
||||
*/
|
||||
async expireIfLapsed(id: string): Promise<boolean> {
|
||||
const result = await this.repository
|
||||
.createQueryBuilder()
|
||||
.update(Contract)
|
||||
.set({ status: 'EXPIRED' })
|
||||
.where('id = :id', { id })
|
||||
.andWhere('deleted_at IS NULL')
|
||||
.andWhere('status NOT IN (:...terminal)', { terminal: TERMINAL_CONTRACT_STATUSES })
|
||||
.andWhere('contract_valid_until IS NOT NULL AND contract_valid_until < :now', {
|
||||
now: new Date(),
|
||||
})
|
||||
.execute();
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live contracts whose validity ends between `days` and `days + 1` days from
|
||||
* now — the slice the daily expiry-reminder cron warns about. The window is
|
||||
|
||||
@@ -811,6 +811,16 @@ export class ContractsService {
|
||||
throw new NotFoundException(`Contract ${id} not found`);
|
||||
}
|
||||
|
||||
// Lazy expiry flip: the nightly cron only sweeps once a day, so a
|
||||
// contract can be past contract_valid_until for hours before it shows
|
||||
// EXPIRED. Flip it here so the detail page never shows a stale status.
|
||||
if (isEffectivelyExpired(contract) && contract.status !== 'EXPIRED') {
|
||||
const flipped = await this.contractsRepository.expireIfLapsed(id);
|
||||
if (flipped) {
|
||||
contract.status = 'EXPIRED';
|
||||
}
|
||||
}
|
||||
|
||||
if (contract.files && contract.files.length > 0) {
|
||||
contract.files = await Promise.all(
|
||||
contract.files.map(async (file: FileRecord) => {
|
||||
|
||||
@@ -211,6 +211,19 @@ function WindowCard({ w }: { w: MyBookingWindow }) {
|
||||
</Text>
|
||||
</Fragment>
|
||||
))}
|
||||
{w.trainNumber ? (
|
||||
<Text
|
||||
fz={13}
|
||||
fw={700}
|
||||
style={{
|
||||
color: INK,
|
||||
flexShrink: 0,
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
>
|
||||
Train {w.trainNumber}
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
<Group gap={6} wrap="nowrap" mt={8}>
|
||||
@@ -221,7 +234,7 @@ function WindowCard({ w }: { w: MyBookingWindow }) {
|
||||
</Group>
|
||||
{w.departureDate ? (
|
||||
<Text fz={12} style={{ color: MUTED }}>
|
||||
Departs {fmtDay(w.departureDate)}
|
||||
Departs {fmtDay(w.departureDate)}, {fmtTime(w.departureDate)}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user