mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
implement update train details feature: add DTO, service method, and UI modal for editing train name and run numbers
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Prepaid customs clearance service fee (Path B):
|
||||
* - contract_rate_snapshots.is_clearance — flags the frozen CUSTOMS_CLEARANCE
|
||||
* fee line so it is billed via its own clearance invoice and excluded from
|
||||
* shipment booking totals;
|
||||
* - contracts.clearance_fee_paid_at — when the ONE_TIME contract-level fee
|
||||
* settled (gate: AWAITING_CLEARANCE_PAYMENT → AWAITING_CLEARANCE_DOCUMENTS);
|
||||
* - bookings.clearance_fee_paid_at — when a GENERAL shipment-request instance's
|
||||
* fee settled (gate: AWAITING_CLEARANCE_PAYMENT → AWAITING_DOCUMENTS).
|
||||
* All nullable/defaulted — existing rows are untouched and keep today's flow.
|
||||
*/
|
||||
export class AddClearanceFeePayment2260000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.contract_rate_snapshots
|
||||
ADD COLUMN IF NOT EXISTS is_clearance BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.contracts
|
||||
ADD COLUMN IF NOT EXISTS clearance_fee_paid_at TIMESTAMPTZ;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS clearance_fee_paid_at TIMESTAMPTZ;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS clearance_fee_paid_at;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.contracts
|
||||
DROP COLUMN IF EXISTS clearance_fee_paid_at;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.contract_rate_snapshots
|
||||
DROP COLUMN IF EXISTS is_clearance;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
@@ -714,6 +715,11 @@ 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",
|
||||
|
||||
@@ -46,6 +46,7 @@ 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,6 +522,10 @@ 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;
|
||||
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { Injectable, Logger, UnprocessableEntityException } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { Freight } from '@edr/types';
|
||||
|
||||
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
|
||||
import { Invoice } from '../billing/entities/invoice.entity';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { ContractPricingBreakdown } from './contract-pricing.service';
|
||||
import { ContractNotifierService } from './contract-notifier.service';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { Contract } from './entities/contract.entity';
|
||||
|
||||
/** Invoice `type` for the contract-level fee (Path B ONE_TIME, after counter-sign). */
|
||||
export const CLEARANCE_CONTRACT_INVOICE_TYPE = 'CLEARANCE_CONTRACT';
|
||||
/** Invoice `type` for the per-shipment fee (Path B GENERAL, at shipment request). */
|
||||
export const CLEARANCE_BOOKING_INVOICE_TYPE = 'CLEARANCE_BOOKING';
|
||||
|
||||
/**
|
||||
* The prepaid customs clearance service fee (Path B) — the GL service charge,
|
||||
* separate from both freight (booking invoice) and duty/tax (paid offline).
|
||||
* Issued as its own `clearance`-source invoice and paid BEFORE the clearance
|
||||
* document step opens and before GL touches the file:
|
||||
* - ONE_TIME: once per contract, at staff counter-sign
|
||||
* (AWAITING_CLEARANCE_PAYMENT → paid → AWAITING_CLEARANCE_DOCUMENTS);
|
||||
* - GENERAL: once per shipment request, on the initiated booking instance
|
||||
* (booking AWAITING_CLEARANCE_PAYMENT → paid → AWAITING_DOCUMENTS).
|
||||
* The fee amount is the frozen CUSTOMS_CLEARANCE contract rate snapshot, so
|
||||
* customers pay what their contract shows, not the live rate of the day.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ClearanceFeeService {
|
||||
private readonly logger = new Logger(ClearanceFeeService.name);
|
||||
|
||||
constructor(
|
||||
private readonly billing: BillingService,
|
||||
private readonly contractsRepository: ContractsRepository,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly notifier: ContractNotifierService,
|
||||
) {}
|
||||
|
||||
/** The frozen flat fee for a contract; falls back to the pricing breakdown. */
|
||||
private async feeAmountOrNull(
|
||||
contract: Contract,
|
||||
): Promise<{ amount: number; currency: string } | null> {
|
||||
const snapshots = await this.contractsRepository.findRateSnapshots(contract.id);
|
||||
const snapshot = snapshots.find(
|
||||
(s) => s.isClearance || s.rateCode === 'CUSTOMS_CLEARANCE',
|
||||
);
|
||||
if (snapshot && Number(snapshot.unitPrice) > 0) {
|
||||
return { amount: Number(snapshot.unitPrice), currency: snapshot.currency };
|
||||
}
|
||||
const breakdown = contract.pricingBreakdown as ContractPricingBreakdown | null;
|
||||
const line = breakdown?.lineItems?.find((l) => l.code === 'CUSTOMS_CLEARANCE');
|
||||
if (line && Number(line.unitPrice) > 0) {
|
||||
return { amount: Number(line.unitPrice), currency: breakdown!.currency };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async feeAmount(
|
||||
contract: Contract,
|
||||
): Promise<{ amount: number; currency: string }> {
|
||||
const fee = await this.feeAmountOrNull(contract);
|
||||
if (!fee) {
|
||||
throw new UnprocessableEntityException(
|
||||
`Contract ${contract.reference} has no frozen customs clearance fee — regenerate its price with a live CUSTOMS_CLEARANCE rate.`,
|
||||
);
|
||||
}
|
||||
return fee;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the payment gate applies. Skipped for government/unlinked
|
||||
* contracts (no company to bill — invoices require one, same rule the
|
||||
* booking invoice applies) and for legacy customs contracts frozen before
|
||||
* the fee existed (no CUSTOMS_CLEARANCE snapshot to bill from) — both keep
|
||||
* the pre-fee flow instead of dead-ending.
|
||||
*/
|
||||
async gateApplies(contract: Contract): Promise<boolean> {
|
||||
if (!contract.customsClearingEnabled || !contract.companyId) return false;
|
||||
if ((await this.feeAmountOrNull(contract)) !== null) return true;
|
||||
this.logger.warn(
|
||||
`Contract ${contract.reference} has customs enabled but no frozen clearance fee — skipping the prepay gate (legacy contract).`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Issue (idempotently) the ONE_TIME contract-level fee invoice. */
|
||||
async issueForContract(contract: Contract): Promise<Invoice> {
|
||||
const existing = await this.billing.findPayable(
|
||||
Freight.InvoiceSource.Clearance,
|
||||
contract.id,
|
||||
CLEARANCE_CONTRACT_INVOICE_TYPE,
|
||||
);
|
||||
if (existing) return existing;
|
||||
|
||||
const { amount, currency } = await this.feeAmount(contract);
|
||||
const invoice = await this.billing.generateInvoice({
|
||||
source: Freight.InvoiceSource.Clearance,
|
||||
sourceId: contract.id,
|
||||
type: CLEARANCE_CONTRACT_INVOICE_TYPE,
|
||||
companyId: contract.companyId!,
|
||||
companyProfileId: contract.companyProfileId!,
|
||||
currency,
|
||||
lines: [
|
||||
{
|
||||
chargeType: 'CUSTOMS_CLEARANCE',
|
||||
description: `Customs clearance service fee — contract ${contract.reference}`,
|
||||
quantity: 1,
|
||||
unitRate: amount,
|
||||
amount,
|
||||
currency,
|
||||
},
|
||||
],
|
||||
status: Freight.InvoiceStatus.Pending,
|
||||
});
|
||||
this.notifier.clearanceFeeDue(contract, amount, currency);
|
||||
return invoice;
|
||||
}
|
||||
|
||||
/** Issue (idempotently) the GENERAL per-shipment fee invoice on the booking. */
|
||||
async issueForBooking(booking: Booking, contract: Contract): Promise<Invoice> {
|
||||
const existing = await this.billing.findPayable(
|
||||
Freight.InvoiceSource.Clearance,
|
||||
booking.id,
|
||||
CLEARANCE_BOOKING_INVOICE_TYPE,
|
||||
);
|
||||
if (existing) return existing;
|
||||
|
||||
const { amount, currency } = await this.feeAmount(contract);
|
||||
const invoice = await this.billing.generateInvoice({
|
||||
source: Freight.InvoiceSource.Clearance,
|
||||
sourceId: booking.id,
|
||||
type: CLEARANCE_BOOKING_INVOICE_TYPE,
|
||||
companyId: booking.companyId ?? contract.companyId!,
|
||||
companyProfileId: booking.companyProfileId ?? contract.companyProfileId!,
|
||||
currency,
|
||||
lines: [
|
||||
{
|
||||
chargeType: 'CUSTOMS_CLEARANCE',
|
||||
description: `Customs clearance service fee — shipment ${booking.reference}`,
|
||||
quantity: 1,
|
||||
unitRate: amount,
|
||||
amount,
|
||||
currency,
|
||||
},
|
||||
],
|
||||
status: Freight.InvoiceStatus.Pending,
|
||||
});
|
||||
this.notifier.clearanceFeeDue(contract, amount, currency, booking.reference);
|
||||
return invoice;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settlement branch point for `clearance`-source invoices: unlock the
|
||||
* document-upload step the fee was gating. Idempotent — a replayed event on
|
||||
* an already-advanced contract/booking is a no-op.
|
||||
*/
|
||||
@OnEvent('clearance.invoice.paid')
|
||||
async onClearanceInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
||||
this.logger.log(
|
||||
`clearance.invoice.paid (${payload.type}) for ${payload.sourceId} from ${payload.invoiceId}`,
|
||||
);
|
||||
switch (payload.type) {
|
||||
case CLEARANCE_CONTRACT_INVOICE_TYPE:
|
||||
await this.advanceContract(payload.sourceId);
|
||||
break;
|
||||
case CLEARANCE_BOOKING_INVOICE_TYPE:
|
||||
await this.advanceBooking(payload.sourceId);
|
||||
break;
|
||||
default:
|
||||
this.logger.warn(
|
||||
`Unhandled clearance invoice type "${payload.type}" paid (${payload.invoiceId})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async advanceContract(contractId: string): Promise<void> {
|
||||
const contract = await this.contractsRepository.findById(contractId);
|
||||
if (!contract) {
|
||||
this.logger.warn(`Cannot advance unknown contract ${contractId} on clearance fee payment.`);
|
||||
return;
|
||||
}
|
||||
if (contract.status !== 'AWAITING_CLEARANCE_PAYMENT') return;
|
||||
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'AWAITING_CLEARANCE_DOCUMENTS',
|
||||
clearanceStatus: 'AWAITING_DOCUMENTS',
|
||||
clearanceFeePaidAt: new Date(),
|
||||
} as never);
|
||||
const updated = await this.contractsRepository.findByIdWithRelations(contractId);
|
||||
if (updated) this.notifier.clearanceFeePaid(updated);
|
||||
}
|
||||
|
||||
private async advanceBooking(bookingId: string): Promise<void> {
|
||||
const booking = await this.bookingsRepository.findById(bookingId);
|
||||
if (!booking) {
|
||||
this.logger.warn(`Cannot advance unknown booking ${bookingId} on clearance fee payment.`);
|
||||
return;
|
||||
}
|
||||
if (booking.status !== 'AWAITING_CLEARANCE_PAYMENT') return;
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
status: 'AWAITING_DOCUMENTS',
|
||||
clearanceFeePaidAt: new Date(),
|
||||
} as never);
|
||||
if (booking.contractId) {
|
||||
const contract = await this.contractsRepository.findByIdWithRelations(
|
||||
booking.contractId,
|
||||
);
|
||||
if (contract) this.notifier.clearanceFeePaid(contract, booking.reference);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ describe('ContractBookingService — quantity-cap completion', () => {
|
||||
{} as never, // milestoneService
|
||||
{} as never, // workflowService
|
||||
{} as never, // invoiceService
|
||||
{} as never, // clearanceFeeService
|
||||
{} as never, // dataSource
|
||||
{} as never, // trainSchedulingService
|
||||
{} as never, // bookingBatchService
|
||||
|
||||
@@ -57,6 +57,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
|
||||
milestoneService as never,
|
||||
{} as never, // workflowService
|
||||
invoiceService as never,
|
||||
{} as never, // clearanceFeeService
|
||||
{} as never, // dataSource
|
||||
{} as never, // trainSchedulingService
|
||||
{} as never, // bookingBatchService
|
||||
|
||||
@@ -35,6 +35,7 @@ import { hasFreightPermission } from '../../common/freight-permission.util';
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { ContractRoute } from './entities/contract-route.entity';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { ClearanceFeeService } from './clearance-fee.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||
import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto';
|
||||
@@ -78,6 +79,7 @@ export class ContractBookingService {
|
||||
private readonly milestoneService: ClearanceMilestoneService,
|
||||
private readonly workflowService: ClearanceWorkflowService,
|
||||
private readonly invoiceService: BookingInvoiceService,
|
||||
private readonly clearanceFeeService: ClearanceFeeService,
|
||||
private readonly dataSource: DataSource,
|
||||
@Inject(forwardRef(() => TrainSchedulingService))
|
||||
private readonly trainSchedulingService: TrainSchedulingService,
|
||||
@@ -492,6 +494,11 @@ export class ContractBookingService {
|
||||
|
||||
const route = await this.resolveRoute(contract, opts.contractRouteId);
|
||||
|
||||
// Prepay gate: each shipment request owes its own flat clearance service
|
||||
// fee before the document step opens (the paid event advances the booking
|
||||
// to AWAITING_DOCUMENTS). Government/unlinked contracts skip the gate.
|
||||
const feeGate = await this.clearanceFeeService.gateApplies(contract);
|
||||
|
||||
const booking = await insertWithGeneratedReference(
|
||||
() => this.generateReference(),
|
||||
(reference) =>
|
||||
@@ -501,7 +508,7 @@ export class ContractBookingService {
|
||||
companyProfileId: contract.companyProfileId ?? null,
|
||||
isGovernment: contract.isGovernment,
|
||||
governmentInstitution: contract.governmentInstitution ?? null,
|
||||
status: 'AWAITING_DOCUMENTS',
|
||||
status: feeGate ? 'AWAITING_CLEARANCE_PAYMENT' : 'AWAITING_DOCUMENTS',
|
||||
bookingType: 'ONE_TIME',
|
||||
contractId: contract.id,
|
||||
contractRouteId: route?.id ?? null,
|
||||
@@ -540,6 +547,10 @@ export class ContractBookingService {
|
||||
contract.tradeDirection,
|
||||
);
|
||||
|
||||
if (feeGate) {
|
||||
await this.clearanceFeeService.issueForBooking(booking, contract);
|
||||
}
|
||||
|
||||
return (await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking;
|
||||
}
|
||||
|
||||
|
||||
@@ -489,6 +489,11 @@ export class ContractClearanceService {
|
||||
files: Express.Multer.File[],
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
if (contract.status === 'AWAITING_CLEARANCE_PAYMENT') {
|
||||
throw new ConflictException(
|
||||
'The customs clearance service fee has not been paid yet — pay it from the portal to unlock document upload.',
|
||||
);
|
||||
}
|
||||
if (
|
||||
contract.status !== 'AWAITING_CLEARANCE_DOCUMENTS' &&
|
||||
contract.status !== 'CLEARANCE_UNDER_REVIEW'
|
||||
|
||||
@@ -158,6 +158,26 @@ export class ContractNotifierService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Clearance service fee invoiced — customer must pay before document upload. */
|
||||
clearanceFeeDue(c: Contract, amount: number, currency: string, shipmentRef?: string): void {
|
||||
const scope = shipmentRef ? `shipment ${shipmentRef} under contract ${c.reference}` : `contract ${c.reference}`;
|
||||
const msg =
|
||||
`A customs clearance service fee of ${amount} ${currency} is due for ${scope}. ` +
|
||||
`Please pay from the portal to unlock the clearance document upload.`;
|
||||
void this.notifyContact(c, msg, 'CLEARANCE FEE DUE');
|
||||
this.inApp(c, 'Clearance fee due', msg);
|
||||
}
|
||||
|
||||
/** Clearance service fee settled — document upload is now open. */
|
||||
clearanceFeePaid(c: Contract, shipmentRef?: string): void {
|
||||
const scope = shipmentRef ? `shipment ${shipmentRef} under contract ${c.reference}` : `contract ${c.reference}`;
|
||||
const msg =
|
||||
`Your customs clearance service fee for ${scope} has been received. ` +
|
||||
`You can now upload the clearance documents from the portal.`;
|
||||
void this.notifyContact(c, msg, 'CLEARANCE FEE PAID');
|
||||
this.inApp(c, 'Clearance fee paid', msg);
|
||||
}
|
||||
|
||||
// ── Clearance milestones needing customer action ──────────────────────────
|
||||
|
||||
/** GL advised duty & tax on the contract cycle — customer pays + uploads slip. */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, UnprocessableEntityException } from '@nestjs/common';
|
||||
|
||||
import { RatesService } from '../rule-engine/services/rates.service';
|
||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||
@@ -15,6 +15,11 @@ export interface ContractUnitRateLineItem {
|
||||
containerSize?: string | null;
|
||||
conditionalOn?: string | null;
|
||||
cargoTypeCode?: string | null;
|
||||
/**
|
||||
* Customs clearance service fee — billed separately in advance (before the
|
||||
* clearance document step), never part of shipment booking totals.
|
||||
*/
|
||||
isClearance?: boolean;
|
||||
}
|
||||
|
||||
/** The contract `pricing_breakdown` shape (doc §9.1). */
|
||||
@@ -184,6 +189,31 @@ export class ContractPricingService {
|
||||
}
|
||||
}
|
||||
|
||||
// Customs clearance service fee (Path B) — a FLAT prepaid fee, shown on the
|
||||
// contract and billed via its own clearance invoice: after counter-sign for
|
||||
// ONE_TIME, per shipment request for GENERAL. Excluded from booking totals.
|
||||
// A customs contract may not proceed without a configured live rate.
|
||||
if (contract.customsClearingEnabled) {
|
||||
const clearance = liveRates.find(
|
||||
(r) => r.rateType === 'CUSTOMS_CLEARANCE' && r.currency === 'USD',
|
||||
);
|
||||
if (!clearance || Number(clearance.rateValue) <= 0) {
|
||||
throw new UnprocessableEntityException(
|
||||
'No customs clearance service fee is configured. Ask the rates team to set a live CUSTOMS_CLEARANCE rate before submitting customs contracts.',
|
||||
);
|
||||
}
|
||||
lineItems.push({
|
||||
code: 'CUSTOMS_CLEARANCE',
|
||||
label:
|
||||
contract.contractKind === 'GENERAL'
|
||||
? 'Customs clearance service fee (per shipment request, prepaid)'
|
||||
: 'Customs clearance service fee (prepaid)',
|
||||
unit: toContractUnit(clearance.rateUnit),
|
||||
unitPrice: convert(Number(clearance.rateValue)),
|
||||
isClearance: true,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
displayMode: 'UNIT_RATES',
|
||||
currency,
|
||||
@@ -229,6 +259,7 @@ export class ContractPricingService {
|
||||
containerSize: line.containerSize ?? null,
|
||||
isSurcharge: !!line.conditionalOn,
|
||||
conditionalOn: line.conditionalOn ?? null,
|
||||
isClearance: !!line.isClearance,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { SignaturesService } from '../signatures/signatures.service';
|
||||
import { OtpService } from '../otp/otp.service';
|
||||
import { ContractTemplatesService } from '../contract-templates/contract-templates.service';
|
||||
import { ContractPricingService } from './contract-pricing.service';
|
||||
import { ClearanceFeeService } from './clearance-fee.service';
|
||||
import { ContractNotifierService } from './contract-notifier.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
@@ -89,6 +90,7 @@ export class ContractTransitionService {
|
||||
private readonly otpService: OtpService,
|
||||
private readonly notifier: ContractNotifierService,
|
||||
private readonly contractTemplates: ContractTemplatesService,
|
||||
private readonly clearanceFeeService: ClearanceFeeService,
|
||||
) {}
|
||||
|
||||
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
|
||||
@@ -866,8 +868,17 @@ export class ContractTransitionService {
|
||||
const cycleNumber = (contract.clearanceCycleNumber ?? 0) + 1;
|
||||
const cycle = await this.contractsRepository.openCycle(contractId, cycleNumber);
|
||||
await this.milestoneService.seedPreBookingMilestones(contract, cycle.id);
|
||||
updates.status = 'AWAITING_CLEARANCE_DOCUMENTS';
|
||||
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
|
||||
// Path B prepay gate: the customs clearance service fee is invoiced here
|
||||
// and must settle before the document step opens (the paid event advances
|
||||
// to AWAITING_CLEARANCE_DOCUMENTS). Path A (self-clearance) has no GL fee.
|
||||
if (await this.clearanceFeeService.gateApplies(contract)) {
|
||||
await this.clearanceFeeService.issueForContract(contract);
|
||||
updates.status = 'AWAITING_CLEARANCE_PAYMENT';
|
||||
updates.clearanceStatus = 'AWAITING_PAYMENT';
|
||||
} else {
|
||||
updates.status = 'AWAITING_CLEARANCE_DOCUMENTS';
|
||||
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
|
||||
}
|
||||
updates.clearanceCycleNumber = cycleNumber;
|
||||
} else {
|
||||
// No contract-level clearance gate — DOMESTIC, or any GENERAL contract
|
||||
|
||||
@@ -22,6 +22,7 @@ import { ContractsController } from './contracts.controller';
|
||||
import { ContractsService } from './contracts.service';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { ContractPricingService } from './contract-pricing.service';
|
||||
import { ClearanceFeeService } from './clearance-fee.service';
|
||||
import { ContractNotifierService } from './contract-notifier.service';
|
||||
import { ContractTransitionService } from './contract-transition.service';
|
||||
import { ContractClearanceService } from './contract-clearance.service';
|
||||
@@ -103,6 +104,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
||||
ContractsService,
|
||||
ContractsRepository,
|
||||
ContractPricingService,
|
||||
ClearanceFeeService,
|
||||
ContractNotifierService,
|
||||
ContractTransitionService,
|
||||
ContractClearanceService,
|
||||
|
||||
@@ -44,4 +44,11 @@ export class ContractRateSnapshot extends BaseEntity {
|
||||
/** is_hazardous | is_reefer when this is a conditional surcharge. */
|
||||
@Column({ name: 'conditional_on', type: 'varchar', length: 32, nullable: true })
|
||||
conditionalOn?: string | null;
|
||||
|
||||
/**
|
||||
* Customs clearance service fee line — billed up front via a clearance
|
||||
* invoice, excluded from shipment booking totals.
|
||||
*/
|
||||
@Column({ name: 'is_clearance', type: 'boolean', default: false })
|
||||
isClearance!: boolean;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export const CONTRACT_STATUSES = [
|
||||
'SIGNED_CUSTOMER',
|
||||
'FULLY_EXECUTED',
|
||||
'CONTRACT_ACTIVE',
|
||||
'AWAITING_CLEARANCE_PAYMENT', // Path B — clearance fee invoiced, unpaid
|
||||
'AWAITING_CLEARANCE_DOCUMENTS',
|
||||
'CLEARANCE_UNDER_REVIEW',
|
||||
'CLEARANCE_READY_FOR_BOOKING',
|
||||
@@ -84,6 +85,7 @@ export type ContractKindValue = (typeof CONTRACT_KINDS)[number];
|
||||
|
||||
export const CONTRACT_CLEARANCE_STATUSES = [
|
||||
'NOT_APPLICABLE',
|
||||
'AWAITING_PAYMENT', // Path B — clearance service fee must be paid first
|
||||
'AWAITING_DOCUMENTS',
|
||||
'DOCUMENTS_UNDER_REVIEW',
|
||||
'CLEARANCE_READY_FOR_BOOKING', // Path B — GL may create the booking
|
||||
@@ -215,6 +217,10 @@ export class Contract extends BaseEntity {
|
||||
@Column({ name: 'clearance_cycle_number', type: 'int', default: 0 })
|
||||
clearanceCycleNumber!: number;
|
||||
|
||||
/** When the prepaid customs clearance service fee settled (Path B ONE_TIME). */
|
||||
@Column({ name: 'clearance_fee_paid_at', type: 'timestamptz', nullable: true })
|
||||
clearanceFeePaidAt?: Date | null;
|
||||
|
||||
@Column({ name: 'pricing_breakdown', type: 'jsonb', nullable: true })
|
||||
pricingBreakdown?: Record<string, unknown> | null;
|
||||
|
||||
|
||||
@@ -37,6 +37,8 @@ export function deriveRateType(input: {
|
||||
return 'DEMURRAGE';
|
||||
case 'PIL_EXTRA_FEE':
|
||||
return 'PIL_EXTRA_FEE';
|
||||
case 'CUSTOMS_CLEARANCE':
|
||||
return 'CUSTOMS_CLEARANCE';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,9 @@ export function allowedRateUnits(input: {
|
||||
return ['PER_CONTAINER', 'PER_TON'];
|
||||
case 'CANCELLATION':
|
||||
return ['FLAT', 'PER_INVOICE'];
|
||||
case 'CUSTOMS_CLEARANCE':
|
||||
// Flat per clearance (ONE_TIME contract) / per shipment request (GENERAL).
|
||||
return ['FLAT'];
|
||||
case 'CONSOLIDATION':
|
||||
return ['PER_CONTAINER', 'FLAT'];
|
||||
case 'SHIPPING_LINE':
|
||||
|
||||
@@ -21,6 +21,7 @@ export const RATE_TYPES = [
|
||||
'HAZARD_SURCHARGE',
|
||||
'REEFER_SURCHARGE',
|
||||
'PIL_EXTRA_FEE',
|
||||
'CUSTOMS_CLEARANCE',
|
||||
] as const;
|
||||
|
||||
export type RateType = typeof RATE_TYPES[number];
|
||||
@@ -75,6 +76,9 @@ export const RATE_TRIGGERS = [
|
||||
'CANCELLATION',
|
||||
'DEMURRAGE',
|
||||
'PIL_EXTRA_FEE',
|
||||
// Customs clearance service fee — billed up front via a clearance invoice,
|
||||
// never auto-applied to booking pricing (matchesTrigger returns false).
|
||||
'CUSTOMS_CLEARANCE',
|
||||
] as const;
|
||||
export type RateTrigger = typeof RATE_TRIGGERS[number];
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
/**
|
||||
* Edit a built train's display identity: its name and its fixed import/export
|
||||
* run numbers. Composition (yard, locomotives, wagons) has its own endpoints.
|
||||
* Omitted fields keep their current value; an empty trainName clears the name.
|
||||
*/
|
||||
export class UpdateTrainDetailsDto {
|
||||
@ApiPropertyOptional({ description: 'Display name; empty string clears it' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
trainName?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Fixed IMPORT (even) run number' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(20)
|
||||
importTrainNumber?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Fixed EXPORT (odd) run number' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(20)
|
||||
exportTrainNumber?: string;
|
||||
}
|
||||
@@ -45,7 +45,7 @@ export class Train extends BaseEntity {
|
||||
trainNumber?: string;
|
||||
|
||||
@Column({ name: 'train_name', type: 'varchar', length: 100, nullable: true })
|
||||
trainName?: string;
|
||||
trainName?: string | null;
|
||||
|
||||
@Column({ name: 'route_id', type: 'uuid', nullable: true })
|
||||
routeId?: string;
|
||||
|
||||
@@ -19,6 +19,7 @@ import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
|
||||
import { BuildTrainDto } from './dto/build-train.dto';
|
||||
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
|
||||
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto';
|
||||
import { UpdateTrainDetailsDto } from './dto/update-train-details.dto';
|
||||
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
|
||||
import { UpdateTrainYardDto } from './dto/update-train-yard.dto';
|
||||
import { TrainBuilderService } from './train-builder.service';
|
||||
@@ -59,6 +60,18 @@ export class TrainBuilderController {
|
||||
return this.trainBuilderService.setLocomotives(id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/details')
|
||||
@FleetManage()
|
||||
@ApiOperation({
|
||||
summary: "Edit the train's name and fixed import/export run numbers",
|
||||
})
|
||||
updateDetails(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateTrainDetailsDto,
|
||||
) {
|
||||
return this.trainBuilderService.updateDetails(id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/yard')
|
||||
@FleetManage()
|
||||
@ApiOperation({
|
||||
|
||||
@@ -17,6 +17,7 @@ import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
|
||||
import { BuildTrainDto } from './dto/build-train.dto';
|
||||
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
|
||||
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto';
|
||||
import { UpdateTrainDetailsDto } from './dto/update-train-details.dto';
|
||||
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
|
||||
import { TrainLocomotive } from './entities/train-locomotive.entity';
|
||||
import { Train } from './entities/train.entity';
|
||||
@@ -331,6 +332,53 @@ export class TrainBuilderService {
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit a built train's display identity: name and fixed import/export run
|
||||
* numbers. Mirrors the build-time number rules — the pair may not collide
|
||||
* with any other train's pair or legacy number (friendly 409 ahead of the
|
||||
* partial unique indexes). Blocked while the train is out on a dispatched
|
||||
* run, like every other composition edit.
|
||||
*/
|
||||
async updateDetails(id: string, dto: UpdateTrainDetailsDto) {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const train = await this.getEditableTrain(manager, id);
|
||||
|
||||
const patch: Partial<Train> = {};
|
||||
if (dto.trainName !== undefined) {
|
||||
patch.trainName = dto.trainName.trim() || null;
|
||||
}
|
||||
const importTrainNumber = dto.importTrainNumber?.trim();
|
||||
const exportTrainNumber = dto.exportTrainNumber?.trim();
|
||||
if (importTrainNumber) patch.importTrainNumber = importTrainNumber;
|
||||
if (exportTrainNumber) patch.exportTrainNumber = exportTrainNumber;
|
||||
|
||||
if (importTrainNumber || exportTrainNumber) {
|
||||
const nextImport = importTrainNumber ?? train.importTrainNumber ?? '';
|
||||
const nextExport = exportTrainNumber ?? train.exportTrainNumber ?? '';
|
||||
const numberClash: { code: string }[] = await manager.query(
|
||||
`SELECT code FROM freight.trains
|
||||
WHERE deleted_at IS NULL
|
||||
AND id != $3
|
||||
AND (import_train_number IN ($1, $2)
|
||||
OR export_train_number IN ($1, $2)
|
||||
OR train_number IN ($1, $2))
|
||||
LIMIT 1`,
|
||||
[nextImport, nextExport, train.id],
|
||||
);
|
||||
if (numberClash.length) {
|
||||
throw new ConflictException(
|
||||
`Train number ${nextImport}/${nextExport} is already used by train ${numberClash[0].code}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(patch).length) {
|
||||
await manager.getRepository(Train).update(train.id, patch);
|
||||
}
|
||||
});
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relocate the train to another yard. The consist moves as one unit: every
|
||||
* coupled locomotive and wagon follows to the new yard (so their current
|
||||
|
||||
Reference in New Issue
Block a user