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

@@ -602,6 +602,19 @@ export class BillingService {
if (invoice.status === Freight.InvoiceStatus.Paid) {
throw new BadRequestException("Invoice is already fully paid.");
}
// M27: a Draft invoice is not yet issued and an Expired invoice's pay
// window has closed — neither is payable. Without these guards a payment
// could settle an unissued draft or a lapsed invoice.
if (invoice.status === Freight.InvoiceStatus.Draft) {
throw new BadRequestException(
"Cannot pay a draft invoice — it must be issued first.",
);
}
if (invoice.status === Freight.InvoiceStatus.Expired) {
throw new BadRequestException(
"Cannot pay an expired invoice — its payment window has closed.",
);
}
if (round2(input.amount) > Number(invoice.balanceAmount)) {
throw new BadRequestException(
`Payment of ${round2(input.amount)} exceeds the outstanding balance of ${Number(invoice.balanceAmount)}.`,
@@ -891,6 +904,20 @@ export class BillingService {
status: Freight.InvoiceStatus,
manager?: EntityManager,
): Promise<void> {
// M27: this is the blunt "issue a draft" override — it stamps `issuedAt` but
// does NOT touch paidAmount/balanceAmount. Its only legitimate use is the
// Draft → Pending/Issued issue transition. It must NEVER mark an invoice
// Paid/Refunded/Cancelled/Expired (or PartiallyPaid/Overdue): those carry
// balance implications and must go through the dedicated settlement methods
// (recordPayment / markInvoiceAsRefunded / cancelInvoice / expirePayable).
if (
status !== Freight.InvoiceStatus.Pending &&
status !== Freight.InvoiceStatus.Issued
) {
throw new BadRequestException(
`updateStatus only issues an invoice (→ PENDING/ISSUED); use the dedicated settlement methods to set ${status}.`,
);
}
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, {
where: {

View File

@@ -103,8 +103,35 @@ export class PaymentController {
}
}
/**
* HTML-escape a value interpolated into the public checkout pages. These
* pages are served unauthenticated and the interpolated values (provider
* error messages, status strings, intent ids, redirect URLs) can carry
* attacker-influenced input — unescaped they are a reflected-XSS sink.
*/
private escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
private buildRedirectHtml(url: string): string {
const escaped = url.replace(/\"/g, "&quot;");
// Only http(s) URLs may be used as a redirect target — a javascript:
// URL would execute in the victim's browser from the <a>/location.href.
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return this.buildErrorHtml("Invalid payment redirect URL");
}
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
return this.buildErrorHtml("Invalid payment redirect URL");
}
const escaped = this.escapeHtml(url);
const jsEscaped = JSON.stringify(url);
return `<!DOCTYPE html>
<html lang="en">
<head>
@@ -126,12 +153,14 @@ export class PaymentController {
<p>Redirecting to payment provider…</p>
<p><a href="${escaped}">Click here if you are not redirected</a></p>
</div>
<script>window.location.href = "${escaped}";</script>
<script>window.location.href = ${jsEscaped};</script>
</body>
</html>`;
}
private buildStatusHtml(status: string, intentId: string): string {
private buildStatusHtml(rawStatus: string, rawIntentId: string): string {
const status = this.escapeHtml(rawStatus);
const intentId = this.escapeHtml(rawIntentId);
return `<!DOCTYPE html>
<html lang="en">
<head>
@@ -153,7 +182,8 @@ export class PaymentController {
</html>`;
}
private buildErrorHtml(message: string): string {
private buildErrorHtml(rawMessage: string): string {
const message = this.escapeHtml(rawMessage);
return `<!DOCTYPE html>
<html lang="en">
<head>

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

View File

@@ -1,6 +1,6 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm';
import { FindOptionsOrder, FindOptionsWhere, ILike, Not, Repository } from 'typeorm';
import { CreateCargoDto } from './dto/create-cargo.dto';
import { UpdateCargoDto } from './dto/update-cargo.dto';
import { LoadCargoDto } from './dto/load-cargo.dto';
@@ -32,6 +32,7 @@ export class CargoesService {
if (!container) {
throw new NotFoundException(`Container ${dto.containerId} not found`);
}
await this.assertContainerCapacity(container, dto.weight);
if (dto.cargoTypeId) {
const cargoType = await this.cargoTypeRepo.findOne({
@@ -121,6 +122,10 @@ export class CargoesService {
throw new ConflictException('Cargo already loaded or delivered');
}
if (cargo.container) {
await this.assertContainerCapacity(cargo.container, dto.weight, cargo.id);
}
cargo.status = 'LOADED';
cargo.loadedAt = new Date();
cargo.quantity = dto.quantity;
@@ -137,13 +142,31 @@ export class CargoesService {
}
async unloadCargo(id: string): Promise<Cargo> {
const cargo = await this.findById(id);
const cargo = await this.cargoRepo.findOne({
where: { id },
relations: { container: true },
});
if (!cargo) throw new NotFoundException('Cargo not found');
if (cargo.status !== 'LOADED') {
throw new ConflictException('Cargo is not loaded');
}
cargo.status = 'UNLOADED';
cargo.unloadedAt = new Date();
return this.cargoRepo.save(cargo);
const saved = await this.cargoRepo.save(cargo);
// loadCargo flips the container to LOADED; on unload, free it back to
// AVAILABLE once no other LOADED cargo still references the container.
if (cargo.containerId != null && cargo.container) {
const remaining = await this.cargoRepo.count({
where: { containerId: cargo.containerId, status: 'LOADED', id: Not(cargo.id) },
});
if (remaining === 0) {
cargo.container.status = 'AVAILABLE';
await this.containerRepo.save(cargo.container);
}
}
return saved;
}
async deliverCargo(id: string, dto?: DeliverCargoDto): Promise<Cargo> {
@@ -161,10 +184,13 @@ export class CargoesService {
if (dto?.receiverName) cargo.receiverName = dto.receiverName;
if (dto?.deliveryRemarks) cargo.deliveryRemarks = dto.deliveryRemarks;
// Exclude the cargo being delivered — it is still LOADED in the DB until the
// save below, so counting it would keep `remaining` > 0 and never free the
// container.
const remaining =
cargo.containerId != null
? await this.cargoRepo.count({
where: { containerId: cargo.containerId, status: 'LOADED' },
where: { containerId: cargo.containerId, status: 'LOADED', id: Not(cargo.id) },
})
: 0;
if (remaining === 0 && cargo.container) {
@@ -174,4 +200,34 @@ export class CargoesService {
return this.cargoRepo.save(cargo);
}
/**
* Reject when placing `newWeightKg` on the container would exceed its max gross
* weight. All values are kilograms: cargoes.weight is kg (entity), and the
* container's tare_weight / max_gross_weight are kg (entity). Capacity check is
* tare + already-LOADED cargo + new cargo <= max gross weight.
*/
private async assertContainerCapacity(
container: Container,
newWeightKg: number,
excludeCargoId?: string,
): Promise<void> {
const qb = this.cargoRepo
.createQueryBuilder('c')
.select('COALESCE(SUM(c.weight), 0)', 'sum')
.where('c.containerId = :containerId', { containerId: container.id })
.andWhere('c.status = :status', { status: 'LOADED' });
if (excludeCargoId) qb.andWhere('c.id != :excludeCargoId', { excludeCargoId });
const raw = await qb.getRawOne<{ sum: string }>();
const loadedKg = Number(raw?.sum ?? 0);
const tareKg = Number(container.tareWeight);
const maxGrossKg = Number(container.maxGrossWeight);
if (tareKg + loadedKg + newWeightKg > maxGrossKg) {
throw new BadRequestException(
`Cargo weight exceeds container capacity: tare ${tareKg}kg + loaded ${loadedKg}kg + ` +
`new ${newWeightKg}kg > max gross ${maxGrossKg}kg`,
);
}
}
}

View File

@@ -1,7 +1,7 @@
// apps/edr-freight-api/src/modules/container-management/containers.service.ts
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm';
import { DataSource, FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm';
import { CreateContainerDto } from './dto/create-container.dto';
import { UpdateContainerDto } from './dto/update-container.dto';
import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto';
@@ -18,6 +18,7 @@ export class ContainersService {
private readonly wagonRepo: Repository<Wagon>, // ✅ use raw repository
@InjectRepository(ContainerType)
private readonly containerTypeRepo: Repository<ContainerType>,
private readonly dataSource: DataSource,
) {}
async create(dto: CreateContainerDto): Promise<Container> {
@@ -115,24 +116,42 @@ export class ContainersService {
if (container.status === 'LOADED') {
throw new ConflictException('Cannot reassign a loaded container');
}
// Reject a container that is already placed on a wagon — it must be
// unassigned first, otherwise it would silently jump to another wagon.
if (container.wagonId) {
throw new ConflictException(
`Container ${containerId} is already assigned to wagon ${container.wagonId}`,
);
}
const wagon = await this.wagonRepo.findOne({ where: { id: dto.wagonId } });
if (!wagon) throw new NotFoundException(`Wagon ${dto.wagonId} not found`);
let position: number | null = dto.position ?? null;
if (position === null) {
const maxPos = await this.containerRepo
.createQueryBuilder('c')
.select('MAX(c.position)', 'max')
.where('c.wagonId = :wagonId', { wagonId: wagon.id })
.getRawOne();
position = (maxPos?.max ?? 0) + 1;
}
// The MAX(position)+1 allocation is check-then-act: two concurrent assigns can
// read the same MAX and collide on the same position. Do the read + save inside
// one transaction to narrow the race window.
// TODO: add a unique (wagon_id, position) DB index so the database itself
// rejects a colliding position even under concurrency.
return this.dataSource.transaction(async (manager) => {
const containerRepo = manager.getRepository(Container);
container.wagonId = wagon.id;
container.position = position;
container.status = 'AVAILABLE';
return this.containerRepo.save(container);
let position: number | null = dto.position ?? null;
if (position === null) {
const maxPos = await containerRepo
.createQueryBuilder('c')
.select('MAX(c.position)', 'max')
.where('c.wagonId = :wagonId', { wagonId: wagon.id })
.getRawOne<{ max: number | null }>();
position = (maxPos?.max ?? 0) + 1;
}
container.wagonId = wagon.id;
container.position = position;
// Placing a container on a wagon does not make it AVAILABLE. The status enum
// (AVAILABLE, LOADED, IN_TRANSIT, MAINTENANCE, DAMAGED) has no ASSIGNED/ON_WAGON
// state, so leave the existing status unchanged rather than forcing AVAILABLE.
return containerRepo.save(container);
});
}
async unassignFromWagon(containerId: string): Promise<Container> {

View File

@@ -78,12 +78,21 @@ export class ClearanceFeeService {
* 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;
// Customs disabled → the prepay gate genuinely does not apply.
if (!contract.customsClearingEnabled) return false;
// No company to bill (government / unlinked) → the gate cannot raise an
// invoice, so it stays out of the flow (same rule the booking invoice uses).
if (!contract.companyId) return false;
// M26: customs IS enabled and billable. A missing frozen fee line must NOT
// silently waive the gate — that ships clearance for free. Hard-fail exactly
// as price generation does when no CUSTOMS_CLEARANCE rate is configured, so a
// missing fee blocks counter-sign / shipment instead of bypassing payment.
if ((await this.feeAmountOrNull(contract)) === null) {
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.',
);
}
return true;
}
/** Issue (idempotently) the ONE_TIME contract-level fee invoice. */

View File

@@ -793,17 +793,34 @@ export class ContractTransitionService {
const contract = await this.contractsService.findById(contractId);
if (dto.role === 'CUSTOMER') {
// H12(a): only the owning company's customer may sign — assert ownership
// before anything else (hidden as NotFound otherwise). A signing customer
// has no permission key, so this is the gate that binds the sign to the
// contract's company.
await this.contractsService.assertCustomerCanAccessContract(
options.signerUserId,
contract,
);
assertContractStatus(contract, ['CONTRACT_READY']);
const existing = await this.contractsRepository.findSignature(contractId, 'CUSTOMER');
if (existing) {
throw new BadRequestException('Customer has already signed this contract');
}
// Sudo-mode gate: a fresh, single-use OTP (SMS'd to the customer's phone)
// must be verified before the signature is applied.
if (!dto.otpPhone || !dto.otp) {
// Sudo-mode gate: a fresh, single-use OTP must be verified before the
// signature is applied. H12(b): verify against the CONTRACT COMPANY's
// registered phone — never the caller-supplied dto.otpPhone, which an
// attacker could point at their own phone to sign someone else's
// contract. The OTP is issued to the company's registered number.
const companyPhone = contract.company?.phone?.trim();
if (!companyPhone) {
throw new BadRequestException(
'The contract company has no registered phone on file to verify the signing OTP against',
);
}
if (!dto.otp) {
throw new BadRequestException('OTP verification is required to sign the contract');
}
await this.otpService.verifyOtpForAction({ phone: dto.otpPhone }, dto.otp);
await this.otpService.verifyOtpForAction({ phone: companyPhone }, dto.otp);
await this.applySignature(contract, dto, options);
await this.contractsRepository.update(contractId, {
status: 'SIGNED_CUSTOMER',

View File

@@ -524,12 +524,18 @@ export class ContractsController {
@Post(':id/renew')
@ApiOperation({ summary: 'Create a renewal draft linked via renewalOfId' })
renew(
async renew(
@Param('id', ParseUUIDPipe) id: string,
@Body() _dto: RenewContractDto,
@CurrentUser() user: AuthUserPayload,
@CurrentUser() user: TCurrentUser,
) {
return this.transitionService.renew(id, user?.id ?? user?.sub);
// H12(c): a customer may only renew a contract their company owns. Staff
// with bookings.view bypass, mirroring getContractView/downloadContractDocument.
const contract = await this.contractsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
}
return this.transitionService.renew(id, resolveAuthUserId(user));
}
// ── Pre-booking clearance (Path B, doc §15.2.1) ────────────────────────────
@@ -544,10 +550,17 @@ export class ContractsController {
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Customer uploads clearance documents (fieldname = document key)' })
uploadClearanceDocuments(
async uploadClearanceDocuments(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
@UploadedFiles() files: Express.Multer.File[],
) {
// H12(c): only the owning company's customer may upload clearance docs.
// Staff with bookings.view bypass, mirroring the other contract handlers.
const contract = await this.contractsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
}
return this.clearanceService.uploadDocuments(id, files ?? []);
}

View File

@@ -22,7 +22,10 @@ import { CONTRACT_KINDS } from '../entities/contract.entity';
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const;
const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const;
const EQUIPMENT_RETURNS = ['with_return', 'without_return'] as const;
// Canonical UPPERCASE — everything downstream (booking gating, pricing
// surcharge, GL/portal booking forms) compares contract.equipmentReturn
// against 'WITH_RETURN'/'WITHOUT_RETURN'. Lowercase input is normalized.
const EQUIPMENT_RETURNS = ['WITH_RETURN', 'WITHOUT_RETURN'] as const;
export {
CONTRACT_KINDS,
@@ -161,6 +164,9 @@ export class CreateContractDto {
@ApiPropertyOptional({ enum: EQUIPMENT_RETURNS })
@IsOptional()
@Transform(({ value }) =>
typeof value === 'string' ? value.toUpperCase() : value,
)
@IsIn([...EQUIPMENT_RETURNS])
equipmentReturn?: string;

View File

@@ -1,4 +1,5 @@
import {
BadRequestException,
Controller,
Get,
Post,
@@ -72,7 +73,30 @@ export class DriversController {
@Post(':id/documents')
@BookingStaff(FREIGHT_PERMS.drivers.update)
@ApiConsumes('multipart/form-data')
@UseInterceptors(AnyFilesInterceptor())
// Bound the upload: 10MB/file, max 20 files, images + PDF only. Without limits
// AnyFilesInterceptor buffers arbitrarily large / arbitrary-type payloads.
@UseInterceptors(
AnyFilesInterceptor({
limits: { fileSize: 10 * 1024 * 1024, files: 20 },
fileFilter: (_req, file, cb) => {
const allowed = [
'image/jpeg',
'image/png',
'image/webp',
'image/gif',
'application/pdf',
];
if (allowed.includes(file.mimetype)) {
cb(null, true);
} else {
cb(
new BadRequestException(`Unsupported file type: ${file.mimetype}`),
false,
);
}
},
}),
)
@ApiOperation({ summary: 'Upload driver documents (code driver_docs)' })
uploadDocuments(
@Param('id', ParseUUIDPipe) id: string,

View File

@@ -6,22 +6,24 @@ import {
Query,
Res,
} from "@nestjs/common";
import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger";
import { Public } from "@edr/api-common";
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger";
import { Response } from "express";
import { FilesService } from "./files.service";
@ApiTags("files")
@ApiBearerAuth()
@Controller("files")
export class FilesController {
constructor(private readonly filesService: FilesService) {}
@Get(":fileId")
// Public so the browser can load the bytes directly via <img>/<iframe>/<a> —
// those requests can't carry the Bearer token the axios client injects, so a
// guarded route 401s. File UUIDs are unguessable; same tradeoff as webhooks.
@Public()
// Authenticated: no @Public, so the global JwtGuard applies. Unguessable file
// UUIDs are obscurity, not authorization — raw byte streams must require auth.
// Browser inline previews (<img>/<iframe>/<a>) that can't carry the Bearer
// token should use a short-lived signed URL instead (FilesService.signUrl).
// TODO: enforce ownership-by-resource here next (scope the file to the
// caller's booking/company before streaming).
@ApiOperation({
summary: "Stream a file by ID",
description:

View File

@@ -1,4 +1,8 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import {
BadRequestException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { Readable } from "stream";
import { MinioService } from "../minio/minio.service";
@@ -28,6 +32,31 @@ function sanitizeObjectName(name: string): string {
@Injectable()
export class FilesService {
// Defense-in-depth for ANY caller of upload() (not just the driver-docs
// route). This is deliberately BROADER than the driver controller's strict
// images+pdf Multer filter, because the same method also stores generated
// PDFs, PNG signatures, and customer/customs booking documents (scans, office
// docs). It rejects the actual attack surface (executables/scripts/HTML) while
// permitting every business-document type these flows legitimately upload.
// No file-upload-settings row governs raw byte size, so the cap is a sane,
// generous default that won't reject large scanned documents.
private static readonly MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
private static readonly ALLOWED_UPLOAD_MIME = new Set([
"image/jpeg",
"image/png",
"image/webp",
"image/gif",
"image/heic",
"image/tiff",
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"text/csv",
"text/plain",
]);
constructor(
private readonly filesRepository: FilesRepository,
private readonly minioService: MinioService,
@@ -35,6 +64,15 @@ export class FilesService {
async upload(input: CreateFileInput): Promise<FileRecord> {
const { resourceId, resource, code, file } = input;
if (!FilesService.ALLOWED_UPLOAD_MIME.has(file.mimetype)) {
throw new BadRequestException(`Unsupported file type: ${file.mimetype}`);
}
if (file.size > FilesService.MAX_UPLOAD_BYTES) {
throw new BadRequestException(
`File exceeds the ${FilesService.MAX_UPLOAD_BYTES / (1024 * 1024)}MB upload limit`,
);
}
// Keep the object key URL-safe so it survives the round-trip through the
// stored URL (spaces/unicode in the original name would otherwise be
// percent-encoded in the URL and no longer match the MinIO key). The

View File

@@ -1,4 +1,4 @@
import { IsUUID, IsNumber, IsDateString, IsString, IsOptional, IsEnum } from 'class-validator';
import { IsUUID, IsNumber, IsPositive, IsDateString, IsString, IsOptional, IsEnum } from 'class-validator';
import { PaymentMethod } from '../entities/fuel-purchase.entity';
export class CreateFuelPurchaseDto {
@@ -9,9 +9,11 @@ export class CreateFuelPurchaseDto {
purchaseDate!: string;
@IsNumber()
@IsPositive()
liters!: number;
@IsNumber()
@IsPositive()
costPerLiter!: number;
@IsOptional()

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { ConflictException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { FuelRepository } from './fuel.repository';
@@ -15,6 +15,18 @@ export class FuelService {
) {}
async recordFuelPurchase(dto: CreateFuelPurchaseDto): Promise<FuelPurchase> {
// Reject a re-submitted receipt for the same vehicle (double-entry guard).
if (dto.receiptNumber) {
const duplicate = await this.purchaseRepository.findOne({
where: { vehicleId: dto.vehicleId, receiptNumber: dto.receiptNumber },
});
if (duplicate) {
throw new ConflictException(
`A fuel purchase with receipt number ${dto.receiptNumber} already exists for this vehicle`,
);
}
}
const totalCost = dto.liters * dto.costPerLiter;
const purchase = this.purchaseRepository.create({

View File

@@ -1,6 +1,8 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import {
AssignCustomsRiskDto,
CreateDjiboutiIncidentDto,
@@ -15,6 +17,9 @@ import { ImportOperationsService } from './import-operations.service';
@ApiTags('import-operations')
@ApiBearerAuth()
@Controller('import-operations')
// Post-booking customs / import-operations actions are GL/Ops work, mirroring the
// contracts controller's GL operational endpoints (risk, duty, milestones).
@BookingStaff(FREIGHT_PERMS.bookings.operations)
export class ImportOperationsController {
constructor(private readonly service: ImportOperationsService) {}

View File

@@ -8,18 +8,26 @@ import {
Param,
Query,
} from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { ApiBearerAuth, ApiTags, ApiOperation } from '@nestjs/swagger';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { IncidentsService } from './incidents.service';
import { CreateIncidentDto } from './dto/create-incident.dto';
import { UpdateIncidentDto } from './dto/update-incident.dto';
import { IncidentStatus, IncidentType } from './entities/incident.entity';
@ApiTags('Accident & Incident Management')
@ApiBearerAuth()
@Controller('incidents')
// No incidents-specific permission exists in the registry, so this reuses the
// (real) drivers.* fleet-road keys — incident records are driver-safety data
// (driver stats / incident history). TODO: add a dedicated incidents:* key.
@BookingStaff(FREIGHT_PERMS.drivers.view)
export class IncidentsController {
constructor(private readonly incidentsService: IncidentsService) {}
@Post()
@BookingStaff(FREIGHT_PERMS.drivers.create)
@ApiOperation({ summary: 'Report an incident' })
async create(@Body() dto: CreateIncidentDto) {
return this.incidentsService.create(dto);
@@ -55,12 +63,14 @@ export class IncidentsController {
}
@Patch(':id')
@BookingStaff(FREIGHT_PERMS.drivers.update)
@ApiOperation({ summary: 'Update an incident' })
async update(@Param('id') id: string, @Body() dto: UpdateIncidentDto) {
return this.incidentsService.update(id, dto);
}
@Delete(':id')
@BookingStaff(FREIGHT_PERMS.drivers.delete)
@ApiOperation({ summary: 'Delete an incident' })
async remove(@Param('id') id: string) {
await this.incidentsService.remove(id);

View File

@@ -1,6 +1,8 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { GenerateFromScheduleDto } from './dto/generate-from-schedule.dto';
import { InterchangeDocumentQueryDto } from './dto/interchange-document-query.dto';
import {
@@ -12,6 +14,8 @@ import { InterchangeDocumentsService } from './interchange-documents.service';
@ApiTags('interchange-documents')
@ApiBearerAuth()
@Controller('interchange-documents')
// Class-level view guard; each write route adds its own manage permission below.
@BookingStaff(FREIGHT_PERMS.interchangeDocuments.view)
export class InterchangeDocumentsController {
constructor(private readonly service: InterchangeDocumentsService) {}
@@ -28,12 +32,14 @@ export class InterchangeDocumentsController {
}
@Post('generate-from-schedule')
@BookingStaff(FREIGHT_PERMS.interchangeDocuments.generate)
@ApiOperation({ summary: 'Generate interchange document from a train schedule handover' })
generateFromSchedule(@Body() dto: GenerateFromScheduleDto) {
return this.service.generateFromSchedule(dto);
}
@Patch(':id/acknowledge')
@BookingStaff(FREIGHT_PERMS.interchangeDocuments.acknowledge)
@ApiOperation({ summary: 'Acknowledge an interchange document' })
acknowledge(
@Param('id', ParseUUIDPipe) id: string,
@@ -43,12 +49,14 @@ export class InterchangeDocumentsController {
}
@Patch(':id/dispute')
@BookingStaff(FREIGHT_PERMS.interchangeDocuments.dispute)
@ApiOperation({ summary: 'Dispute an interchange document' })
dispute(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DisputeInterchangeDocumentDto) {
return this.service.dispute(id, dto);
}
@Patch(':id/cancel')
@BookingStaff(FREIGHT_PERMS.interchangeDocuments.cancel)
@ApiOperation({ summary: 'Cancel a draft/generated interchange document' })
cancel(@Param('id', ParseUUIDPipe) id: string) {
return this.service.cancel(id);

View File

@@ -184,8 +184,13 @@ export class InterchangeDocumentsService {
dto: AcknowledgeInterchangeDocumentDto,
): Promise<InterchangeDocument> {
const document = await this.findOne(id);
if (document.status === 'CANCELLED') {
throw new BadRequestException('Cancelled interchange document cannot be acknowledged');
// Only a freshly GENERATED document can be acknowledged. Rejecting DISPUTED
// (as well as CANCELLED / already-ACKNOWLEDGED) stops an acknowledge from
// silently overriding a raised dispute.
if (document.status !== 'GENERATED') {
throw new BadRequestException(
`Interchange document in ${document.status} status cannot be acknowledged (must be GENERATED)`,
);
}
await this.dataSource.getRepository(InterchangeDocument).update(id, {
status: 'ACKNOWLEDGED',
@@ -197,7 +202,14 @@ export class InterchangeDocumentsService {
}
async dispute(id: string, dto: DisputeInterchangeDocumentDto): Promise<InterchangeDocument> {
await this.findOne(id);
const document = await this.findOne(id);
// A dispute can only be raised on a live handover — a GENERATED or already
// ACKNOWLEDGED document. CANCELLED and already-DISPUTED are terminal here.
if (!['GENERATED', 'ACKNOWLEDGED'].includes(document.status)) {
throw new BadRequestException(
`Interchange document in ${document.status} status cannot be disputed (must be GENERATED or ACKNOWLEDGED)`,
);
}
await this.dataSource.getRepository(InterchangeDocument).update(id, {
status: 'DISPUTED',
remarks: dto.remarks,
@@ -273,7 +285,10 @@ export class InterchangeDocumentsService {
NULL::uuid AS "cargoId",
a.booking_cargo_type AS "cargoType",
a.cargo_free_text AS "cargoDescription",
COALESCE(bc.total_vgm_tons, c.max_gross_weight, a.cargo_total_weight_vgm) AS "weight",
-- Item weight is normalized to TONS. total_vgm_tons and
-- cargo_total_weight_vgm are already tons; containers.max_gross_weight
-- is kilograms, so convert it (kg -> tons).
COALESCE(bc.total_vgm_tons, c.max_gross_weight / 1000.0 /* kg->tons */, a.cargo_total_weight_vgm) AS "weight",
COALESCE(bc.quantity, 1) AS "quantity",
COALESCE(bc.quantity, 1) AS "packageCount",
a.wagon_number AS "wagonNumber",
@@ -322,7 +337,9 @@ export class InterchangeDocumentsService {
cg.id AS "cargoId",
COALESCE(cgt.cargo_type_name, a.booking_cargo_type) AS "cargoType",
COALESCE(cg.description, a.cargo_free_text) AS "cargoDescription",
COALESCE(cg.weight, a.cargo_total_weight_vgm) AS "weight",
-- Normalized to TONS: cargoes.weight is kilograms (convert), while
-- cargo_total_weight_vgm is already tons.
COALESCE(cg.weight / 1000.0 /* kg->tons */, a.cargo_total_weight_vgm) AS "weight",
cg.quantity AS "quantity",
cg.quantity AS "packageCount",
a.wagon_number AS "wagonNumber",

View File

@@ -1,4 +1,5 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { CreateLocomotiveDto } from './dto/create-locomotive.dto';
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
@@ -9,11 +10,23 @@ import {
type LocomotiveStatus,
type LocomotiveType,
} from './entities/locomotive.entity';
import { TrainLocomotive } from '../trains/entities/train-locomotive.entity';
import { LocomotivesRepository } from './locomotives.repository';
@Injectable()
export class LocomotivesService {
constructor(private readonly locomotivesRepository: LocomotivesRepository) {}
constructor(
private readonly locomotivesRepository: LocomotivesRepository,
private readonly dataSource: DataSource,
) {}
/** The built-train link (if any) coupling this locomotive to a fleet train. */
private findTrainLink(locomotiveId: string): Promise<TrainLocomotive | null> {
return this.dataSource.getRepository(TrainLocomotive).findOne({
where: { locomotiveId },
relations: { train: true },
});
}
findAll(filter: FilterLocomotivesDto): Promise<Locomotive[]> {
return this.locomotivesRepository.findAll({
@@ -94,6 +107,22 @@ export class LocomotivesService {
}
}
// A locomotive coupled to a built train follows the train: its yard and
// status are owned by the train-builder flow, not this generic PATCH.
const link = await this.findTrainLink(id);
if (link) {
if (dto.currentYardId !== undefined && dto.currentYardId !== link.train?.currentYardId) {
throw new ConflictException(
`Locomotive ${locomotive.code} is coupled to train ${link.train?.code}; move the train (train-builder yard change) instead`,
);
}
if (dto.status !== undefined && dto.status !== locomotive.status) {
throw new ConflictException(
`Locomotive ${locomotive.code} is coupled to train ${link.train?.code}; detach it before changing its status`,
);
}
}
const updated = await this.locomotivesRepository.update(id, {
...dto,
locomotiveType:
@@ -119,7 +148,16 @@ export class LocomotivesService {
}
async decommission(id: string): Promise<Locomotive> {
await this.findById(id);
const locomotive = await this.findById(id);
// Can't retire a locomotive that is still coupled to a built train — detach
// it in the train-builder first so the train never loses a live loco.
const link = await this.findTrainLink(id);
if (link) {
throw new ConflictException(
`Locomotive ${locomotive.code} is coupled to train ${link.train?.code}; detach it before taking it out of service`,
);
}
const updated = await this.locomotivesRepository.update(id, {
status: 'OUT_OF_SERVICE',

View File

@@ -1,9 +1,10 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { DataSource, Repository } from 'typeorm';
import { MaintenanceRepository } from './maintenance.repository';
import { MaintenanceSchedule } from './entities/maintenance-schedule.entity';
import { MaintenanceSchedule, MaintenanceStatus } from './entities/maintenance-schedule.entity';
import { MaintenanceCost } from './entities/maintenance-cost.entity';
import { Vehicle, VehicleAvailability, VehicleStatus } from '../vehicles/entities/vehicle.entity';
import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto';
@Injectable()
@@ -14,15 +15,41 @@ export class MaintenanceService {
private readonly scheduleRepository: Repository<MaintenanceSchedule>,
@InjectRepository(MaintenanceCost)
private readonly costRepository: Repository<MaintenanceCost>,
// Vehicle isn't registered in this module's TypeOrmModule.forFeature, so we
// reach it through the global DataSource rather than @InjectRepository.
private readonly dataSource: DataSource,
) {}
/**
* Reflect a maintenance schedule's lifecycle on the target vehicle. A vehicle
* under maintenance is taken out of service (MAINTENANCE + BUSY); once the
* maintenance completes or is cancelled it returns to service (ACTIVE + FREE).
* Only the vehicle's status/availability columns are written here. The
* assignment-side reject (first-mile/last-mile refusing MAINTENANCE vehicles)
* lives in those excluded mile modules, not here.
*/
private async setVehicleMaintenanceState(
vehicleId: string,
underMaintenance: boolean,
): Promise<void> {
await this.dataSource.getRepository(Vehicle).update(vehicleId, {
status: underMaintenance ? VehicleStatus.MAINTENANCE : VehicleStatus.ACTIVE,
availability: underMaintenance
? VehicleAvailability.BUSY
: VehicleAvailability.FREE,
});
}
async scheduleMaintenanceAsync(dto: CreateMaintenanceScheduleDto): Promise<MaintenanceSchedule> {
const schedule = this.scheduleRepository.create({
...dto,
scheduledDate: new Date(dto.scheduledDate),
nextDueDate: dto.nextDueDate ? new Date(dto.nextDueDate) : undefined,
});
return this.scheduleRepository.save(schedule);
const saved = await this.scheduleRepository.save(schedule);
// Scheduling maintenance takes the vehicle out of the available pool.
await this.setVehicleMaintenanceState(saved.vehicleId, true);
return saved;
}
async recordMaintenanceCost(dto: CreateMaintenanceCostDto): Promise<MaintenanceCost> {
@@ -42,6 +69,21 @@ export class MaintenanceService {
completedDate: dto.completedDate ? new Date(dto.completedDate) : undefined,
});
const updated = await this.scheduleRepository.findOneBy({ id });
// Keep the vehicle's status/availability in step with the schedule status.
if (updated && dto.status) {
if (
dto.status === MaintenanceStatus.COMPLETED ||
dto.status === MaintenanceStatus.CANCELLED
) {
// Maintenance finished/aborted → vehicle back in service.
await this.setVehicleMaintenanceState(updated.vehicleId, false);
} else if (dto.status === MaintenanceStatus.IN_PROGRESS) {
// Maintenance started → keep the vehicle out of service.
await this.setVehicleMaintenanceState(updated.vehicleId, true);
}
}
return updated!;
}

View File

@@ -1,4 +1,9 @@
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
import {
Injectable,
Logger,
NotFoundException,
ServiceUnavailableException,
} from "@nestjs/common";
import { EmailNotificationStrategy } from "./strategies/notification.email.strategy";
import { NotificationStrategy } from "./strategies/notification.strategy";
import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy";
@@ -27,8 +32,19 @@ export class NotificationsService {
if (!strategy) {
throw new NotFoundException();
}
// A strategy returning false (or throwing) is a real delivery failure — do
// not swallow it. Surface it so callers observe the failure (existing
// callers wrap directSend in try/catch for best-effort notifications).
const sent = await strategy.send(recipient, message);
this.logger.log(`is sent - ${sent}`);
if (!sent) {
this.logger.error(
`Notification via ${method} to ${recipient} failed to send`,
);
throw new ServiceUnavailableException(
`Failed to send ${method} notification`,
);
}
this.logger.log(`Notification via ${method} to ${recipient} sent`);
}
async notifyDriverVehicleAssignment(params: {

View File

@@ -1,12 +1,28 @@
import { Injectable, Logger } from "@nestjs/common";
import { NotificationStrategy } from "./notification.strategy";
import { EmailClientService } from "../email-client.service";
@Injectable()
export class EmailNotificationStrategy implements NotificationStrategy {
private readonly logger = new Logger(EmailNotificationStrategy.name);
constructor() { }
constructor(private readonly emailClient: EmailClientService) { }
async send(recipient: string, message: string): Promise<boolean> {
this.logger.log(`${recipient}, ${message}`)
return false;
try {
// Route through the shared email client (RabbitMQ hand-off). `queued`
// reflects whether the message was accepted for delivery; a false or
// a thrown result is a real failure the caller must observe.
const { queued } = await this.emailClient.sendEmail({
to: recipient,
subject: "EDR Freight notification",
text: message,
});
return queued;
} catch (err) {
this.logger.error(
`Failed to send email to ${recipient}: ${err instanceof Error ? err.message : String(err)}`,
err instanceof Error ? err.stack : undefined,
);
return false;
}
}
}
}

View File

@@ -19,6 +19,9 @@ function toTarget(phone?: string, email?: string): OtpTarget {
throw new BadRequestException("phone or email is required");
}
// TODO: these public routes need per-target + per-IP rate limiting (a NestJS
// ThrottlerGuard / @Throttle on /otp/send and /otp/verify). No Throttler is
// wired into the app yet; add @nestjs/throttler and apply it here.
@Controller("otp")
@Public()
export class OtpController {

View File

@@ -1,6 +1,7 @@
// otp.service.ts
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
import { randomInt } from "node:crypto";
import { OtpRepository } from "./otp.repository";
@@ -25,7 +26,9 @@ export class OtpService {
// ---------------------------------------------------------------------------
generateOtp(): string {
return Math.floor(100000 + Math.random() * 900000).toString();
// Cryptographically secure 6-digit code (100000999999). Math.random() is a
// non-CSPRNG and must never be used to mint a security token.
return randomInt(100000, 1000000).toString();
}
// ---------------------------------------------------------------------------
@@ -50,8 +53,13 @@ export class OtpService {
await this.otpRepository.createOtp(target, otp);
}
// A freshly issued code gets a fresh guess budget.
this.actionAttempts.delete(this.targetKey(target));
// NOTE: do NOT reset the brute-force attempt counter on send. Clearing it
// here let an attacker wipe the per-target guess budget just by calling
// /otp/send between guesses. The counter is cleared only when the code is
// consumed/expired during verification.
// TODO: add per-target + per-IP rate limiting on the public /otp/send and
// /otp/verify routes (a NestJS ThrottlerGuard / @Throttle) — none exists
// in the codebase yet.
if (target.email) {
// send email (queued to RabbitMQ via the shared Email service)
@@ -94,6 +102,7 @@ export class OtpService {
async verifyOtp(target: OtpTarget, otp: string) {
// find the channel's row
const otpData = await this.otpRepository.findByTarget(target);
const key = this.targetKey(target);
// not found
if (!otpData) {
@@ -102,13 +111,35 @@ export class OtpService {
);
}
// invalid otp
// TTL: reuse the same age window as the hardened action verifier — an old
// code can't be verified.
const ageMs = Date.now() - new Date(otpData.updatedAt).getTime();
if (ageMs > this.ACTION_OTP_TTL_MS) {
await this.otpRepository.deleteOtp(otpData);
this.actionAttempts.delete(key);
throw new BadRequestException(
"Verification code has expired. Request a new one.",
);
}
// invalid otp — per-target attempt cap so a 6-digit code can't be
// brute-forced within its TTL; the code is burned once the budget is spent.
if (otpData.otp !== otp) {
const attempts = (this.actionAttempts.get(key) ?? 0) + 1;
if (attempts >= this.MAX_ACTION_ATTEMPTS) {
await this.otpRepository.deleteOtp(otpData);
this.actionAttempts.delete(key);
throw new BadRequestException(
"Too many incorrect attempts. Request a new code.",
);
}
this.actionAttempts.set(key, attempts);
throw new BadRequestException("Invalid OTP");
}
// mark verified
await this.otpRepository.markVerified(otpData);
// single-use: consume the code on success so it can't be replayed.
await this.otpRepository.deleteOtp(otpData);
this.actionAttempts.delete(key);
return {
success: true,

View File

@@ -1,8 +1,15 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { TrainScheduleStatus } from '@edr/types';
import { DataSource, In } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { Yard } from '../rule-engine/entities/yard.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { CreateRouteDto } from './dto/create-route.dto';
import { FilterRoutesDto } from './dto/filter-routes.dto';
import { UpdateRouteDto } from './dto/update-route.dto';
@@ -112,6 +119,30 @@ export class RoutesService {
? await this.validateMilestones(dto.milestones)
: null;
// Milestones or endpoints are about to be rewritten — reject if any
// non-terminal schedule still references this route, otherwise its stop list
// and distances would silently shift under a live plan. Status-only /
// label-only edits (no milestones supplied) are always allowed.
if (milestoneInput) {
const activeSchedules = await this.dataSource
.getRepository(TrainSchedule)
.count({
where: {
routeId: id,
status: In([
TrainScheduleStatus.Draft,
TrainScheduleStatus.Scheduled,
TrainScheduleStatus.Dispatched,
]),
},
});
if (activeSchedules > 0) {
throw new ConflictException(
'This route is used by active train schedules and its stops cannot be changed. Create a new route instead.',
);
}
}
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(Route).update(id, {
originYardId: milestoneInput?.originYardId ?? existing.originYardId,

View File

@@ -5,6 +5,7 @@ import {
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
Logger,
NotFoundException,
@@ -82,6 +83,15 @@ export class PriorityRuleChangeRequestsService {
): Promise<PriorityRuleChangeRequest> {
const request = await this.findPending(id);
// Separation of duties: the requester cannot approve their own change.
// TODO: split approval into a distinct approver permission rather than
// relying on this id check.
if (userId && userId === request.requestedByUserId) {
throw new ForbiddenException(
'You cannot approve a change request you submitted',
);
}
// Apply the change through the normal service so currency + range-collision
// validation runs against the CURRENT rules; a stale request that now
// collides fails here and stays PENDING for the approver to see the error.

View File

@@ -1,6 +1,7 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Inject,
Injectable,
NotFoundException,
@@ -199,6 +200,12 @@ export class RatesService {
if (rate.status !== 'PENDING_APPROVAL') {
throw new BadRequestException('Only PENDING_APPROVAL rates can be approved');
}
// Separation of duties: the proposer cannot approve their own rate.
// TODO: split approval into a distinct CEO/approver permission — a proposer
// who also holds the approve permission is still the wrong person to sign off.
if (approverUserId === rate.proposedByStaffId) {
throw new ForbiddenException('You cannot approve a rate you proposed');
}
const updated = await this.repository.update(id, {
status: 'LIVE',
approvedByCeoId: approverUserId,

View File

@@ -0,0 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsDateString } from 'class-validator';
import { PreviewRescheduleDto } from './preview-reschedule.dto';
/**
* Body for the maintenance-reschedule endpoint. This must be a real class (not
* the previous `PreviewRescheduleDto & { newDepartureDate: string }`
* intersection): an intersection type carries no class-validator metadata, so
* Nest's ValidationPipe silently skipped validation of the whole payload.
*/
export class MaintenanceRescheduleDto extends PreviewRescheduleDto {
@ApiProperty({ example: '2026-06-22T08:00:00.000Z' })
@IsDateString()
newDepartureDate!: string;
}

View File

@@ -8,6 +8,7 @@ import {
resolveAuthUserId,
} from '../../common/resolve-auth-user-id';
import { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto';
import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto';
import { SchedulingRescheduleService } from './scheduling-reschedule.service';
@ApiTags('train-scheduling')
@@ -53,7 +54,7 @@ export class SchedulingMaintenanceController {
@ApiOperation({ summary: 'Reschedule train for maintenance (new departure + rebalance)' })
maintenance(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: PreviewRescheduleDto & { newDepartureDate: string },
@Body() dto: MaintenanceRescheduleDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.schedulingRescheduleService.maintenanceReschedule(

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { EntityManager, Repository } from 'typeorm';
import { SchedulingEvent, type RescheduleTrigger } from './entities/scheduling-event.entity';
@@ -12,14 +12,18 @@ export class SchedulingRescheduleRepository {
) {}
/** Persist an audit record for a completed reschedule. */
async createEvent(data: {
trainScheduleId: string;
trigger: RescheduleTrigger;
actorUserId?: string;
reason?: string;
planSnapshot: Record<string, unknown>;
displacedBookingIds: string[];
}): Promise<SchedulingEvent> {
return this.repository.save(this.repository.create(data));
async createEvent(
data: {
trainScheduleId: string;
trigger: RescheduleTrigger;
actorUserId?: string;
reason?: string;
planSnapshot: Record<string, unknown>;
displacedBookingIds: string[];
},
manager?: EntityManager,
): Promise<SchedulingEvent> {
const repo = manager ? manager.getRepository(SchedulingEvent) : this.repository;
return repo.save(repo.create(data));
}
}

View File

@@ -54,6 +54,10 @@ describe('SchedulingRescheduleService', () => {
let bookingsRepository: Record<string, jest.Mock>;
let trainSchedulingService: Record<string, jest.Mock>;
let schedulingRescheduleRepository: Record<string, jest.Mock>;
// Sentinel EntityManager the mocked dataSource.transaction hands to the
// callback; executeReschedule threads it into updateStatus/createEvent.
const txManager = {} as never;
let dataSource: { transaction: jest.Mock };
beforeEach(() => {
trainSchedulesRepository = {
@@ -72,6 +76,9 @@ describe('SchedulingRescheduleService', () => {
schedulingRescheduleRepository = {
createEvent: jest.fn().mockResolvedValue({ id: 'event-1' }),
};
dataSource = {
transaction: jest.fn(async (cb: (m: never) => unknown) => cb(txManager)),
};
service = new SchedulingRescheduleService(
trainSchedulesRepository as never,
@@ -83,6 +90,7 @@ describe('SchedulingRescheduleService', () => {
removedFromTrain: jest.fn(),
maintenanceMoved: jest.fn(),
} as never, // notifier
dataSource as never,
);
});
@@ -212,7 +220,7 @@ describe('SchedulingRescheduleService', () => {
incomingBookingIds: ['c1'],
trigger: 'TRAIN_MAINTENANCE',
reason: 'Locomotive service',
newDepartureDate: '2026-06-22T10:00:00.000Z',
newDepartureDate: '2099-06-22T10:00:00.000Z',
},
'staff-1',
);
@@ -220,7 +228,8 @@ describe('SchedulingRescheduleService', () => {
expect(trainSchedulesRepository.updateStatus).toHaveBeenCalledWith(
'sched-1',
'DRAFT',
{ scheduledDepartureDate: new Date('2026-06-22T10:00:00.000Z') },
{ scheduledDepartureDate: new Date('2099-06-22T10:00:00.000Z') },
txManager,
);
expect(schedulingRescheduleRepository.createEvent).toHaveBeenCalledWith(
expect.objectContaining({
@@ -228,6 +237,7 @@ describe('SchedulingRescheduleService', () => {
actorUserId: 'staff-1',
reason: 'Locomotive service',
}),
txManager,
);
expect(result.plan.trigger).toBe('TRAIN_MAINTENANCE');
expect(result.plan.finalBookingIds).toEqual(['c1']);

View File

@@ -3,6 +3,8 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { SchedulingStatus, TrainScheduleStatus } from '@edr/types';
import { Booking } from '../bookings/entities/booking.entity';
@@ -12,6 +14,7 @@ import { TrainSchedulesRepository } from '../train-schedules/train-schedules.rep
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { BookingNotifierService } from '../train-scheduling/booking-notifier.service';
import { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto';
import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto';
import { SchedulingRescheduleRepository } from './scheduling-reschedule.repository';
export interface RescheduleBookingSummary {
@@ -40,6 +43,8 @@ export class SchedulingRescheduleService {
private readonly trainSchedulingService: TrainSchedulingService,
private readonly schedulingRescheduleRepository: SchedulingRescheduleRepository,
private readonly notifier: BookingNotifierService,
@InjectDataSource()
private readonly dataSource: DataSource,
) {}
/** Preview who is retained, displaced, and readmitted on a schedule. */
@@ -148,21 +153,38 @@ export class SchedulingRescheduleService {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (dto.newDepartureDate && schedule) {
await this.trainSchedulesRepository.updateStatus(
scheduleId,
schedule.status as TrainScheduleStatus,
{ scheduledDepartureDate: new Date(dto.newDepartureDate) },
);
// M7: validate the requested departure BEFORE mutating anything, so a past
// or malformed date is rejected up front rather than after (un)assign side
// effects have already run. The actual write happens late (below), so a
// failing (un)assign step never leaves the train visibly moved.
let newDeparture: Date | null = null;
if (dto.newDepartureDate) {
newDeparture = new Date(dto.newDepartureDate);
const now = new Date();
if (Number.isNaN(newDeparture.getTime()) || newDeparture <= now) {
throw new BadRequestException('New departure date must be in the future');
}
}
// H18: run the cross-service (un)assign steps FIRST. They own their own
// transactions and are the steps most likely to fail, so doing them before
// the date change + audit write means a failure aborts before anything of
// ours is committed.
for (const bookingId of dto.displacedBookingIds) {
try {
await this.trainSchedulingService.unassignBooking(scheduleId, bookingId);
} catch {
// M11: the unassign failed but this booking is being removed from the
// train — also clear its schedule pointer, otherwise it stays linked
// (stale trainScheduleId) and risks being double-booked. NOTE: the
// TrainScheduleBooking link row / wagon allocations may still persist
// (deleting those lives in TrainSchedulingService, not an injected repo
// we own), so this is a best-effort detach; a human should finish the
// link/allocation cleanup.
await this.bookingsRepository.updateSchedulingFields(bookingId, {
schedulingStatus: SchedulingStatus.Eligible,
wagonsRequired: null,
trainScheduleId: null,
});
}
}
@@ -170,7 +192,7 @@ export class SchedulingRescheduleService {
// A train can be rescheduled even with no bookings (e.g. moved for
// maintenance). assignBookingsToSchedule requires at least one booking, so
// only call it when something is actually being (re)assigned — the new
// departure date above is the meaningful change for an empty train. The
// departure date below is the meaningful change for an empty train. The
// empty-train branch returns the same schedule-detail shape as the assign
// path so callers get a consistent response.
const assignResult = dto.finalBookingIds.length
@@ -186,25 +208,50 @@ export class SchedulingRescheduleService {
deferredBookings: [] as unknown[],
};
await this.schedulingRescheduleRepository.createEvent({
trainScheduleId: scheduleId,
trigger: dto.trigger,
actorUserId,
reason: dto.reason,
planSnapshot: plan as unknown as Record<string, unknown>,
displacedBookingIds: dto.displacedBookingIds,
// H18: apply the date change and write the audit record LAST, together, in a
// single transaction over repositories we own (both updateStatus and
// createEvent accept our manager, so the two writes commit or roll back as
// one). RESIDUAL RISK: the cross-service (un)assign calls above are NOT
// covered by this transaction — they run their own and cannot be threaded
// through this manager without editing TrainSchedulingService. A failure
// between those steps and this block can still leave partial state; a human
// must finish the full cross-service transaction threading.
await this.dataSource.transaction(async (manager) => {
if (newDeparture) {
// M7: raw write of scheduledDepartureDate. We deliberately do NOT
// delegate to TrainSchedulingService.updateScheduleDate, which only
// permits a date change while windowPhase === 'PRE_WINDOW' and would
// reject reschedules of already-open (SCHEDULED) trains. Consequence:
// the booking-window fields are NOT re-derived for the new date here.
await this.trainSchedulesRepository.updateStatus(
scheduleId,
schedule.status as TrainScheduleStatus,
{ scheduledDepartureDate: newDeparture },
manager,
);
}
await this.schedulingRescheduleRepository.createEvent(
{
trainScheduleId: scheduleId,
trigger: dto.trigger,
actorUserId,
reason: dto.reason,
planSnapshot: plan as unknown as Record<string, unknown>,
displacedBookingIds: dto.displacedBookingIds,
},
manager,
);
});
// Notify affected customers (SMS + email). Best-effort — a notification
// failure must never fail the reschedule, so each send is fire-and-forget
// inside the notifier. Government pre-empt already notifies via the batch
// displaced() path, so skip removed-from-train notices for that trigger.
// Use the new departure date when the reschedule moved it (the in-memory
// `schedule` still holds the pre-update date).
const effectiveDeparture = dto.newDepartureDate
? new Date(dto.newDepartureDate)
: schedule.scheduledDepartureDate;
await this.notifyRescheduleOutcome(dto, effectiveDeparture);
// M12: only announce a new departure when the date actually moved —
// `newDeparture` is null when the date was unchanged, so retained customers
// are not falsely told the train was rescheduled.
await this.notifyRescheduleOutcome(dto, newDeparture);
return { plan, schedule: assignResult };
}
@@ -256,17 +303,26 @@ export class SchedulingRescheduleService {
/** Maintenance shortcut: new departure + rebalance. */
async maintenanceReschedule(
scheduleId: string,
dto: PreviewRescheduleDto & { newDepartureDate: string },
dto: MaintenanceRescheduleDto,
actorUserId?: string,
) {
const currentIds = (
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId)
)?.scheduleBookings?.map((l) => l.bookingId) ?? [];
// M13: merge the bookings already on the train with any caller-supplied
// incoming ids and feed the SAME set to both preview and execute. The old
// code dropped the caller's ids whenever the train was non-empty (preview)
// and then executed against a different (raw) set, so the previewed plan and
// the executed plan could diverge.
const mergedIncomingIds = Array.from(
new Set([...currentIds, ...(dto.incomingBookingIds ?? [])]),
);
const preview = await this.previewReschedule(scheduleId, {
...dto,
trigger: 'TRAIN_MAINTENANCE',
incomingBookingIds: currentIds.length ? currentIds : dto.incomingBookingIds,
incomingBookingIds: mergedIncomingIds,
});
return this.executeReschedule(
@@ -274,7 +330,7 @@ export class SchedulingRescheduleService {
{
...dto,
trigger: 'TRAIN_MAINTENANCE',
incomingBookingIds: dto.incomingBookingIds,
incomingBookingIds: mergedIncomingIds,
finalBookingIds: preview.finalBookingIds,
displacedBookingIds: preview.displaced.map((b) => b.id),
},

View File

@@ -2,7 +2,7 @@ export interface SchedulingPriorityBooking {
isGovernment?: boolean;
priorityScore?: number | null;
// One-time bookings always carry a date; general contracts (never scheduled)
// may be null — treated as epoch 0 so they sort last.
// may be null — treated as the far future (MAX_SAFE_INTEGER) so they sort last.
scheduledDate?: Date | string | null;
}
@@ -17,7 +17,11 @@ export function compareSchedulingPriority(
const priorityDiff = (b.priorityScore ?? 0) - (a.priorityScore ?? 0);
if (priorityDiff !== 0) return priorityDiff;
const aTime = a.scheduledDate ? new Date(a.scheduledDate).getTime() : 0;
const bTime = b.scheduledDate ? new Date(b.scheduledDate).getTime() : 0;
const aTime = a.scheduledDate
? new Date(a.scheduledDate).getTime()
: Number.MAX_SAFE_INTEGER;
const bTime = b.scheduledDate
? new Date(b.scheduledDate).getTime()
: Number.MAX_SAFE_INTEGER;
return aTime - bTime;
}

View File

@@ -1,15 +1,22 @@
import { Controller, Get, Param, ParseUUIDPipe } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { TrackingService } from "./tracking.service";
@ApiTags("tracking")
@ApiBearerAuth()
@Controller("tracking")
@BookingStaff(FREIGHT_PERMS.tracking.view)
export class TrackingController {
constructor(private readonly trackingService: TrackingService) {}
@Get(":consignmentId")
@ApiOperation({ summary: "Get the tracking timeline for a consignment" })
// TODO: scope to the caller's company — a staffer with tracking:view can
// currently read any consignment's timeline. Add company-ownership filtering
// once the ownership helper is wired into this module.
findByConsignment(
@Param("consignmentId", ParseUUIDPipe) consignmentId: string,
) {

View File

@@ -769,7 +769,54 @@ export class BookingBatchService implements OnModuleInit {
bookings: Booking[],
scheduleId: string,
): Promise<void> {
for (const b of bookings) await this.reserve(b, scheduleId);
// H8: the capacity check (pickExportSchedule → budget.fits) and the
// reservation writes below are not atomic on their own — two concurrent
// export accepts can each see the same train as fitting and both reserve,
// overshooting the train's capacity. Serialize reservations against this
// schedule: take a pessimistic_write lock on the TrainSchedule row
// (SELECT … FOR UPDATE), then RE-VERIFY budget.fits for these bookings'
// combined need from freshly-committed state INSIDE the lock before the
// reserve writes run. A loser (another accept took the space first) gets a
// ConflictException — the staff accept fails and reverts, exactly as an
// up-front full train does. Covered: the fits-vs-reserve overshoot on the
// export FCFS path; the lock is held for the duration of the reserve writes.
await this.dataSource.transaction(async (manager) => {
const locked = await manager.findOne(TrainSchedule, {
where: { id: scheduleId },
lock: { mode: "pessimistic_write" },
});
if (!locked) {
throw new ConflictException(
"Export train is no longer available for reservation",
);
}
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
const locomotive = schedule?.trainSet?.locomotive;
if (!schedule || !locomotive) {
throw new ConflictException(
"Export train is no longer available for reservation",
);
}
const wagonDims = await this.loadWagonDims();
const limits = await this.capacityLimits(locomotive);
const budget = await this.remainingBudget(schedule, limits, wagonDims);
const leg = budget.legOf(
bookings[0].originYardId,
bookings[0].destinationYardId,
);
const need =
bookings.length >= 2
? this.combinedNeed(bookings[0], bookings[1], wagonDims)
: this.needFor(bookings[0], wagonDims);
if (!leg || !budget.fits(need, leg)) {
throw new ConflictException(
"Train is full — no export capacity left for this day",
);
}
for (const b of bookings) await this.reserve(b, scheduleId);
});
this.armSettle(scheduleId);
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);

View File

@@ -1522,22 +1522,48 @@ export class TrainSchedulingService {
manager,
);
const remainingBookings = (schedule.scheduleBookings ?? []).filter(
(sb) => sb.bookingId !== bookingId,
);
if (remainingBookings.length === 0) {
await this.wagonBookingAllocationsRepository.deleteByTrainSetId(
schedule.trainSetId,
manager,
// Recompute the train-set composition from whatever survives this removal.
// The removed booking's allocations were already deleted above, so any slot
// left with zero allocations was ridden only by this booking — release it
// (frees its reserved wagon slot). Shared slots keep their surviving
// allocations and are re-weighed. This fixes stale tonnage/length/wagonCount
// and orphaned RESERVED slots on a PARTIAL unassign (previously only the
// fully-empty train was reset).
const survivingSlots = await manager.getRepository(TrainSetWagon).find({
where: { trainSetId: schedule.trainSetId },
relations: { allocations: true },
});
let recomputedWeightTons = 0;
let recomputedLengthMeters = 0;
let recomputedWagonCount = 0;
for (const slot of survivingSlots) {
const slotAllocations = slot.allocations ?? [];
if (slotAllocations.length === 0) {
await manager.getRepository(TrainSetWagon).delete(slot.id);
continue;
}
const slotWeight = slotAllocations.reduce(
(sum, a) => sum + Number(a.allocatedWeightTons ?? 0),
0,
);
await manager.getRepository(TrainSetWagon).delete({ trainSetId: schedule.trainSetId });
await manager.getRepository(TrainSet).update(schedule.trainSetId, {
totalWeightTons: 0,
totalLengthMeters: 0,
wagonCount: 0,
status: 'DRAFT',
});
if (Number(slot.assignedWeightTons) !== slotWeight) {
await manager
.getRepository(TrainSetWagon)
.update(slot.id, { assignedWeightTons: roundTons(slotWeight) });
}
recomputedWeightTons += slotWeight;
recomputedLengthMeters += Number(slot.lengthMeters ?? 0);
recomputedWagonCount += 1;
}
await manager.getRepository(TrainSet).update(schedule.trainSetId, {
totalWeightTons: roundTons(recomputedWeightTons),
totalLengthMeters: roundTons(recomputedLengthMeters),
wagonCount: recomputedWagonCount,
// Only downgrade to DRAFT once the train is fully empty; otherwise keep
// the current status (an object literal lets TypeORM's contextual typing
// accept the partial without pulling in relation fields).
...(recomputedWagonCount === 0 ? { status: 'DRAFT' } : {}),
});
});
await this.trainCompositionRemovalLogRepository.create({
@@ -3228,6 +3254,14 @@ export class TrainSchedulingService {
if (!schedule) {
throw new NotFoundException(`Train schedule ${id} not found`);
}
if (
schedule.status !== TrainScheduleStatusEnum.Draft &&
schedule.status !== TrainScheduleStatusEnum.Scheduled
) {
throw new BadRequestException(
`Cannot cancel a ${schedule.status} train; only DRAFT or SCHEDULED schedules may be cancelled`,
);
}
const now = new Date();
@@ -3397,6 +3431,27 @@ export class TrainSchedulingService {
violations.push('Selected bookings must lie on the schedule route (origin before destination)');
}
// Day-match: a booking scheduled for a specific EAT day must board a train
// departing that same day. forceAssign downgrades a mismatch to a warning
// so staff can knowingly move a booking onto an adjacent-day train.
const scheduleDay = eatDay(new Date(dto.scheduleDate));
const dayMismatched = bookings.filter(
(b) =>
!(targetScheduleId && b.trainScheduleId === targetScheduleId) &&
b.scheduledDate != null &&
eatDay(new Date(b.scheduledDate)) !== scheduleDay,
);
if (dayMismatched.length) {
const message = `Bookings scheduled for a different day than this train's departure (${scheduleDay}): ${dayMismatched
.map((b) => b.reference ?? b.id)
.join(', ')}`;
if (forceAssign) {
warnings.push(message);
} else {
violations.push(message);
}
}
if (!forceAssign) {
for (const booking of bookings) {
if (this.isHoldActive(booking)) {

View File

@@ -1,15 +1,19 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { WagonStatus } from '@edr/types';
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm';
import { DataSource, FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm';
import { CreateTrainDto } from './dto/create-train.dto';
import { UpdateTrainDto } from './dto/update-train.dto';
import { TrainLocomotive } from './entities/train-locomotive.entity';
import { Train } from './entities/train.entity';
import { Wagon } from '../wagons/entities/wagon.entity';
@Injectable()
export class TrainsService {
constructor(
@InjectRepository(Train)
private readonly trainRepo: Repository<Train>,
private readonly dataSource: DataSource,
) {}
create(dto: CreateTrainDto): Promise<Train> {
@@ -54,8 +58,40 @@ export class TrainsService {
return this.trainRepo.save(train);
}
/**
* Delete a built train. Blocked while it still has a live (DRAFT/SCHEDULED/
* DISPATCHED) schedule; otherwise its wagons are freed (back to AVAILABLE)
* and its locomotive links dropped so nothing is stranded, then the train is
* soft-deleted. Mirrors TrainBuilderService.disband but prefers softRemove.
*/
async remove(id: string): Promise<void> {
const train = await this.findById(id);
await this.trainRepo.remove(train);
await this.dataSource.transaction(async (manager) => {
const train = await manager.getRepository(Train).findOne({ where: { id } });
if (!train) throw new NotFoundException(`Train ${id} not found`);
const active: { count: string }[] = await manager.query(
`SELECT COUNT(*)::text AS count
FROM freight.train_schedules ts
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
WHERE tset.train_id = $1
AND ts.deleted_at IS NULL
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')`,
[id],
);
if (Number(active[0]?.count ?? 0) > 0) {
throw new ConflictException(
'Train has active schedules; cancel them before deleting the train',
);
}
await manager
.getRepository(Wagon)
.update(
{ trainId: train.id },
{ trainId: null, sequenceNumber: null, status: WagonStatus.Available },
);
await manager.getRepository(TrainLocomotive).delete({ trainId: train.id });
await manager.getRepository(Train).softRemove(train);
});
}
}

View File

@@ -1,4 +1,8 @@
import { PartialType } from '@nestjs/swagger';
import { OmitType, PartialType } from '@nestjs/swagger';
import { CreateWagonDto } from './create-wagon.dto';
export class UpdateWagonDto extends PartialType(CreateWagonDto) {}
// `trainId` and `sequenceNumber` are owned by the assign/train-builder flow and
// must never be settable through a generic wagon PATCH — omit them here.
export class UpdateWagonDto extends PartialType(
OmitType(CreateWagonDto, ['trainId', 'sequenceNumber'] as const),
) {}

View File

@@ -1,5 +1,10 @@
import { WagonMovementKind, WagonStatus } from '@edr/types';
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { Freight, WagonMovementKind, WagonStatus } from '@edr/types';
import {
BadRequestException,
Injectable,
NotFoundException,
ConflictException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike, In } from 'typeorm';
import { CreateWagonDto } from './dto/create-wagon.dto';
@@ -89,6 +94,20 @@ export class WagonsService {
async update(id: string, dto: UpdateWagonDto, userId?: string | null): Promise<Wagon> {
const wagon = await this.findById(id);
// A wagon coupled to a built train follows the train: its yard and status
// are managed through the train-builder flow, not this generic PATCH.
if (wagon.trainId != null) {
if (dto.currentYardId !== undefined && dto.currentYardId !== wagon.currentYardId) {
throw new ConflictException(
`Wagon ${wagon.wagonNumber} is coupled to a built train; relocate the train (train-builder) instead of moving the wagon`,
);
}
if (dto.status !== undefined && dto.status !== wagon.status) {
throw new ConflictException(
`Wagon ${wagon.wagonNumber} is coupled to a built train; detach it via train-builder before changing its status`,
);
}
}
const previousYardId = wagon.currentYardId ?? null;
Object.assign(wagon, dto);
// `findById` eager-loads `currentYard`; when the DTO changes the scalar FK
@@ -135,36 +154,96 @@ export class WagonsService {
async remove(id: string): Promise<void> {
const wagon = await this.findById(id);
await this.wagonRepo.remove(wagon);
// A coupled wagon must be detached via train-builder before it can be
// removed, so a built train never silently loses a wagon.
if (wagon.trainId != null) {
throw new ConflictException(
`Wagon ${wagon.wagonNumber} is coupled to a built train; detach it via train-builder before deleting it`,
);
}
if (await this.isWagonPinnedToLiveSchedule(id)) {
throw new ConflictException(
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be deleted`,
);
}
// Soft delete (deleted_at) — hard-deleting would strand ledger/schedule
// history that references this wagon.
await this.wagonRepo.softRemove(wagon);
}
/**
* A wagon is busy when any live (DRAFT/SCHEDULED/DISPATCHED) schedule pins it
* to one of its slots — schedule occupancy lives on TrainSetWagon rows, not
* on the Wagon entity. Mirrors TrainBuilderService.isWagonPinnedToLiveSchedule.
*/
private async isWagonPinnedToLiveSchedule(wagonId: string): Promise<boolean> {
const rows: { exists: boolean }[] = await this.dataSource.query(
`SELECT TRUE AS exists
FROM freight.train_set_wagons tsw
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
WHERE tsw.physical_wagon_id = $1
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
AND ts.deleted_at IS NULL
AND tsw.deleted_at IS NULL
LIMIT 1`,
[wagonId],
);
return rows.length > 0;
}
async assignToTrain(wagonId: string, dto: AssignWagonToTrainDto): Promise<Wagon> {
const wagon = await this.findById(wagonId);
if (wagon.status === WagonStatus.Assigned) {
throw new ConflictException('Wagon already assigned to a train');
// Mirror train-builder attachWagons: only a truly free, available wagon in
// the train's own yard can be coupled, and never onto a dispatched train.
if (wagon.trainId != null) {
throw new ConflictException(`Wagon ${wagon.wagonNumber} is already on another train`);
}
if (wagon.status !== WagonStatus.Available) {
throw new ConflictException(`Wagon ${wagon.wagonNumber} is not available (${wagon.status})`);
}
const train = await this.trainRepo.findOne({ where: { id: dto.trainId } });
if (!train) throw new NotFoundException('Train not found');
if (train.status === Freight.TrainStatus.InService) {
throw new ConflictException(
`Train ${train.code} is out on a dispatched run; its composition is frozen until arrival`,
);
}
if (wagon.currentYardId !== train.currentYardId) {
throw new BadRequestException(
`Wagon ${wagon.wagonNumber} is not in the train's yard; only wagons in the same yard can be attached`,
);
}
let sequence: number | null = dto.sequenceNumber ?? null;
if (sequence === null) {
const maxSeq = await this.wagonRepo
.createQueryBuilder('w')
.select('MAX(w.sequenceNumber)', 'max')
.where('w.trainId = :trainId', { trainId: train.id })
.getRawOne();
sequence = (maxSeq?.max ?? 0) + 1;
const maxSeq = await this.wagonRepo
.createQueryBuilder('w')
.select('MAX(w.sequenceNumber)', 'max')
.where('w.trainId = :trainId', { trainId: train.id })
.getRawOne();
const nextSequence = Number(maxSeq?.max ?? 0) + 1;
// An explicit sequence is only honoured when it is the next free slot;
// anything else would duplicate a slot or leave a gap.
if (dto.sequenceNumber != null && dto.sequenceNumber !== nextSequence) {
throw new BadRequestException(
`Sequence ${dto.sequenceNumber} is not the next free slot (${nextSequence}) for train ${train.code}`,
);
}
wagon.trainId = train.id;
wagon.sequenceNumber = sequence;
wagon.sequenceNumber = nextSequence;
wagon.status = WagonStatus.Assigned;
return this.wagonRepo.save(wagon);
}
async unassignFromTrain(wagonId: string): Promise<Wagon> {
const wagon = await this.findById(wagonId);
// A wagon pinned to a live schedule is still operationally committed even
// if the fleet train is being edited — don't free it out from under it.
if (await this.isWagonPinnedToLiveSchedule(wagonId)) {
throw new ConflictException(
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be detached`,
);
}
wagon.trainId = null;
wagon.sequenceNumber = null;
wagon.status = WagonStatus.Available;
@@ -201,6 +280,19 @@ export class WagonsService {
throw new NotFoundException('One or more wagons not found');
}
// Only free, available wagons can be bulk-relocated; a coupled wagon
// moves with its train (train-builder), never on its own here.
const blocked = wagons.filter(
(w) => w.trainId != null || w.status !== WagonStatus.Available,
);
if (blocked.length) {
throw new ConflictException(
`Cannot transfer wagons coupled to a train or not available: ${blocked
.map((w) => w.wagonNumber)
.join(', ')}`,
);
}
let moved = 0;
for (const wagon of wagons) {
const previousYardId = wagon.currentYardId ?? null;
@@ -253,6 +345,17 @@ export class WagonsService {
throw new NotFoundException('One or more wagons not found');
}
// A coupled wagon's status is owned by the train-builder flow — refuse to
// flip status on any wagon that is currently on a built train.
const coupled = wagons.filter((w) => w.trainId != null);
if (coupled.length) {
throw new ConflictException(
`Cannot change status of wagons coupled to a built train: ${coupled
.map((w) => w.wagonNumber)
.join(', ')}. Detach them via train-builder first.`,
);
}
for (const wagon of wagons) {
wagon.status = status;
}