mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +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 &&
|
actorPermissions != null &&
|
||||||
hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking);
|
hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking);
|
||||||
|
|
||||||
|
await this.assertNotExpired(contract);
|
||||||
const createdByRole = await this.assertGate(contract, isGlActor);
|
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
|
// 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),
|
// booking reached a terminal state (e.g. payment expired without shipping),
|
||||||
// letting the customer re-book within contract validity (doc §10.4).
|
// 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
|
// The customer initiates his own shipment instance on ONE_TIME contracts
|
||||||
// (customs or self-clearance); GL may also initiate on a customs contract.
|
// (customs or self-clearance); GL may also initiate on a customs contract.
|
||||||
// GENERAL customs instances come from a shipment request, not from here.
|
// GENERAL customs instances come from a shipment request, not from here.
|
||||||
|
await this.assertNotExpired(contract);
|
||||||
const createdByRole = await this.assertGate(contract, isGlActor, true);
|
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
|
// 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
|
// 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
|
// 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.',
|
'Shipment-request initiation applies only to general customs contracts.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
|
await this.assertNotExpired(contract);
|
||||||
throw new BadRequestException('Contract validity has expired — no new bookings.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const route = await this.resolveRoute(contract, opts.contractRouteId);
|
const route = await this.resolveRoute(contract, opts.contractRouteId);
|
||||||
|
|
||||||
@@ -681,9 +672,11 @@ export class ContractBookingService {
|
|||||||
if (!dto.scheduledDate) {
|
if (!dto.scheduledDate) {
|
||||||
throw new BadRequestException('A binding shipment day is required');
|
throw new BadRequestException('A binding shipment day is required');
|
||||||
}
|
}
|
||||||
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
|
// No expiry gate here on purpose: this booking was already initiated
|
||||||
throw new BadRequestException('Contract validity has expired — no new bookings.');
|
// 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 —
|
// 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.
|
// 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
|
* 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.
|
* 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(
|
private async assertGate(
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
isGlActor: boolean,
|
isGlActor: boolean,
|
||||||
|
|||||||
@@ -11,7 +11,14 @@ import { Contract } from './entities/contract.entity';
|
|||||||
export interface ContractUnitRateLineItem {
|
export interface ContractUnitRateLineItem {
|
||||||
code: string;
|
code: string;
|
||||||
label: 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;
|
unitPrice: number;
|
||||||
containerSize?: string | null;
|
containerSize?: string | null;
|
||||||
conditionalOn?: string | null;
|
conditionalOn?: string | null;
|
||||||
@@ -44,6 +51,8 @@ function toContractUnit(rateUnit: string): ContractUnitRateLineItem['unit'] {
|
|||||||
return 'per_wagon';
|
return 'per_wagon';
|
||||||
case 'PER_CONTAINER':
|
case 'PER_CONTAINER':
|
||||||
return 'per_container';
|
return 'per_container';
|
||||||
|
case 'PER_LITER':
|
||||||
|
return 'per_liter';
|
||||||
default:
|
default:
|
||||||
return 'flat';
|
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
|
// Empty-container return service — container contracts only, toggled on the
|
||||||
// contract like hazard/reefer. Billed at booking per WITH_RETURN container.
|
// contract like hazard/reefer. Billed at booking per WITH_RETURN container.
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -140,6 +140,27 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
return result.affected ?? 0;
|
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
|
* 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
|
* 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`);
|
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) {
|
if (contract.files && contract.files.length > 0) {
|
||||||
contract.files = await Promise.all(
|
contract.files = await Promise.all(
|
||||||
contract.files.map(async (file: FileRecord) => {
|
contract.files.map(async (file: FileRecord) => {
|
||||||
|
|||||||
@@ -211,6 +211,19 @@ function WindowCard({ w }: { w: MyBookingWindow }) {
|
|||||||
</Text>
|
</Text>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
|
{w.trainNumber ? (
|
||||||
|
<Text
|
||||||
|
fz={13}
|
||||||
|
fw={700}
|
||||||
|
style={{
|
||||||
|
color: INK,
|
||||||
|
flexShrink: 0,
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Train {w.trainNumber}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Group gap={6} wrap="nowrap" mt={8}>
|
<Group gap={6} wrap="nowrap" mt={8}>
|
||||||
@@ -221,7 +234,7 @@ function WindowCard({ w }: { w: MyBookingWindow }) {
|
|||||||
</Group>
|
</Group>
|
||||||
{w.departureDate ? (
|
{w.departureDate ? (
|
||||||
<Text fz={12} style={{ color: MUTED }}>
|
<Text fz={12} style={{ color: MUTED }}>
|
||||||
Departs {fmtDay(w.departureDate)}
|
Departs {fmtDay(w.departureDate)}, {fmtTime(w.departureDate)}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
Reference in New Issue
Block a user