Merge branch 'dev' into freight/feat/fixes-v1

This commit is contained in:
Nathnael
2026-07-23 11:51:04 +00:00
217 changed files with 17041 additions and 1071 deletions

View File

@@ -12,7 +12,7 @@ import {
ensurePostgresSchemas,
APPLICATION_SEARCH_PATH,
} from "./config/ensure-postgres-schemas";
import { IamModule, DataSeeder } from "@tria-plc/iamapi-common";
import { IamModule } from "@tria-plc/iamapi-common";
import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module";
import appConfig from "./config/app.config";
@@ -77,8 +77,8 @@ import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-l
import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder";
//New Trains, Wagons, Container and Cargo management modules
import { TrainsModule } from "./modules/trains/trains.module";
import { VerifaydaModule } from './modules/verifayda/verifayda.module';
import { FleetHistoryModule } from './modules/fleet-history/fleet-history.module';
import { VerifaydaModule } from "./modules/verifayda/verifayda.module";
import { FleetHistoryModule } from "./modules/fleet-history/fleet-history.module";
import { WagonsModule } from "./modules/wagons/wagons.module";
import { ContainersModule } from "./modules/container-management/containers.module";
import { CargoesModule } from "./modules/cargoes/cargoes.module";
@@ -104,7 +104,13 @@ import { LoggerMiddleware } from "./logger.middleware";
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig, faydaConfig],
load: [
appConfig,
databaseConfig,
telebirrConfig,
rabbitmqConfig,
faydaConfig,
],
}),
ScheduleModule.forRoot(),
EventEmitterModule.forRoot(),
@@ -223,7 +229,7 @@ import { LoggerMiddleware } from "./logger.middleware";
})
export class AppModule implements OnApplicationBootstrap {
constructor(
private readonly seeder: DataSeeder,
// private readonly seeder: DataSeeder,
private readonly edrOrgSeeder: EdrOrgSeeder,
private readonly freightPositionsSeeder: FreightPositionsSeeder,
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
@@ -258,7 +264,7 @@ export class AppModule implements OnApplicationBootstrap {
// freightPositionsSeeder → seeds Position + PositionPermission rows
// (depends on edrOrgSeeder, must run after)
await this.freightPermissionKeyMigrationSeeder.run();
await this.seeder.run();
// await this.seeder.run();
await this.edrOrgSeeder.run();
await this.freightPositionsSeeder.run();

View File

@@ -29,9 +29,22 @@ export const TrainSchedulingView = () =>
export const TrainSchedulingManage = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.manage);
export const FleetView = () => BookingStaff(FREIGHT_PERMS.fleet.view);
/**
* Fleet guards take an optional granular per-resource key (locomotives:create,
* wagons:delete, …). The legacy coarse fleet:view / fleet:manage keys remain
* valid as a one-of fallback so existing role grants keep working.
*/
export const FleetView = (granular?: string) =>
BookingStaff(
granular ? [granular, FREIGHT_PERMS.fleet.view] : FREIGHT_PERMS.fleet.view,
);
export const FleetManage = () => BookingStaff(FREIGHT_PERMS.fleet.manage);
export const FleetManage = (granular?: string) =>
BookingStaff(
granular
? [granular, FREIGHT_PERMS.fleet.manage]
: FREIGHT_PERMS.fleet.manage,
);
/** Requester creates a wagon-transfer request (count-only, no wagon picks). */
export const WagonTransferRequest = () =>

View File

@@ -0,0 +1,51 @@
import {
assertCanApproveContractStep,
canEditContractStep,
} from './freight-permission.util';
import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
// The document-edit gate (canEditContractStep) must be STRICT: only the approver
// whose turn it is may edit. This is the fix for a previous approver keeping the
// "Edit contract articles" button after acting, because the approve gate lets
// through anyone holding any contract-approve permission.
describe('canEditContractStep (strict per-step edit gate)', () => {
const director = {
employee: { position: { positionType: { key: '-marketing-director-' } } },
};
// A line staff who already approved their own step but still holds a
// contract-approve permission — the exact actor that leaked edit rights.
const officerWithApprovePerm = {
employee: {
position: {
positionType: { key: '-marketing-officer-' },
permissions: [{ key: FREIGHT_PERMS.contracts.approveLineStaff }],
},
},
};
const superAdmin = { roles: [{ key: 'super_admin' }] };
it('lets the steps own approver edit', () => {
expect(canEditContractStep(director, '-marketing-director-')).toBe(true);
});
it('lets an approval admin edit any step', () => {
expect(canEditContractStep(superAdmin, '-marketing-director-')).toBe(true);
});
it('does NOT let a different approver edit just because they hold an approve permission', () => {
expect(canEditContractStep(officerWithApprovePerm, '-marketing-director-')).toBe(
false,
);
});
it('stays intentionally stricter than the approve gate (which keeps the blanket fallback)', () => {
// The approve gate passes the officer via the any-permission blanket…
expect(() =>
assertCanApproveContractStep(officerWithApprovePerm, '-marketing-director-'),
).not.toThrow();
// …but the edit gate does not — that divergence IS the fix.
expect(canEditContractStep(officerWithApprovePerm, '-marketing-director-')).toBe(
false,
);
});
});

View File

@@ -201,6 +201,34 @@ export function assertCanApproveContractStep(
);
}
/**
* Strict "is it exactly this caller's turn?" test — mirrors the backoffice
* `canApproveContractStep`. Same passes as {@link assertCanApproveContractStep}
* EXCEPT the blanket "holds any contract-approve permission" fallback is
* dropped: a line-staff holding `approveLineStaff` must NOT read as the director
* for a director step. Used to gate contract-document editing so approval hands
* edit rights to the NEXT approver only — a previous approver who already acted
* (but still holds an approve permission) loses the edit button, as required.
*
* (Kept separate from the approve/reject gate, which keeps the blanket fallback
* so delegates whose token omits a position type can still action their step.)
*/
export function canEditContractStep(
user: TCurrentUser | MeLikeUser | null | undefined,
requiredRole: string,
): boolean {
if (isFreightApprovalAdmin(user)) return true;
const positionTypes = collectPositionTypeKeys(user);
if (positionTypes.includes(requiredRole)) return true;
const aliases = LEGACY_ROLE_POSITION_TYPES[requiredRole] ?? [];
if (aliases.some((alias) => positionTypes.includes(alias))) return true;
const legacyPermission = CONTRACT_APPROVE_ROLE_PERMISSION[requiredRole];
return Boolean(legacyPermission && hasFreightPermission(user, legacyPermission));
}
export function assertCanApproveBookingStep(
user: TCurrentUser | MeLikeUser | null | undefined,
requiredRole: string,

View File

@@ -0,0 +1,22 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Dedup stamp for the km/date-due maintenance alert — without it the daily
* cron would re-notify every day a schedule stays due.
*/
export class AddMaintenanceDueNotifiedAt2480000000000 implements MigrationInterface {
name = 'AddMaintenanceDueNotifiedAt2480000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.maintenance_schedules
ADD COLUMN IF NOT EXISTS due_notified_at timestamptz NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.maintenance_schedules DROP COLUMN IF EXISTS due_notified_at
`);
}
}

View File

@@ -0,0 +1,40 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* KM-based maintenance scheduling: per-vehicle service intervals (by km
* and/or days) driving the maintenance due engine. Raw schema-qualified SQL —
* the builder API resolved bare table names against the default schema and
* failed on boot ("Table maintenance_intervals does not exist").
*/
export class AddMaintenanceIntervals2800000000000 implements MigrationInterface {
name = 'AddMaintenanceIntervals2800000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.maintenance_intervals (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
vehicle_id uuid NOT NULL REFERENCES freight.vehicles(id) ON DELETE CASCADE,
maintenance_type varchar NOT NULL,
interval_km numeric(14,2),
interval_days integer,
description text,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_maintenance_intervals_vehicle_type"
ON freight.maintenance_intervals (vehicle_id, maintenance_type);
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_maintenance_intervals_vehicle_type"
ON freight.maintenance_intervals (vehicle_id, maintenance_type);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.maintenance_intervals;`);
}
}

View File

@@ -0,0 +1,22 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Persist the signer's saved-signature image on the handover record, so the
* signed handover document can render the actual signature (not just the
* typed name) — parity with the booking-contract signing flow.
*/
export class AddSignatureToHandover2800000000001 implements MigrationInterface {
name = 'AddSignatureToHandover2800000000001';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.booking_handovers ADD COLUMN IF NOT EXISTS signature_image_url text;`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.booking_handovers DROP COLUMN IF EXISTS signature_image_url;`,
);
}
}

View File

@@ -0,0 +1,47 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Named service items for KM-based maintenance ("oil change", "tires", …).
* The coarse maintenance_type enum (PREVENTIVE/…) allowed only one interval
* per type per vehicle, so oil and tire intervals could not coexist. Interval
* identity becomes (vehicle, maintenance_type, service_item); schedules carry
* the item so completion re-finds the right interval for auto-scheduling.
*/
export class AddMaintenanceServiceItem2810000000000 implements MigrationInterface {
name = 'AddMaintenanceServiceItem2810000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.maintenance_intervals ADD COLUMN IF NOT EXISTS service_item varchar(120);`,
);
await queryRunner.query(
`ALTER TABLE freight.maintenance_schedules ADD COLUMN IF NOT EXISTS service_item varchar(120);`,
);
// Re-key interval uniqueness on (vehicle, type, item). COALESCE folds the
// item-less legacy rows into one slot; soft-deleted rows are ignored.
await queryRunner.query(
`DROP INDEX IF EXISTS freight."UQ_maintenance_intervals_vehicle_type";`,
);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_maintenance_intervals_vehicle_type_item"
ON freight.maintenance_intervals (vehicle_id, maintenance_type, COALESCE(service_item, ''))
WHERE deleted_at IS NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS freight."UQ_maintenance_intervals_vehicle_type_item";`,
);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_maintenance_intervals_vehicle_type"
ON freight.maintenance_intervals (vehicle_id, maintenance_type);
`);
await queryRunner.query(
`ALTER TABLE freight.maintenance_schedules DROP COLUMN IF EXISTS service_item;`,
);
await queryRunner.query(
`ALTER TABLE freight.maintenance_intervals DROP COLUMN IF EXISTS service_item;`,
);
}
}

View File

@@ -0,0 +1,67 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Scope the customs clearance service fee to a direction + route.
*
* The fee was a single global flat rate; the business sells it per lane —
* "import clearance, Djibouti → Adama, 300 USD". CUSTOMS_CLEARANCE rates now
* carry trade_direction + the yard pair, and contract pricing matches on them
* strictly (no route-less fallback).
*
* Existing route-less clearance rates cannot be backfilled (no way to know
* which lane each was meant for) — retired exactly like the base-freight
* retirement in AddRateYardScope: SUPERSEDED + soft-deleted, kept for
* snapshot history.
*/
export class CustomsClearanceRouteScope2820000000000 implements MigrationInterface {
name = 'CustomsClearanceRouteScope2820000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE freight.rates
SET status = 'SUPERSEDED',
deleted_at = now(),
updated_at = now()
WHERE deleted_at IS NULL
AND rate_type = 'CUSTOMS_CLEARANCE'
AND (origin_yard_id IS NULL OR destination_yard_id IS NULL);
`);
await queryRunner.query(
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`,
);
await queryRunner.query(`
ALTER TABLE freight.rates
ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
deleted_at IS NOT NULL
OR status = 'SUPERSEDED'
OR CASE
WHEN ("trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'))
OR "trigger" = 'CUSTOMS_CLEARANCE'
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
END
);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Retired rates stay retired (their lanes were never recorded); down only
// restores the pre-customs constraint shape.
await queryRunner.query(
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`,
);
await queryRunner.query(`
ALTER TABLE freight.rates
ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
deleted_at IS NOT NULL
OR status = 'SUPERSEDED'
OR CASE
WHEN "trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY')
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
END
);
`);
}
}

View File

@@ -0,0 +1,64 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Scope the empty-container return surcharge to a direction + route +
* container type, like base freight (import-only for now — the box only goes
* back to the port on imports).
*
* Existing route-less RETURN_SURCHARGE rates cannot be backfilled — retired
* (SUPERSEDED + soft-deleted) exactly like base freight and customs clearance
* were, kept readable for snapshot history. Route-scoped replacements must be
* re-entered; a booking that asks for return with no matching rate hard-blocks.
*/
export class ReturnSurchargeRouteScope2830000000000 implements MigrationInterface {
name = 'ReturnSurchargeRouteScope2830000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE freight.rates
SET status = 'SUPERSEDED',
deleted_at = now(),
updated_at = now()
WHERE deleted_at IS NULL
AND rate_type = 'RETURN_SURCHARGE'
AND (origin_yard_id IS NULL OR destination_yard_id IS NULL);
`);
await queryRunner.query(
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`,
);
await queryRunner.query(`
ALTER TABLE freight.rates
ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
deleted_at IS NOT NULL
OR status = 'SUPERSEDED'
OR CASE
WHEN ("trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'))
OR "trigger" IN ('CUSTOMS_CLEARANCE', 'WITH_RETURN')
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
END
);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Retired rates stay retired; down only restores the customs-era shape.
await queryRunner.query(
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`,
);
await queryRunner.query(`
ALTER TABLE freight.rates
ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
deleted_at IS NOT NULL
OR status = 'SUPERSEDED'
OR CASE
WHEN ("trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'))
OR "trigger" = 'CUSTOMS_CLEARANCE'
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
END
);
`);
}
}

View File

@@ -165,8 +165,16 @@ export class BookingPricingService {
const usdAmount = mod.calculatedAmount;
const rate = rateById.get(mod.rateId);
const unit = rate?.rateUnit ?? 'FLAT';
const unitUsd = rate ? Number(rate.rateValue) : usdAmount;
// Derived/route-matched charges (import overweight, empty-container
// return) carry their own unit price + billing unit — bill and display
// those, not whatever the referenced rate row says.
const isDerived = mod.unitPriceUsd != null;
const unit = mod.billingUnit ?? rate?.rateUnit ?? 'FLAT';
const unitUsd = isDerived
? Number(mod.unitPriceUsd)
: rate
? Number(rate.rateValue)
: usdAmount;
// 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 (the live unit price — a count, not a
@@ -182,11 +190,11 @@ export class BookingPricingService {
// 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,
);
// Derived charges skip the snapshot — import overweight prices off the
// route's container freight, never a frozen OVERWEIGHT_PER_TON value.
const frozen = isDerived
? null
: this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency);
const unitAmount = frozen
? Number(frozen.unitPrice)
: isEtbBooking
@@ -369,6 +377,8 @@ export class BookingPricingService {
isGovernment: booking.isGovernment,
allowConsolidation,
shippingLineId: booking.shippingLineId,
originYardId: booking.originYardId,
destinationYardId: booking.destinationYardId,
totalWagons,
// Bulk tonnage scales PER_TON surcharges (e.g. the bulk reefer surcharge).
// Container freight carries 0 here — its surcharges scale by container count.

View File

@@ -436,18 +436,26 @@ export class BookingsController {
}
@Get(':id/customer-truck-assignment/freight-order')
@ApiOperation({ summary: 'Download duplicate freight order copies for customer truck assignment' })
@ApiOperation({
summary:
'Download freight order copies. The 2 gate copies always print; ?copies=1,2,8 adds waybill-style copies (catalog indexes 1-8).',
})
async customerTruckFreightOrder(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
@Res() res: Response,
@Query('copies') copies?: string,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
const extraCopyIndexes = (copies ?? '')
.split(',')
.map((n) => Number(n.trim()))
.filter((n) => Number.isInteger(n) && n >= 1 && n <= 8);
const { filename, buffer } =
await this.bookingsService.customerTruckFreightOrderCopies(id);
await this.bookingsService.customerTruckFreightOrderCopies(id, extraCopyIndexes);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(buffer);

View File

@@ -142,8 +142,21 @@ export class BookingsService {
return this.findById(bookingId);
}
/** Selectable freight-order copies (rail-waybill style). Indexes 1-8. */
static readonly FREIGHT_ORDER_EXTRA_COPIES = [
'Original 1 (for Issuing Carrier)',
'Original 2 (for Consignee)',
'Original 3 (for Shipper)',
'Copy 4 (Delivery Receipt)',
'Copy 5 (Extra Copy)',
'Copy 6 (Extra Copy)',
'Copy 7 (Extra Copy)',
'Copy 8 (for Agent)',
] as const;
async customerTruckFreightOrderCopies(
bookingId: string,
extraCopyIndexes: number[] = [],
): Promise<{ filename: string; buffer: Buffer }> {
const booking = await this.findById(bookingId);
if (!booking.customerTruckAssignedAt) {
@@ -171,7 +184,12 @@ export class BookingsService {
[bookingId],
);
const html = this.buildCustomerTruckFreightOrderHtml(booking, trucks);
// The 2 gate copies are ALWAYS printed; the waybill-style copies are
// whatever the customer ticked (indexes into the fixed catalog).
const extraCopies = [...new Set(extraCopyIndexes)]
.map((i) => BookingsService.FREIGHT_ORDER_EXTRA_COPIES[i - 1])
.filter(Boolean);
const html = this.buildCustomerTruckFreightOrderHtml(booking, trucks, extraCopies);
// Chromium when available; otherwise the styled tabular fallback (never the
// generic text dump — the freight order is an outward-facing gate document).
const buffer = await this.pdfRender.htmlToPdfBuffer(html, {
@@ -268,6 +286,7 @@ export class BookingsService {
arrivedAt: string | null;
containers: string | null;
}>,
extraCopies: string[] = [],
): string {
const esc = (v: unknown) => this.escapeHtml(String(v ?? '-'));
const assignedAt = booking.customerTruckAssignedAt
@@ -386,6 +405,7 @@ export class BookingsService {
<body>
${copy('Copy 1: Port Operations Copy')}
${copy('Copy 2: Gate Security & Carrier Copy')}
${extraCopies.map((label) => copy(label)).join('')}
</body>
</html>`;
}
@@ -422,6 +442,8 @@ export class BookingsService {
isReefer?: boolean;
isGovernment?: boolean;
shippingLineId?: string | null;
originYardId?: string | null;
destinationYardId?: string | null;
bulkTons?: number;
containers: CreateBookingContainerDto[];
}): Promise<BookingEvaluationInput> {
@@ -467,6 +489,8 @@ export class BookingsService {
isGovernment: dto.isGovernment ?? false,
allowConsolidation,
shippingLineId: dto.shippingLineId,
originYardId: dto.originYardId ?? null,
destinationYardId: dto.destinationYardId ?? null,
totalWagons,
bulkTons: dto.freightType === 'BULK' ? Number(dto.bulkTons ?? 0) : 0,
containers,
@@ -788,6 +812,8 @@ export class BookingsService {
isReefer: dto.isReefer,
isGovernment,
shippingLineId: dto.shippingLineId,
originYardId: dto.originYardId,
destinationYardId: dto.destinationYardId,
bulkTons: dto.cargoTotalWeightVgm,
containers,
});
@@ -998,6 +1024,8 @@ export class BookingsService {
isHazardous: dto.isHazardous ?? existing.isHazardous,
isReefer: dto.isReefer ?? existing.isReefer,
shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined,
originYardId: dto.originYardId ?? existing.originYardId,
destinationYardId: dto.destinationYardId ?? existing.destinationYardId,
bulkTons: dto.cargoTotalWeightVgm ?? Number(existing.cargoTotalWeightVgm ?? 0),
containers,
});

View File

@@ -11,6 +11,7 @@ import {
} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { FleetManage, FleetView } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { CreateCargoDto } from './dto/create-cargo.dto';
import { UpdateCargoDto } from './dto/update-cargo.dto';
import { LoadCargoDto } from './dto/load-cargo.dto';
@@ -19,12 +20,12 @@ import { CargoesService } from './cargoes.service';
@ApiTags('cargoes')
@Controller('cargoes')
@FleetView()
@FleetView(FREIGHT_PERMS.cargoes.view)
export class CargoesController {
constructor(private readonly cargoesService: CargoesService) {}
@Post()
@FleetManage()
@FleetManage(FREIGHT_PERMS.cargoes.create)
@ApiOperation({ summary: 'Create a new cargo' })
create(@Body() dto: CreateCargoDto) {
return this.cargoesService.create(dto);
@@ -43,35 +44,35 @@ export class CargoesController {
}
@Patch(':id')
@FleetManage()
@FleetManage(FREIGHT_PERMS.cargoes.update)
@ApiOperation({ summary: 'Update a cargo' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCargoDto) {
return this.cargoesService.update(id, dto);
}
@Delete(':id')
@FleetManage()
@FleetManage(FREIGHT_PERMS.cargoes.delete)
@ApiOperation({ summary: 'Delete a cargo' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.cargoesService.remove(id);
}
@Post(':id/load')
@FleetManage()
@FleetManage(FREIGHT_PERMS.cargoes.update)
@ApiOperation({ summary: 'Load cargo into a container' })
load(@Param('id', ParseUUIDPipe) id: string, @Body() dto: LoadCargoDto) {
return this.cargoesService.loadCargo(id, dto);
}
@Post(':id/unload')
@FleetManage()
@FleetManage(FREIGHT_PERMS.cargoes.update)
@ApiOperation({ summary: 'Unload cargo from container' })
unload(@Param('id', ParseUUIDPipe) id: string) {
return this.cargoesService.unloadCargo(id);
}
@Post(':id/deliver')
@FleetManage()
@FleetManage(FREIGHT_PERMS.cargoes.update)
@ApiOperation({ summary: 'Mark cargo as delivered' })
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto?: DeliverCargoDto) {
return this.cargoesService.deliverCargo(id, dto);

View File

@@ -10,18 +10,19 @@ import {
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { FleetManage, FleetView } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { ConsignmentsService } from "./consignments.service";
import { CreateConsignmentDto } from "./dto/create-consignment.dto";
import { FilterConsignmentDto } from "./dto/filter-consignment.dto";
@ApiTags("consignments")
@Controller("consignments")
@FleetView()
@FleetView(FREIGHT_PERMS.consignments.view)
export class ConsignmentsController {
constructor(private readonly consignmentsService: ConsignmentsService) {}
@Post()
@FleetManage()
@FleetManage(FREIGHT_PERMS.consignments.create)
@ApiOperation({ summary: "Create a new consignment" })
create(@Body() dto: CreateConsignmentDto) {
return this.consignmentsService.create(dto);

View File

@@ -11,6 +11,7 @@ import {
} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { FleetManage, FleetView } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { CreateContainerDto } from './dto/create-container.dto';
import { UpdateContainerDto } from './dto/update-container.dto';
import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto';
@@ -18,12 +19,12 @@ import { ContainersService } from './containers.service';
@ApiTags('containers')
@Controller('containers')
@FleetView()
@FleetView(FREIGHT_PERMS.containers.view)
export class ContainersController {
constructor(private readonly containersService: ContainersService) {}
@Post()
@FleetManage()
@FleetManage(FREIGHT_PERMS.containers.create)
@ApiOperation({ summary: 'Create a new container' })
create(@Body() dto: CreateContainerDto) {
return this.containersService.create(dto);
@@ -42,28 +43,28 @@ export class ContainersController {
}
@Patch(':id')
@FleetManage()
@FleetManage(FREIGHT_PERMS.containers.update)
@ApiOperation({ summary: 'Update a container' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContainerDto) {
return this.containersService.update(id, dto);
}
@Delete(':id')
@FleetManage()
@FleetManage(FREIGHT_PERMS.containers.delete)
@ApiOperation({ summary: 'Delete a container' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.containersService.remove(id);
}
@Post(':id/assign-wagon')
@FleetManage()
@FleetManage(FREIGHT_PERMS.containers.update)
@ApiOperation({ summary: 'Assign container to a wagon' })
assignToWagon(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignContainerToWagonDto) {
return this.containersService.assignToWagon(id, dto);
}
@Post(':id/unassign-wagon')
@FleetManage()
@FleetManage(FREIGHT_PERMS.containers.update)
@ApiOperation({ summary: 'Unassign container from wagon' })
unassignFromWagon(@Param('id', ParseUUIDPipe) id: string) {
return this.containersService.unassignFromWagon(id);

View File

@@ -289,6 +289,7 @@ export class ContractBookingService {
tradeDirection: contract.tradeDirection,
freightType,
cargoTypeId: this.resolveCargoTypeId(contract, dto),
cargoFreeText: dto.cargoFreeText?.trim() || null,
isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'),
isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'),
cargoTotalWeightVgm: this.resolveBulkTons(dto),
@@ -738,6 +739,7 @@ export class ContractBookingService {
}
await this.bookingsRepository.update(booking.id, {
cargoTypeId: this.resolveCargoTypeId(contract, dto),
cargoFreeText: dto.cargoFreeText?.trim() || null,
cargoTotalWeightVgm: this.resolveBulkTons(dto),
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
} as never);

View File

@@ -194,17 +194,49 @@ export class ContractPricingService {
contract.freightType === 'CONTAINER' &&
contract.equipmentReturn === 'WITH_RETURN'
) {
const withReturn = liveRates.find(
(r) => r.rateType === 'RETURN_SURCHARGE' && r.currency === 'USD',
);
if (withReturn && Number(withReturn.rateValue) > 0) {
lineItems.push({
code: 'RETURN_SURCHARGE',
label: 'Empty container return',
unit: toContractUnit(withReturn.rateUnit),
unitPrice: convert(Number(withReturn.rateValue)),
conditionalOn: 'with_return',
// Return is sold per direction + route + container type (import-only) —
// one display line per contract size that has a configured rate. A size
// with no rate shows nothing here and hard-blocks at booking time.
// ponytail: bookings bill the live route rate, not a frozen snapshot.
const route = [...(contract.routes ?? [])].sort(
(a, b) => a.sortOrder - b.sortOrder,
)[0];
const onLeg = route
? liveRates.filter(
(r) =>
r.rateType === 'RETURN_SURCHARGE' &&
r.currency === 'USD' &&
r.tradeDirection === contract.tradeDirection &&
r.originYardId === route.originYardId &&
r.destinationYardId === route.destinationYardId,
)
: [];
if (onLeg.length > 0) {
const sizes = (contract.cargoScope ?? [])
.map((c) => c.containerSize)
.filter((s): s is string => !!s);
const { items: containerTypes } = await this.containerTypesService.findAll({
isActive: true,
pageSize: 100,
});
for (const size of sizes) {
const sizeFt = size === '40ft' ? 40 : 20;
const matchedIds = new Set(
containerTypes.filter((ct) => ct.sizeFt === sizeFt).map((ct) => ct.id),
);
const rate =
onLeg.find((r) => r.containerTypeId && matchedIds.has(r.containerTypeId)) ??
onLeg.find((r) => !r.containerTypeId);
if (!rate || Number(rate.rateValue) <= 0) continue;
lineItems.push({
code: 'RETURN_SURCHARGE',
label: `Empty container return (${size})`,
unit: toContractUnit(rate.rateUnit),
unitPrice: convert(Number(rate.rateValue)),
containerSize: size,
conditionalOn: 'with_return',
});
}
}
}
@@ -213,12 +245,24 @@ export class ContractPricingService {
// ONE_TIME, per shipment request for GENERAL. Excluded from booking totals.
// A customs contract may not proceed without a configured live rate.
if (contract.customsClearingEnabled) {
const clearance = liveRates.find(
(r) => r.rateType === 'CUSTOMS_CLEARANCE' && r.currency === 'USD',
);
// The fee is sold per direction + route — strict, no route-less fallback.
// ponytail: multi-route contracts bill the first lane's fee; per-lane fees need per-route snapshots.
const route = [...(contract.routes ?? [])].sort(
(a, b) => a.sortOrder - b.sortOrder,
)[0];
const clearance = route
? liveRates.find(
(r) =>
r.rateType === 'CUSTOMS_CLEARANCE' &&
r.currency === 'USD' &&
r.tradeDirection === contract.tradeDirection &&
r.originYardId === route.originYardId &&
r.destinationYardId === route.destinationYardId,
)
: undefined;
if (!clearance || Number(clearance.rateValue) <= 0) {
throw new UnprocessableEntityException(
'No customs clearance service fee is configured. Ask the rates team to set a live CUSTOMS_CLEARANCE rate before submitting customs contracts.',
'No customs clearance service fee is configured for this direction and route. Ask the rates team to set a live CUSTOMS_CLEARANCE rate for this origin → destination.',
);
}
lineItems.push({

View File

@@ -18,7 +18,15 @@ import { ContractPdfService } from '../../contracts/contract-pdf.service';
import { ContractViewModel } from '../../contracts/contract-view-model.builder';
import { MinioService } from '../minio/minio.service';
import { FileRecord } from '../files/entities/file.entity';
import { assertCanApproveContractStep } from '../../common/freight-permission.util';
import {
assertCanApproveContractStep,
assertFreightPermission,
canEditContractStep,
} from '../../common/freight-permission.util';
import {
FREIGHT_PERMS,
forFreightType,
} from '../../seed/freight-permissions.registry';
import { ContractDocumentHistoryService } from './contract-document-history.service';
import { ApprovalRulesService } from '../rule-engine/services/approval-rules.service';
import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
@@ -236,8 +244,15 @@ export class ContractTransitionService {
actorId: string,
validityDays: number,
documentSnapshot?: ContractDocumentSnapshotInput | null,
user?: TCurrentUser | null,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
// The route guard passes on either arm; the contract's freight type decides
// which one is actually required (accept bulk ≠ accept container).
assertFreightPermission(
user,
forFreightType(FREIGHT_PERMS.contracts.staffAccept, contract.freightType),
);
assertContractStatus(contract, ['SUBMITTED']);
if (!Number.isInteger(validityDays) || validityDays < 1) {
@@ -431,12 +446,11 @@ export class ContractTransitionService {
if (!next) return false;
if (!user) return false;
try {
assertCanApproveContractStep(user, next.requiredRole);
return true;
} catch {
return false;
}
// Strict match: ONLY the approver whose turn it is (the next pending step's
// role) may edit. Using the looser approve gate here let any approver who
// held a contract-approve permission keep the edit button after acting —
// approval must hand edit rights to the next approver, not share them.
return canEditContractStep(user, next.requiredRole);
}
/** The role that currently holds editing rights, for UI messaging. */
@@ -533,8 +547,13 @@ export class ContractTransitionService {
contractId: string,
note: string,
actorId: string,
user?: TCurrentUser | null,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
assertFreightPermission(
user,
forFreightType(FREIGHT_PERMS.contracts.requestChanges, contract.freightType),
);
assertContractStatus(contract, ['SUBMITTED']);
await this.contractsRepository.createReviewNote(
@@ -552,8 +571,17 @@ export class ContractTransitionService {
return updated;
}
async reject(contractId: string, reason: string, actorId: string): Promise<Contract> {
async reject(
contractId: string,
reason: string,
actorId: string,
user?: TCurrentUser | null,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
assertFreightPermission(
user,
forFreightType(FREIGHT_PERMS.contracts.reject, contract.freightType),
);
assertContractStatus(contract, ['SUBMITTED', 'PENDING_APPROVAL']);
await this.contractsRepository.createReviewNote(

View File

@@ -34,7 +34,11 @@ import {
import { actorLabel } from '../warehouses/current-actor.util';
import { BookingStaff } from '../../common/booking-guards';
import { ContractDocumentHistoryService } from './contract-document-history.service';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import {
FREIGHT_PERMS,
bothFreightTypes,
forFreightType,
} from '../../seed/freight-permissions.registry';
import {
assertFreightPermission,
hasFreightPermission,
@@ -184,7 +188,10 @@ export class ContractsController {
@CurrentUser() user: TCurrentUser,
) {
if (dto.isGovernment) {
assertFreightPermission(user, FREIGHT_PERMS.contracts.staffAccept);
assertFreightPermission(
user,
forFreightType(FREIGHT_PERMS.contracts.staffAccept, dto.freightType),
);
}
return this.contractsService.create(dto, files ?? [], user?.id);
}
@@ -337,23 +344,25 @@ export class ContractsController {
}
@Post(':id/staff/accept')
@BookingStaff(FREIGHT_PERMS.contracts.staffAccept)
// One-of guard; the service then requires the arm matching the contract's freight type.
@BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept))
@ApiOperation({ summary: 'Staff accept → set validity window + start approval chain' })
staffAccept(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AcceptContractDto,
@CurrentUser() user: AuthUserPayload,
@CurrentUser() user: TCurrentUser,
) {
return this.transitionService.staffAccept(
id,
resolveAuthUserId(user),
dto.validityDays,
dto.documentSnapshot,
user,
);
}
@Get(':id/document/draft')
@BookingStaff(FREIGHT_PERMS.contracts.staffAccept)
@BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept))
@ApiOperation({
summary:
'Editable contract-document draft (this contract\'s snapshot, or the live template) for the accept/edit dialog',
@@ -377,7 +386,7 @@ export class ContractsController {
}
@Put(':id/document/articles')
@BookingStaff(FREIGHT_PERMS.contracts.staffAccept)
@BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept))
@ApiOperation({
summary:
'Edit this contract\'s document articles only (per-contract; never touches the six shared templates)',
@@ -396,29 +405,35 @@ export class ContractsController {
}
@Post(':id/staff/request-changes')
@BookingStaff(FREIGHT_PERMS.contracts.requestChanges)
@BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.requestChanges))
@ApiOperation({ summary: 'Staff return contract for customer updates' })
requestChanges(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RequestChangesDto,
@CurrentUser() user: AuthUserPayload,
@CurrentUser() user: TCurrentUser,
) {
return this.transitionService.requestChanges(
id,
dto.note,
resolveAuthUserId(user),
user,
);
}
@Post(':id/staff/reject')
@BookingStaff(FREIGHT_PERMS.contracts.reject)
@BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.reject))
@ApiOperation({ summary: 'Staff reject contract' })
reject(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RejectContractDto,
@CurrentUser() user: AuthUserPayload,
@CurrentUser() user: TCurrentUser,
) {
return this.transitionService.reject(id, dto.reason, resolveAuthUserId(user));
return this.transitionService.reject(
id,
dto.reason,
resolveAuthUserId(user),
user,
);
}
@Post(':id/approval-steps/:stepId/approve')

View File

@@ -182,6 +182,13 @@ export class CreateBookingUnderContractDto {
@Type(() => CreateBulkLineDto)
bulkLines?: CreateBulkLineDto[];
@ApiPropertyOptional({
description: 'What the containers carry — captured per booking (container freight).',
})
@IsOptional()
@IsString()
cargoFreeText?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()

View File

@@ -1,7 +1,8 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { FleetManage, FleetView } from '../../common/booking-guards';
import { FleetManage, StaffReference } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { CreateLocomotiveDto } from './dto/create-locomotive.dto';
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
import { UpdateLocomotiveDto } from './dto/update-locomotive.dto';
@@ -9,39 +10,43 @@ import { LocomotivesService } from './locomotives.service';
@ApiTags('locomotives')
@ApiBearerAuth()
// No class-level guard: reads are login-only reference data (any staff can
// fetch a locomotive for a cross-flow view without the fleet:view that drives
// the Fleet sidebar). Every mutation carries its own @FleetManage().
@Controller('locomotives')
@FleetView()
export class LocomotivesController {
constructor(private readonly locomotivesService: LocomotivesService) {}
@Get()
@StaffReference()
@ApiOperation({ summary: 'List locomotives' })
findAll(@Query() filter: FilterLocomotivesDto) {
return this.locomotivesService.findAll(filter);
}
@Get(':id')
@StaffReference()
@ApiOperation({ summary: 'Get a locomotive by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.locomotivesService.findById(id);
}
@Post()
@FleetManage()
@FleetManage(FREIGHT_PERMS.locomotives.create)
@ApiOperation({ summary: 'Create a locomotive' })
create(@Body() dto: CreateLocomotiveDto) {
return this.locomotivesService.create(dto);
}
@Patch(':id')
@FleetManage()
@FleetManage(FREIGHT_PERMS.locomotives.update)
@ApiOperation({ summary: 'Update a locomotive' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLocomotiveDto) {
return this.locomotivesService.update(id, dto);
}
@Post(':id/decommission')
@FleetManage()
@FleetManage(FREIGHT_PERMS.locomotives.delete)
@ApiOperation({ summary: 'Decommission a locomotive' })
decommission(@Param('id', ParseUUIDPipe) id: string) {
return this.locomotivesService.decommission(id);

View File

@@ -8,6 +8,11 @@ export class CreateMaintenanceScheduleDto {
@IsEnum(MaintenanceType)
maintenanceType!: MaintenanceType;
/** What is serviced — matched against the interval for auto-scheduling. */
@IsOptional()
@IsString()
serviceItem?: string;
@IsString()
description!: string;
@@ -81,7 +86,37 @@ export class UpdateMaintenanceScheduleDto {
@IsNumber()
actualCost?: number;
/** Odometer at completion — drives KM-based auto-scheduling of the next service. */
@IsOptional()
@IsNumber()
odometerReading?: number;
@IsOptional()
@IsString()
notes?: string;
}
export class UpsertMaintenanceIntervalDto {
@IsUUID()
vehicleId!: string;
@IsEnum(MaintenanceType)
maintenanceType!: MaintenanceType;
/** What is serviced — "oil change", "tires", … Distinguishes intervals of the same type. */
@IsOptional()
@IsString()
serviceItem?: string;
@IsOptional()
@IsNumber()
intervalKm?: number;
@IsOptional()
@IsNumber()
intervalDays?: number;
@IsOptional()
@IsString()
description?: string;
}

View File

@@ -0,0 +1,46 @@
import { BaseEntity } from '@edr/api-common';
import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
import { MaintenanceType } from './maintenance-schedule.entity';
/**
* Maintenance interval configuration. Defines how often a vehicle needs a
* given service. Identity is (vehicle, maintenanceType, serviceItem) — a
* vehicle carries several intervals of the same coarse type with different
* items (oil every 10k km, tires every 50k km, both PREVENTIVE). Uniqueness
* is enforced by a COALESCE expression index in the migration (nullable
* service_item), not a TypeORM @Unique.
*/
@Entity({ name: 'maintenance_intervals', schema: 'freight' })
@Index(['vehicleId', 'maintenanceType'])
export class MaintenanceInterval extends BaseEntity {
@Column({ name: 'vehicle_id', type: 'uuid' })
vehicleId!: string;
@ManyToOne(() => Vehicle, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'vehicle_id' })
vehicle!: Vehicle;
@Column({ name: 'maintenance_type', type: 'varchar' })
maintenanceType!: MaintenanceType;
/** What is serviced — "oil change", "tires", … Null = generic for the type. */
@Column({ name: 'service_item', type: 'varchar', length: 120, nullable: true })
serviceItem?: string | null;
/** Maintenance interval in kilometers. E.g., 10000 for oil changes every 10k km. */
@Column({ name: 'interval_km', type: 'numeric', precision: 14, scale: 2, nullable: true })
intervalKm?: number | null;
/** Maintenance interval in days. E.g., 365 for annual inspection. */
@Column({ name: 'interval_days', type: 'integer', nullable: true })
intervalDays?: number | null;
/** Human-readable description. E.g., "Oil and filter change". */
@Column({ name: 'description', type: 'text', nullable: true })
description?: string | null;
/** Is this interval active? Can be disabled without deleting historical data. */
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
}

View File

@@ -30,6 +30,10 @@ export class MaintenanceSchedule extends BaseEntity {
@Column({ name: 'maintenance_type', type: 'varchar' })
maintenanceType!: MaintenanceType;
/** What is serviced — matches the interval's service_item for auto-scheduling. */
@Column({ name: 'service_item', type: 'varchar', length: 120, nullable: true })
serviceItem?: string | null;
@Column({ name: 'description' })
description!: string;
@@ -62,4 +66,8 @@ export class MaintenanceSchedule extends BaseEntity {
@Column({ name: 'next_due_date', type: 'timestamptz', nullable: true })
nextDueDate?: Date;
/** Stamped once the km/date-due alert has fired, so the daily check doesn't repeat it. */
@Column({ name: 'due_notified_at', type: 'timestamptz', nullable: true })
dueNotifiedAt?: Date;
}

View File

@@ -0,0 +1,99 @@
import { MaintenanceService } from './maintenance.service';
import { MaintenanceStatus } from './entities/maintenance-schedule.entity';
/**
* KM-based auto-scheduling: completing a maintenance with an odometer reading
* creates the next SCHEDULED item at completedKm + intervalKm, matched on the
* schedule's (type, serviceItem) interval. Re-completing must not duplicate.
*/
function makeService(opts: {
before: Record<string, unknown> | null;
after: Record<string, unknown> | null;
interval: Record<string, unknown> | null;
}) {
const saved: Array<Record<string, unknown>> = [];
const service = Object.create(MaintenanceService.prototype) as Record<string, unknown>;
service.scheduleRepository = {
findOneBy: jest
.fn()
.mockResolvedValueOnce(opts.before)
.mockResolvedValueOnce(opts.after),
update: jest.fn(),
create: jest.fn((v: Record<string, unknown>) => v),
save: jest.fn(async (v: Record<string, unknown>) => {
saved.push(v);
return v;
}),
};
service.intervalRepository = {
getByVehicleAndType: jest.fn().mockResolvedValue(opts.interval),
};
service.dataSource = {
getRepository: jest.fn().mockReturnValue({ update: jest.fn() }),
};
service.logger = { error: jest.fn() };
return { service: service as unknown as MaintenanceService, saved };
}
const base = {
id: 's-1',
vehicleId: 'v-1',
maintenanceType: 'PREVENTIVE',
serviceItem: 'oil change',
description: 'Oil and filter',
};
describe('MaintenanceService auto-next scheduling', () => {
it('completing at 50,000 km with a 10,000 km interval schedules the next at 60,000', async () => {
const { service, saved } = makeService({
before: { ...base, status: MaintenanceStatus.SCHEDULED },
after: { ...base, status: MaintenanceStatus.COMPLETED, odometerReading: 50000 },
interval: { serviceItem: 'oil change', intervalKm: '10000.00', intervalDays: null, description: 'Oil and filter' },
});
await service.updateMaintenanceSchedule('s-1', {
status: MaintenanceStatus.COMPLETED,
odometerReading: 50000,
});
expect(saved).toHaveLength(1);
expect(saved[0]).toMatchObject({
vehicleId: 'v-1',
serviceItem: 'oil change',
nextDueKm: 60000,
status: MaintenanceStatus.SCHEDULED,
});
});
it('re-completing an already COMPLETED schedule does not duplicate the next one', async () => {
const { service, saved } = makeService({
before: { ...base, status: MaintenanceStatus.COMPLETED, odometerReading: 50000 },
after: { ...base, status: MaintenanceStatus.COMPLETED, odometerReading: 50000 },
interval: { serviceItem: 'oil change', intervalKm: '10000.00', intervalDays: null },
});
await service.updateMaintenanceSchedule('s-1', {
status: MaintenanceStatus.COMPLETED,
odometerReading: 50000,
});
expect(saved).toHaveLength(0);
});
it('a km + days interval produces ONE next schedule carrying both thresholds', async () => {
const { service, saved } = makeService({
before: { ...base, status: MaintenanceStatus.IN_PROGRESS },
after: { ...base, status: MaintenanceStatus.COMPLETED, odometerReading: 20000 },
interval: { serviceItem: 'oil change', intervalKm: '10000.00', intervalDays: 180 },
});
await service.updateMaintenanceSchedule('s-1', {
status: MaintenanceStatus.COMPLETED,
odometerReading: 20000,
});
expect(saved).toHaveLength(1);
expect(saved[0].nextDueKm).toBe(30000);
expect(saved[0].nextDueDate).toBeInstanceOf(Date);
});
});

View File

@@ -0,0 +1,76 @@
import { NotificationAudience } from '@edr/types';
import { MaintenanceService } from './maintenance.service';
/**
* The daily due-alert: a SCHEDULED item that crossed its km or date threshold
* gets one BACKOFFICE notification, then is stamped so it isn't repeated.
*/
function makeService(due: Array<Record<string, unknown>>) {
const update = jest.fn();
const notify = jest.fn();
const service = Object.create(MaintenanceService.prototype) as Record<string, unknown>;
service.maintenanceRepository = { getUnnotifiedDue: jest.fn().mockResolvedValue(due) };
service.scheduleRepository = { update };
service.inbox = { notify };
service.logger = { error: jest.fn() };
return { service: service as unknown as MaintenanceService, update, notify };
}
describe('MaintenanceService.sendDueAlerts', () => {
it('reports the km reason when the km threshold was crossed', async () => {
const { service, notify, update } = makeService([
{
id: 'sched-1',
vehicleId: 'v-1',
plateNumber: 'ET-9875',
maintenanceType: 'PREVENTIVE',
description: 'Oil change',
nextDueKm: 50000,
nextDueDate: null,
currentKm: 50200,
},
]);
await service.sendDueAlerts();
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({
audience: NotificationAudience.BACKOFFICE,
title: 'Maintenance due — ET-9875',
body: expect.stringContaining('driven 50200 km (due at 50000 km)'),
}),
);
expect(update).toHaveBeenCalledWith('sched-1', { dueNotifiedAt: expect.any(Date) });
});
it('reports the date reason when only the due date has passed', async () => {
const { service, notify } = makeService([
{
id: 'sched-2',
vehicleId: 'v-2',
plateNumber: 'AA-8642',
maintenanceType: 'INSPECTION',
description: 'Annual inspection',
nextDueKm: null,
nextDueDate: new Date('2026-01-01'),
currentKm: 1000,
},
]);
await service.sendDueAlerts();
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({ body: expect.stringContaining('due 1/1/2026') }),
);
});
it('does nothing when nothing is due', async () => {
const { service, notify, update } = makeService([]);
await service.sendDueAlerts();
expect(notify).not.toHaveBeenCalled();
expect(update).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,83 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { BaseRepository } from '@edr/api-common';
import { IsNull, Repository } from 'typeorm';
import { MaintenanceInterval } from './entities/maintenance-interval.entity';
import { MaintenanceType } from './entities/maintenance-schedule.entity';
@Injectable()
export class MaintenanceIntervalRepository extends BaseRepository<MaintenanceInterval> {
constructor(
@InjectRepository(MaintenanceInterval)
private readonly intervalRepository: Repository<MaintenanceInterval>,
) {
super(intervalRepository);
}
/**
* Resolve the interval for a completed service. Prefers the exact
* (type, serviceItem) match; a completion without an item falls back to the
* type's item-less interval only, so "oil" completions never consume the
* "tires" interval.
*/
async getByVehicleAndType(
vehicleId: string,
maintenanceType: MaintenanceType,
serviceItem?: string | null,
): Promise<MaintenanceInterval | null> {
return this.intervalRepository.findOne({
where: {
vehicleId,
maintenanceType,
isActive: true,
serviceItem: serviceItem?.trim() ? serviceItem.trim() : IsNull(),
},
});
}
async getActiveIntervals(vehicleId: string): Promise<MaintenanceInterval[]> {
return this.intervalRepository.find({
where: { vehicleId, isActive: true },
order: { maintenanceType: 'ASC', serviceItem: 'ASC' },
});
}
async upsertInterval(
vehicleId: string,
maintenanceType: MaintenanceType,
serviceItem?: string | null,
intervalKm?: number | null,
intervalDays?: number | null,
description?: string | null,
): Promise<MaintenanceInterval> {
const item = serviceItem?.trim() || null;
const existing = await this.getByVehicleAndType(vehicleId, maintenanceType, item);
if (existing) {
await this.intervalRepository.update(existing.id, {
intervalKm: intervalKm ?? existing.intervalKm,
intervalDays: intervalDays ?? existing.intervalDays,
description: description ?? existing.description,
});
const updated = await this.intervalRepository.findOneBy({ id: existing.id });
return updated!;
}
return this.intervalRepository.save(
this.intervalRepository.create({
vehicleId,
maintenanceType,
serviceItem: item,
intervalKm,
intervalDays,
description,
isActive: true,
}),
);
}
/** Soft-disable: history keeps pointing at it, auto-scheduling stops. */
async deactivate(id: string): Promise<void> {
await this.intervalRepository.update(id, { isActive: false });
}
}

View File

@@ -4,7 +4,12 @@ import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { MaintenanceService } from './maintenance.service';
import { MaintenanceDepthService } from './maintenance-depth.service';
import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto';
import {
CreateMaintenanceScheduleDto,
CreateMaintenanceCostDto,
UpdateMaintenanceScheduleDto,
UpsertMaintenanceIntervalDto,
} from './dto/create-maintenance.dto';
import {
CreateWorkOrderDto,
UpdateWorkOrderDto,
@@ -44,6 +49,34 @@ export class MaintenanceController {
return this.maintenanceService.updateMaintenanceSchedule(id, dto);
}
@Get('due-board')
@BookingStaff([FREIGHT_PERMS.maintenance.view, FREIGHT_PERMS.fleetDashboard.view])
@ApiOperation({ summary: 'Fleet-wide next-due maintenance board (by date and km)' })
async getDueBoard() {
return this.maintenanceService.getDueBoard();
}
@Post('intervals')
@BookingStaff(FREIGHT_PERMS.maintenance.create)
@ApiOperation({ summary: 'Define/adjust a service interval (e.g. oil change every 10,000 km)' })
async upsertInterval(@Body() dto: UpsertMaintenanceIntervalDto) {
return this.maintenanceService.upsertInterval(dto);
}
@Get('intervals/:vehicleId')
@BookingStaff(FREIGHT_PERMS.maintenance.view)
@ApiOperation({ summary: "A vehicle's active service intervals" })
async getIntervals(@Param('vehicleId') vehicleId: string) {
return this.maintenanceService.getIntervals(vehicleId);
}
@Delete('intervals/:id')
@BookingStaff(FREIGHT_PERMS.maintenance.delete)
@ApiOperation({ summary: 'Deactivate a service interval (stops auto-scheduling)' })
async deactivateInterval(@Param('id') id: string) {
return this.maintenanceService.deactivateInterval(id);
}
@Get('upcoming/:vehicleId')
@BookingStaff(FREIGHT_PERMS.maintenance.view)
@ApiOperation({ summary: 'Get upcoming maintenance' })

View File

@@ -2,25 +2,37 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MaintenanceSchedule } from './entities/maintenance-schedule.entity';
import { MaintenanceCost } from './entities/maintenance-cost.entity';
import { MaintenanceInterval } from './entities/maintenance-interval.entity';
import { WorkOrder } from './entities/work-order.entity';
import { Part } from './entities/part.entity';
import { Warranty } from './entities/warranty.entity';
import { MaintenanceService } from './maintenance.service';
import { MaintenanceDepthService } from './maintenance-depth.service';
import { MaintenanceRepository } from './maintenance.repository';
import { MaintenanceIntervalRepository } from './maintenance-interval.repository';
import { WorkOrderRepository } from './work-order.repository';
import { PartRepository } from './part.repository';
import { WarrantyRepository } from './warranty.repository';
import { MaintenanceController } from './maintenance.controller';
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
@Module({
imports: [
TypeOrmModule.forFeature([MaintenanceSchedule, MaintenanceCost, WorkOrder, Part, Warranty]),
TypeOrmModule.forFeature([
MaintenanceSchedule,
MaintenanceCost,
MaintenanceInterval,
WorkOrder,
Part,
Warranty,
]),
NotificationInboxModule,
],
providers: [
MaintenanceService,
MaintenanceDepthService,
MaintenanceRepository,
MaintenanceIntervalRepository,
WorkOrderRepository,
PartRepository,
WarrantyRepository,

View File

@@ -47,4 +47,109 @@ export class MaintenanceRepository extends BaseRepository<MaintenanceSchedule> {
.getRawOne();
return result?.total || 0;
}
/**
* Fleet-wide "next due" board: one row per vehicle with a SCHEDULED
* maintenance item, driven by time AND km — whichever is soonest. Current km
* is the vehicle's latest fuel-up odometer reading (how mileage is actually
* captured today), falling back to vehicle.actual_distance_km when the
* vehicle has no fuel purchase on file yet.
*/
async getDueBoard(): Promise<
Array<{
scheduleId: string;
vehicleId: string;
plateNumber: string;
maintenanceType: string;
serviceItem: string | null;
description: string;
scheduledDate: Date;
nextDueDate: Date | null;
nextDueKm: number | null;
currentKm: number | null;
kmRemaining: number | null;
daysRemaining: number | null;
overdue: boolean;
}>
> {
// Every SCHEDULED item, not one per vehicle — a truck legitimately holds
// several (oil vs tires intervals differ).
return this.scheduleRepository.manager.query(`
SELECT
s.id AS "scheduleId",
s.vehicle_id AS "vehicleId",
v.plate_number AS "plateNumber",
s.maintenance_type AS "maintenanceType",
s.service_item AS "serviceItem",
s.description,
s.scheduled_date AS "scheduledDate",
s.next_due_date AS "nextDueDate",
s.next_due_km AS "nextDueKm",
COALESCE(fp.max_odometer, v.actual_distance_km) AS "currentKm",
CASE WHEN s.next_due_km IS NOT NULL
THEN s.next_due_km - COALESCE(fp.max_odometer, v.actual_distance_km, 0)
ELSE NULL END AS "kmRemaining",
CASE WHEN s.next_due_date IS NOT NULL
THEN EXTRACT(DAY FROM s.next_due_date - now())
ELSE NULL END AS "daysRemaining",
(
(s.next_due_date IS NOT NULL AND s.next_due_date <= now())
OR (s.next_due_km IS NOT NULL
AND COALESCE(fp.max_odometer, v.actual_distance_km, 0) >= s.next_due_km)
) AS overdue
FROM freight.maintenance_schedules s
JOIN freight.vehicles v ON v.id = s.vehicle_id AND v.deleted_at IS NULL
LEFT JOIN LATERAL (
SELECT MAX(odometer_reading) AS max_odometer
FROM freight.fuel_purchases fp2
WHERE fp2.vehicle_id = s.vehicle_id
) fp ON true
WHERE s.status = 'SCHEDULED' AND s.deleted_at IS NULL
ORDER BY s.vehicle_id, s.scheduled_date ASC
`);
}
/**
* SCHEDULED items that have crossed their km or date due-point and have not
* yet been notified. Backs the daily km/date maintenance alert.
*/
async getUnnotifiedDue(): Promise<
Array<{
id: string;
vehicleId: string;
plateNumber: string;
maintenanceType: string;
description: string;
nextDueKm: number | null;
nextDueDate: Date | null;
currentKm: number | null;
}>
> {
return this.scheduleRepository.manager.query(`
SELECT
s.id,
s.vehicle_id AS "vehicleId",
v.plate_number AS "plateNumber",
s.maintenance_type AS "maintenanceType",
s.description,
s.next_due_km AS "nextDueKm",
s.next_due_date AS "nextDueDate",
COALESCE(fp.max_odometer, v.actual_distance_km) AS "currentKm"
FROM freight.maintenance_schedules s
JOIN freight.vehicles v ON v.id = s.vehicle_id AND v.deleted_at IS NULL
LEFT JOIN LATERAL (
SELECT MAX(odometer_reading) AS max_odometer
FROM freight.fuel_purchases fp2
WHERE fp2.vehicle_id = s.vehicle_id
) fp ON true
WHERE s.status = 'SCHEDULED'
AND s.deleted_at IS NULL
AND s.due_notified_at IS NULL
AND (
(s.next_due_date IS NOT NULL AND s.next_due_date <= now())
OR (s.next_due_km IS NOT NULL
AND COALESCE(fp.max_odometer, v.actual_distance_km, 0) >= s.next_due_km)
)
`);
}
}

View File

@@ -1,16 +1,28 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Cron, CronExpression } from '@nestjs/schedule';
import { NotificationAudience, NotificationType } from '@edr/types';
import { DataSource, Repository } from 'typeorm';
import { MaintenanceRepository } from './maintenance.repository';
import { MaintenanceSchedule, MaintenanceStatus } from './entities/maintenance-schedule.entity';
import { MaintenanceIntervalRepository } from './maintenance-interval.repository';
import { MaintenanceSchedule, MaintenanceStatus, MaintenanceType } 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';
import {
CreateMaintenanceScheduleDto,
CreateMaintenanceCostDto,
UpdateMaintenanceScheduleDto,
UpsertMaintenanceIntervalDto,
} from './dto/create-maintenance.dto';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
@Injectable()
export class MaintenanceService {
private readonly logger = new Logger(MaintenanceService.name);
constructor(
private readonly maintenanceRepository: MaintenanceRepository,
private readonly intervalRepository: MaintenanceIntervalRepository,
@InjectRepository(MaintenanceSchedule)
private readonly scheduleRepository: Repository<MaintenanceSchedule>,
@InjectRepository(MaintenanceCost)
@@ -18,8 +30,44 @@ export class MaintenanceService {
// 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,
private readonly inbox: NotificationInboxService,
) {}
/** Fleet-wide next-due board — see MaintenanceRepository.getDueBoard. */
async getDueBoard() {
return this.maintenanceRepository.getDueBoard();
}
/**
* Daily check: a vehicle's driven km (latest fuel-up odometer reading, since
* that's the only place mileage is actually recorded) or its due date has
* reached a SCHEDULED item's threshold → alert backoffice once.
*/
@Cron(CronExpression.EVERY_DAY_AT_7AM, { name: 'maintenance-due-alert' })
async sendDueAlerts(): Promise<void> {
try {
const due = await this.maintenanceRepository.getUnnotifiedDue();
for (const item of due) {
const reason =
item.nextDueKm != null && (item.currentKm ?? 0) >= item.nextDueKm
? `driven ${item.currentKm} km (due at ${item.nextDueKm} km)`
: `due ${new Date(item.nextDueDate as Date).toLocaleDateString()}`;
await this.inbox.notify({
recipients: { allBackoffice: true },
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.GENERIC,
title: `Maintenance due — ${item.plateNumber}`,
body: `${item.plateNumber} (${item.maintenanceType}) is due for maintenance — ${reason}. ${item.description}`,
link: `/dashboard/maintenance?vehicleId=${item.vehicleId}`,
data: { vehicleId: item.vehicleId, scheduleId: item.id, action: 'MAINTENANCE_DUE' },
});
await this.scheduleRepository.update(item.id, { dueNotifiedAt: new Date() });
}
} catch (err) {
this.logger.error(`sendDueAlerts failed: ${(err as Error).message}`, (err as Error).stack);
}
}
/**
* Reflect a maintenance schedule's lifecycle on the target vehicle. A vehicle
* under maintenance is taken out of service (MAINTENANCE + BUSY); once the
@@ -64,6 +112,10 @@ export class MaintenanceService {
id: string,
dto: UpdateMaintenanceScheduleDto,
): Promise<MaintenanceSchedule> {
// Status BEFORE the write: completing an already-COMPLETED schedule again
// must not auto-create a second "next" schedule.
const before = await this.scheduleRepository.findOneBy({ id });
await this.scheduleRepository.update(id, {
...dto,
completedDate: dto.completedDate ? new Date(dto.completedDate) : undefined,
@@ -78,6 +130,15 @@ export class MaintenanceService {
) {
// Maintenance finished/aborted → vehicle back in service.
await this.setVehicleMaintenanceState(updated.vehicleId, false);
// First transition into COMPLETED with an odometer → auto-schedule next.
if (
dto.status === MaintenanceStatus.COMPLETED &&
before?.status !== MaintenanceStatus.COMPLETED &&
updated.odometerReading != null
) {
await this.scheduleNextMaintenance(updated);
}
} else if (dto.status === MaintenanceStatus.IN_PROGRESS) {
// Maintenance started → keep the vehicle out of service.
await this.setVehicleMaintenanceState(updated.vehicleId, true);
@@ -87,6 +148,83 @@ export class MaintenanceService {
return updated!;
}
/** Define/adjust how often a vehicle needs a service ("oil change every 10,000 km"). */
async upsertInterval(dto: UpsertMaintenanceIntervalDto) {
return this.intervalRepository.upsertInterval(
dto.vehicleId,
dto.maintenanceType,
dto.serviceItem ?? null,
dto.intervalKm ?? null,
dto.intervalDays ?? null,
dto.description ?? null,
);
}
async getIntervals(vehicleId: string) {
return this.intervalRepository.getActiveIntervals(vehicleId);
}
async deactivateInterval(id: string): Promise<{ id: string; deactivated: boolean }> {
await this.intervalRepository.deactivate(id);
return { id, deactivated: true };
}
/**
* Auto-schedule the next service after a completion: matched on the
* completed schedule's (type, serviceItem) interval; one SCHEDULED row
* carrying BOTH thresholds when the interval defines km and days —
* whichever is crossed first makes it due.
*/
private async scheduleNextMaintenance(completed: MaintenanceSchedule): Promise<void> {
try {
const interval = await this.intervalRepository.getByVehicleAndType(
completed.vehicleId,
completed.maintenanceType as MaintenanceType,
completed.serviceItem,
);
if (!interval) return; // No interval defined, skip auto-scheduling
const now = new Date();
const completedKm = Number(completed.odometerReading ?? 0);
const intervalKm = Number(interval.intervalKm ?? 0);
const intervalDays = Number(interval.intervalDays ?? 0);
if (intervalKm <= 0 && intervalDays <= 0) return;
const nextDueKm = intervalKm > 0 ? completedKm + intervalKm : undefined;
const nextDueDate =
intervalDays > 0
? new Date(now.getTime() + intervalDays * 24 * 60 * 60 * 1000)
: undefined;
const label = interval.serviceItem ? `${interval.serviceItem}: ` : '';
const due = [
nextDueKm != null ? `${nextDueKm} km` : null,
nextDueDate != null ? nextDueDate.toISOString().slice(0, 10) : null,
]
.filter(Boolean)
.join(' / ');
await this.scheduleRepository.save(
this.scheduleRepository.create({
vehicleId: completed.vehicleId,
maintenanceType: completed.maintenanceType,
serviceItem: completed.serviceItem ?? interval.serviceItem ?? null,
description: `${label}${interval.description || completed.description} (next due: ${due})`,
scheduledDate: now,
nextDueKm,
nextDueDate,
status: MaintenanceStatus.SCHEDULED,
}),
);
} catch (err) {
this.logger.error(
`Failed to schedule next maintenance for vehicle ${completed.vehicleId}: ${(err as Error).message}`,
(err as Error).stack,
);
}
}
async getUpcomingMaintenance(vehicleId: string) {
return this.maintenanceRepository.getUpcomingMaintenance(vehicleId);
}

View File

@@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { FleetManage, FleetView } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { CreateRouteDto } from './dto/create-route.dto';
import { FilterRoutesDto } from './dto/filter-routes.dto';
import { UpdateRouteDto } from './dto/update-route.dto';
@@ -10,7 +11,7 @@ import { RoutesService } from './routes.service';
@ApiTags('routes')
@ApiBearerAuth()
@Controller('routes')
@FleetView()
@FleetView(FREIGHT_PERMS.routes.view)
export class RoutesController {
constructor(private readonly routesService: RoutesService) {}
@@ -27,21 +28,21 @@ export class RoutesController {
}
@Post()
@FleetManage()
@FleetManage(FREIGHT_PERMS.routes.create)
@ApiOperation({ summary: 'Create route' })
create(@Body() dto: CreateRouteDto) {
return this.routesService.create(dto);
}
@Patch(':id')
@FleetManage()
@FleetManage(FREIGHT_PERMS.routes.update)
@ApiOperation({ summary: 'Update route' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateRouteDto) {
return this.routesService.update(id, dto);
}
@Delete(':id')
@FleetManage()
@FleetManage(FREIGHT_PERMS.routes.delete)
@ApiOperation({ summary: 'Deactivate route' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.routesService.deactivate(id);

View File

@@ -125,6 +125,22 @@ export class ListRatesQueryDto extends PaginationQueryDto {
@IsString()
@MaxLength(50)
rateType?: string;
@ApiPropertyOptional({
description: 'Filter by rate category — comma-separated appliesTo values (e.g. "CONTAINER" or "FIRST_MILE,LAST_MILE").',
})
@IsOptional()
@IsString()
@MaxLength(100)
appliesTo?: string;
@ApiPropertyOptional({
description: 'Filter by surcharge trigger — comma-separated trigger values (e.g. "CUSTOMS_CLEARANCE" or "HAZARDOUS,REEFER").',
})
@IsOptional()
@IsString()
@MaxLength(200)
trigger?: string;
}
export class ListWeightLimitRulesQueryDto extends PaginationQueryDto {

View File

@@ -117,6 +117,21 @@ export class RatesRepository implements IRatesRepository {
if (query.rateType) {
qb.andWhere('rate.rateType = :rateType', { rateType: query.rateType });
}
// Category tabs on the admin page: comma-separated appliesTo / trigger
// lists, ANDed together (e.g. appliesTo=OTHER + trigger=CUSTOMS_CLEARANCE).
const csv = (v?: string) =>
(v ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const appliesTo = csv(query.appliesTo);
if (appliesTo.length > 0) {
qb.andWhere('rate.appliesTo IN (:...appliesTo)', { appliesTo });
}
const triggers = csv(query.trigger);
if (triggers.length > 0) {
qb.andWhere('rate.trigger IN (:...triggers)', { triggers });
}
if (query.search) {
qb.andWhere(
'(rate.rateType ILIKE :search OR rate.status ILIKE :search OR rate.rateUnit ILIKE :search OR rate.currency ILIKE :search)',

View File

@@ -76,3 +76,191 @@ describe('RuleEngineService — requested service without a configured surcharge
expect(result.hardBlocked[0]).toContain('reefer');
});
});
describe('RuleEngineService — overweight surcharge by trade direction', () => {
const baseImportRate: Rate = {
id: 'rate-import-20',
rateType: 'CONTAINER_IMPORT',
trigger: 'ALWAYS',
rateValue: 1000,
rateUnit: 'PER_CONTAINER',
currency: 'USD',
status: 'LIVE',
containerTypeId: 'ct-20',
cargoTypeId: null,
originYardId: 'yard-dj',
destinationYardId: 'yard-adama',
} as Rate;
const configuredOverweight: Rate = {
id: 'rate-ow',
rateType: 'OVERWEIGHT_PER_TON',
trigger: 'OVERWEIGHT',
rateValue: 10,
rateUnit: 'PER_TON',
currency: 'USD',
status: 'LIVE',
containerTypeId: null,
cargoTypeId: null,
} as Rate;
let service: RuleEngineService;
beforeEach(() => {
service = new RuleEngineService(
{ findById: jest.fn().mockResolvedValue(null) } as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
{
findActiveByContainerTypeId: jest
.fn()
.mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons: null }]),
} as never,
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
{
findLiveRates: jest.fn().mockResolvedValue([baseImportRate, configuredOverweight]),
} as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
{} as never,
);
});
// One 20ft at 25 t against a 20 t limit → 5 t excess.
const overweightInput = (tradeDirection: string): BookingEvaluationInput => ({
serviceTypeId: 'svc-1',
paymentCurrency: 'USD',
tradeDirection,
isHazardous: false,
totalWagons: 1,
originYardId: 'yard-dj',
destinationYardId: 'yard-adama',
containers: [
{ containerTypeId: 'ct-20', quantity: 1, vgmPerUnitTons: 25, totalVgmTons: 25 },
],
});
it('IMPORT derives the per-ton price from base freight ÷ (2 × limit), not the configured rate', async () => {
const result = await service.evaluate(overweightInput('IMPORT'));
const ow = result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON');
expect(ow).toHaveLength(1);
// 1000 / (2 × 20) = 25 USD/t on 5 excess tons.
expect(ow[0].unitPriceUsd).toBe(25);
expect(ow[0].calculatedAmount).toBe(125);
expect(ow[0].triggerValue).toBe(5);
expect(ow[0].rateId).toBe(baseImportRate.id);
});
it('EXPORT keeps billing the configured OVERWEIGHT rate', async () => {
const result = await service.evaluate(overweightInput('EXPORT'));
const ow = result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON');
expect(ow).toHaveLength(1);
expect(ow[0].rateId).toBe(configuredOverweight.id);
// 5 excess tons × the configured 10 USD/t.
expect(ow[0].calculatedAmount).toBe(50);
expect(ow[0].unitPriceUsd).toBeUndefined();
});
it('IMPORT without a route-matching base rate bills no overweight (base freight blocks anyway)', async () => {
const result = await service.evaluate({
...overweightInput('IMPORT'),
destinationYardId: 'yard-elsewhere',
});
const ow = result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON');
expect(ow).toHaveLength(0);
});
});
describe('RuleEngineService — empty-container return per route + container type', () => {
const returnRate20: Rate = {
id: 'rate-return-20',
rateType: 'RETURN_SURCHARGE',
trigger: 'WITH_RETURN',
rateValue: 20,
rateUnit: 'PER_CONTAINER',
currency: 'USD',
status: 'LIVE',
containerTypeId: 'ct-20',
cargoTypeId: null,
tradeDirection: 'IMPORT',
originYardId: 'yard-dj',
destinationYardId: 'yard-adama',
} as Rate;
let service: RuleEngineService;
beforeEach(() => {
service = new RuleEngineService(
{ findById: jest.fn().mockResolvedValue(null) } as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
{
findActiveByContainerTypeId: jest
.fn()
.mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons: null }]),
} as never,
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
{ findLiveRates: jest.fn().mockResolvedValue([returnRate20]) } as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
{} as never,
);
});
const returnInput = (overrides: Partial<BookingEvaluationInput>): BookingEvaluationInput => ({
serviceTypeId: 'svc-1',
paymentCurrency: 'USD',
tradeDirection: 'IMPORT',
isHazardous: false,
totalWagons: 1,
originYardId: 'yard-dj',
destinationYardId: 'yard-adama',
containers: [
{
containerTypeId: 'ct-20',
quantity: 4,
vgmPerUnitTons: 10,
totalVgmTons: 40,
returnQuantity: 2,
},
],
...overrides,
});
it('bills the route + type matched rate on the opted-in count', async () => {
const result = await service.evaluate(returnInput({}));
const ret = result.appliedModifiers.filter((m) => m.surchargeCode === 'RETURN_SURCHARGE');
expect(result.hardBlocked).toHaveLength(0);
expect(ret).toHaveLength(1);
expect(ret[0].rateId).toBe(returnRate20.id);
expect(ret[0].triggerValue).toBe(2);
expect(ret[0].calculatedAmount).toBe(40);
expect(ret[0].billingUnit).toBe('PER_CONTAINER');
});
it('hard-blocks when the booking route has no matching return rate', async () => {
const result = await service.evaluate(
returnInput({ destinationYardId: 'yard-elsewhere' }),
);
expect(result.hardBlocked.some((m) => m.includes('return'))).toBe(true);
expect(
result.appliedModifiers.filter((m) => m.surchargeCode === 'RETURN_SURCHARGE'),
).toHaveLength(0);
});
it('hard-blocks an EXPORT booking asking for return (rates are import-only)', async () => {
const result = await service.evaluate(returnInput({ tradeDirection: 'EXPORT' }));
expect(result.hardBlocked.some((m) => m.includes('return'))).toBe(true);
});
it('legacy booking-level flag bills every container at its type rate', async () => {
const result = await service.evaluate(
returnInput({
withReturn: true,
containers: [
{ containerTypeId: 'ct-20', quantity: 4, vgmPerUnitTons: 10, totalVgmTons: 40 },
],
}),
);
const ret = result.appliedModifiers.filter((m) => m.surchargeCode === 'RETURN_SURCHARGE');
expect(ret).toHaveLength(1);
expect(ret[0].triggerValue).toBe(4);
expect(ret[0].calculatedAmount).toBe(80);
});
});

View File

@@ -67,6 +67,12 @@ export interface BookingEvaluationInput {
isGovernment?: boolean;
allowConsolidation?: boolean;
shippingLineId?: string | null;
/**
* The booking's rail leg. Import overweight derives its per-ton price from
* this route's own container freight rate, so the engine needs the yards.
*/
originYardId?: string | null;
destinationYardId?: string | null;
/**
* Booking's cargo type needs EDR-provided lashing/securing (cargoType
* hasLashing = true). Fires the flat LASHING surcharge. Resolved by the
@@ -91,6 +97,15 @@ export interface AppliedCargoModifier {
triggerValue: number | null;
calculatedAmount: number;
currency: string;
/**
* Effective per-unit USD price when it differs from the rate row's own value
* — set by derived charges (import overweight: base freight ÷ 2×limit) so
* the breakdown shows the real per-ton figure, not the base container price.
* Any modifier carrying it also bypasses frozen contract snapshots.
*/
unitPriceUsd?: number | null;
/** Display unit for a unitPriceUsd modifier (e.g. PER_TON for overweight). */
billingUnit?: string;
}
export interface ContainerWeightResult {
@@ -165,12 +180,16 @@ export class RuleEngineService {
...(await this.capacityViolations(input.containers, input.tradeDirection)),
);
// Per-container-line weight limit (maxVgmTons), index-aligned with
// containerWeightResults — the derived import overweight divides by it.
const lineMaxVgmTons: Array<number | null> = [];
for (const container of input.containers) {
const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId(
container.containerTypeId,
input.tradeDirection,
);
const rule = rules[0];
lineMaxVgmTons.push(rule ? Number(rule.maxVgmTons) : null);
let isOverweight = container.isOverweight ?? false;
let excess = container.overweightExcessTons ?? null;
@@ -273,13 +292,6 @@ export class RuleEngineService {
input.containers.some((c) => Number(c.reeferQuantity ?? 0) > 0),
label: 'refrigerated (reefer) cargo',
},
{
trigger: 'WITH_RETURN',
wanted:
truthy(input.withReturn) ||
input.containers.some((c) => Number(c.returnQuantity ?? 0) > 0),
label: 'empty-container return',
},
];
for (const svc of requestedServices) {
if (svc.wanted && !surchargeRates.some((r) => r.trigger === svc.trigger)) {
@@ -292,6 +304,14 @@ export class RuleEngineService {
}
for (const rate of surchargeRates) {
// Import overweight never bills the configured rate — its per-ton price
// derives from the route's base container freight (see below).
if (rate.trigger === 'OVERWEIGHT' && input.tradeDirection === 'IMPORT') {
continue;
}
// Empty-container return is sold per route + container type — billed by
// the route-matched block below, never by this route-agnostic loop.
if (rate.trigger === 'WITH_RETURN') continue;
const triggered = this.matchesTrigger(rate.trigger, {
isHazardous: input.isHazardous,
hasReefer,
@@ -384,6 +404,21 @@ export class RuleEngineService {
});
}
if (input.tradeDirection === 'IMPORT') {
appliedModifiers.push(
...this.derivedImportOverweight(
input,
containerWeightResults,
lineMaxVgmTons,
liveRates,
),
);
}
const withReturn = this.withReturnCharges(input, liveRates);
appliedModifiers.push(...withReturn.modifiers);
hardBlocked.push(...withReturn.blocked);
return {
priorityScore,
appliedModifiers,
@@ -394,6 +429,132 @@ export class RuleEngineService {
};
}
/**
* Import overweight — derived, never configured. Each overweight container
* line bills its excess tons at (its own base import freight on the booking's
* route) ÷ (2 × its weight limit): 20ft at 1000 USD with a 20 t limit →
* 25 USD per excess ton. Export keeps the configured OVERWEIGHT rate.
* Note: derives from the LIVE route rate even for frozen-rate contract
* bookings — the frozen snapshot has no route-scoped container price to
* divide.
*/
private derivedImportOverweight(
input: BookingEvaluationInput,
weightResults: ContainerWeightResult[],
lineMaxVgmTons: Array<number | null>,
liveRates: Rate[],
): AppliedCargoModifier[] {
const modifiers: AppliedCargoModifier[] = [];
if (!input.originYardId || !input.destinationYardId) return modifiers;
for (let i = 0; i < weightResults.length; i++) {
const wr = weightResults[i];
const excess = Number(wr?.overweightExcessTons ?? 0);
const maxVgm = Number(lineMaxVgmTons[i] ?? 0);
if (!wr?.isOverweight || !(excess > 0) || !(maxVgm > 0)) continue;
// Same precedence as base freight pricing: the rate scoped to this
// container type wins over the route's catch-all rate.
const onLeg = liveRates.filter(
(r) =>
r.rateType === 'CONTAINER_IMPORT' &&
r.currency === 'USD' &&
r.originYardId === input.originYardId &&
r.destinationYardId === input.destinationYardId,
);
const base =
onLeg.find((r) => r.containerTypeId === wr.containerTypeId) ??
onLeg.find((r) => !r.containerTypeId);
// No base rate → the base-freight line hard-blocks this booking anyway.
if (!base) continue;
const perTon = Number(base.rateValue) / (2 * maxVgm);
const amount = excess * perTon;
if (!(amount > 0)) continue;
modifiers.push({
rateId: base.id,
surchargeCode: 'OVERWEIGHT_PER_TON',
triggerValue: excess,
calculatedAmount: amount,
currency: base.currency,
unitPriceUsd: perTon,
billingUnit: 'PER_TON',
});
}
return modifiers;
}
/**
* Empty-container return — sold per direction + route + container type, like
* base freight. Each container line that opted in (returnQuantity, or every
* container when only the legacy booking-level flag is set) bills the
* route-matched WITH_RETURN rate for its own container type; a line with no
* matching rate hard-blocks the booking instead of shipping the service for
* free. Rates are import-only for now, so an export booking that asks for
* return blocks too.
* ponytail: bills the LIVE route rate, not a frozen contract snapshot — one
* RETURN_SURCHARGE snapshot code can't hold per-size route prices.
*/
private withReturnCharges(
input: BookingEvaluationInput,
liveRates: Rate[],
): { modifiers: AppliedCargoModifier[]; blocked: string[] } {
const modifiers: AppliedCargoModifier[] = [];
const blocked: string[] = [];
const bookingLevel = truthy(input.withReturn);
const wanted =
bookingLevel || input.containers.some((c) => Number(c.returnQuantity ?? 0) > 0);
if (!wanted) return { modifiers, blocked };
const onLeg = liveRates.filter(
(r) =>
r.trigger === 'WITH_RETURN' &&
r.currency === 'USD' &&
r.tradeDirection === input.tradeDirection &&
r.originYardId === input.originYardId &&
r.destinationYardId === input.destinationYardId,
);
for (const container of input.containers) {
const qty =
Number(container.returnQuantity ?? 0) > 0
? Number(container.returnQuantity)
: bookingLevel
? Number(container.quantity || 0)
: 0;
if (!(qty > 0)) continue;
const rate =
onLeg.find((r) => r.containerTypeId === container.containerTypeId) ??
onLeg.find((r) => !r.containerTypeId);
if (!rate) {
blocked.push(
'No empty-container return rate is configured for this container ' +
'type on this route (return is import-only) — remove the return ' +
'option or ask EDR to configure its rate for this origin → destination.',
);
continue;
}
const rateValue = Number(rate.rateValue);
const amount = rate.rateUnit === 'FLAT' ? rateValue : qty * rateValue;
if (!(amount > 0)) continue;
modifiers.push({
rateId: rate.id,
surchargeCode: this.surchargeCode(rate),
triggerValue: qty,
calculatedAmount: amount,
currency: rate.currency,
unitPriceUsd: rateValue,
billingUnit: rate.rateUnit,
});
}
// Same block deduplicated — several lines missing the rate is one problem.
return { modifiers, blocked: [...new Set(blocked)] };
}
/**
* Messages for container lines whose total weight exceeds the hard capacity
* ceiling (weight_limit_rules.max_capacity_tons). Non-empty ⇒ the booking

View File

@@ -93,6 +93,19 @@ export class RatesService {
return trigger === 'ALWAYS' && BASE_FREIGHT_CATEGORIES.includes(appliesTo);
}
/**
* Rates sold per direction + route. Base freight always; customs clearance
* and empty-container return are the surcharges that are too — their fee
* depends on the lane (and, for returns, the container type).
*/
private isRouteScoped(appliesTo: Rate['appliesTo'], trigger: Rate['trigger']): boolean {
return (
this.isBaseFreight(appliesTo, trigger) ||
trigger === 'CUSTOMS_CLEARANCE' ||
trigger === 'WITH_RETURN'
);
}
/**
* Which country each end of the leg must sit in, given what the rate is for.
* The railway only sells three shapes: import lands at the Djibouti ports and
@@ -126,7 +139,7 @@ export class RatesService {
destinationYardId?: string | null;
}): Promise<YardScope> {
const { appliesTo, trigger, tradeDirection } = input;
if (!this.isBaseFreight(appliesTo, trigger)) {
if (!this.isRouteScoped(appliesTo, trigger)) {
return { originYardId: null, destinationYardId: null };
}
@@ -134,7 +147,7 @@ export class RatesService {
const destinationYardId = input.destinationYardId ?? null;
if (!originYardId || !destinationYardId) {
throw new BadRequestException(
'Base freight rates are priced per leg — pick both an origin and a destination yard.',
'This rate is priced per leg — pick both an origin and a destination yard.',
);
}
if (originYardId === destinationYardId) {
@@ -179,6 +192,25 @@ export class RatesService {
}): void {
const { appliesTo, trigger, tradeDirection, intercityKind } = input;
const { containerTypeId, cargoTypeId } = input;
if (trigger === 'CUSTOMS_CLEARANCE') {
if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') {
throw new BadRequestException(
'A customs clearance rate must say whether it covers IMPORT or EXPORT.',
);
}
return;
}
if (trigger === 'WITH_RETURN') {
// Returning the empty box only exists on imports (the box goes back to
// the port) — export return rates are rejected until the business sells
// that.
if (tradeDirection !== 'IMPORT') {
throw new BadRequestException(
'An empty container return rate is import-only for now.',
);
}
return;
}
if (!this.isBaseFreight(appliesTo, trigger)) return;
if (appliesTo === 'INTERCITY') {
@@ -247,13 +279,24 @@ export class RatesService {
const trigger = dto.trigger as Rate['trigger'];
// Surcharges (trigger ≠ ALWAYS) carry no direction/scope — clear them so
// the engine never accidentally narrows a surcharge by container/direction.
// Exceptions: customs clearance keeps a direction, and empty-container
// return keeps direction + container type — both are sold per lane.
const isSurcharge = trigger !== 'ALWAYS';
const containerTypeId = isSurcharge ? null : (dto.containerTypeId ?? null);
const containerTypeId =
trigger === 'WITH_RETURN'
? (dto.containerTypeId ?? null)
: isSurcharge
? null
: (dto.containerTypeId ?? null);
const cargoTypeId = isSurcharge ? null : (dto.cargoTypeId ?? null);
// Intercity never leaves Ethiopia, so it has no trade direction to store —
// its yard pair already says where it runs.
const tradeDirection =
isSurcharge || appliesTo === 'INTERCITY' ? null : (dto.tradeDirection ?? null);
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN'
? (dto.tradeDirection ?? null)
: isSurcharge || appliesTo === 'INTERCITY'
? null
: (dto.tradeDirection ?? null);
const intercityKind = dto.intercityKind ?? null;
this.assertScopeCoherent({
@@ -376,7 +419,8 @@ export class RatesService {
if (dto.appliesTo) updates.appliesTo = appliesTo;
if (dto.trigger) updates.trigger = trigger;
const containerTypeId = isSurcharge
const keepsContainerType = !isSurcharge || trigger === 'WITH_RETURN';
const containerTypeId = !keepsContainerType
? null
: dto.containerTypeId !== undefined
? dto.containerTypeId
@@ -387,11 +431,15 @@ export class RatesService {
? dto.cargoTypeId
: existing.cargoTypeId;
const tradeDirection =
isSurcharge || appliesTo === 'INTERCITY'
? null
: dto.tradeDirection !== undefined
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN'
? dto.tradeDirection !== undefined
? dto.tradeDirection
: existing.tradeDirection;
: existing.tradeDirection
: isSurcharge || appliesTo === 'INTERCITY'
? null
: dto.tradeDirection !== undefined
? dto.tradeDirection
: existing.tradeDirection;
updates.containerTypeId = containerTypeId ?? null;
updates.cargoTypeId = cargoTypeId ?? null;

View File

@@ -50,8 +50,11 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
company: true,
originYard: true,
destinationYard: true,
bookingContainers: { containerType: true },
cargoType: true,
// wagonTypes feed grossBookingWeightTons the REAL tare of the
// wagon type the booking rides — without them it falls back to
// default tares and the workspace gross drifts from the validator.
bookingContainers: { containerType: { wagonTypes: true } },
cargoType: { wagonTypes: true },
},
},
},

View File

@@ -1187,6 +1187,7 @@ describe('BookingBatchService — built-train wagon capacity', () => {
reserved: Booking[];
maxWagons?: number;
routeStops?: string[];
yardCountries?: Record<string, string>;
}) => {
const schedule = {
id: scheduleId,
@@ -1218,10 +1219,21 @@ describe('BookingBatchService — built-train wagon capacity', () => {
find: jest.fn().mockResolvedValue([]),
update: jest.fn().mockResolvedValue(undefined),
};
const yardRepo = {
find: jest
.fn()
.mockResolvedValue(
Object.entries(opts.yardCountries ?? {}).map(([id, country]) => ({
id,
country,
})),
),
};
const dataSource = {
getRepository: jest.fn((entity: { name?: string }) => {
if (entity?.name === 'Wagon') return wagonRepo;
if (entity?.name === 'RouteMilestone') return milestoneRepo;
if (entity?.name === 'Yard') return yardRepo;
return genericRepo;
}),
transaction: jest.fn(),
@@ -1264,11 +1276,11 @@ describe('BookingBatchService — built-train wagon capacity', () => {
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false);
});
it('is FULL when sub-leg bookings hold every physical wagon of a milestone route', async () => {
// Regression: 50 wagons sold Negad→Mojo on a Doraleh→…→Dire Dawa corridor
// left the pass-through edges reading "free" in the per-edge budget, so the
// full train's window cycled OPEN forever and the day pool never expired.
// A wagon is committed for the whole trip — leg-free edges are not capacity.
it('is NOT full when only a middle leg is sold and other edges run free (domestic route)', async () => {
// Leg-aware allocation (planWagonsWithStock legs) made mid-leg wagons real
// capacity on the edges they don't ride: a domestic corridor with cargo
// only on m1→m2 still boards bookings on the free first/last edges, so the
// window must stay open for them.
const { service } = buildService({
physicalWagons: 2,
routeStops: ['yard-a', 'yard-m1', 'yard-m2', 'yard-b'],
@@ -1277,9 +1289,48 @@ describe('BookingBatchService — built-train wagon capacity', () => {
reservedBooking('b2', { origin: 'yard-m1', dest: 'yard-m2' }),
],
});
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false);
});
it('is FULL for the trade direction once the border edge is sold out, even with home legs free', async () => {
// Export b→c holds every wagon of the border crossing: no further export
// can board anywhere (they all must ride that edge), so the window closes —
// while intercity keeps booking the free a→b leg through the per-leg budget.
const { service } = buildService({
physicalWagons: 2,
routeStops: ['yard-a', 'yard-b', 'yard-dj'],
yardCountries: {
'yard-a': 'ETHIOPIA',
'yard-b': 'ETHIOPIA',
'yard-dj': 'DJIBOUTI',
},
reserved: [
reservedBooking('b1', { origin: 'yard-b', dest: 'yard-dj' }),
reservedBooking('b2', { origin: 'yard-b', dest: 'yard-dj' }),
],
});
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(true);
});
it('is NOT full while the border edge still has room, even with a home leg sold out', async () => {
// Intercity rode a→b on both wagons; the border edge b→dj is still free,
// so exports can still board — the window stays open.
const { service } = buildService({
physicalWagons: 2,
routeStops: ['yard-a', 'yard-b', 'yard-dj'],
yardCountries: {
'yard-a': 'ETHIOPIA',
'yard-b': 'ETHIOPIA',
'yard-dj': 'DJIBOUTI',
},
reserved: [
reservedBooking('b1', { origin: 'yard-a', dest: 'yard-b' }),
reservedBooking('b2', { origin: 'yard-a', dest: 'yard-b' }),
],
});
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false);
});
it('reports over-allocation when the consist is trimmed below committed bookings', async () => {
const { service } = buildService({
physicalWagons: 1,

View File

@@ -24,9 +24,9 @@ import {
import { Booking } from '../bookings/entities/booking.entity';
import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingPricingService } from '../bookings/booking-pricing.service';
import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { formatRouteLabel } from '../routes/entities/route.entity';
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
import { Yard } from '../rule-engine/entities/yard.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
@@ -60,11 +60,14 @@ import {
DEFAULT_WAGONS_PER_BOOKING,
} from "./booking-batch.constants";
import {
LocomotiveLimits,
WagonTypeDimensions,
bookingCargoTons,
bookingGrossWeightTons,
deriveTrainCapacityFromLocomotive,
sizePartialOfferWagons,
trainHardCaps,
trainSetLocomotiveLimits,
wagonTypeDimensionsFromEntity,
} from './train-capacity.util';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
@@ -677,7 +680,7 @@ export class BookingBatchService implements OnModuleInit {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
candidate.id,
);
const locomotive = schedule?.trainSet?.locomotive;
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
if (!schedule || !locomotive) continue;
const limits = await this.capacityLimits(locomotive);
const budget = await this.remainingBudget(schedule, limits, wagonDims);
@@ -825,7 +828,7 @@ export class BookingBatchService implements OnModuleInit {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
candidate.id,
);
const locomotive = schedule?.trainSet?.locomotive;
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
if (!schedule || !locomotive) continue;
const limits = await this.capacityLimits(locomotive);
const budget = await this.remainingBudget(schedule, limits, wagonDims);
@@ -895,7 +898,7 @@ export class BookingBatchService implements OnModuleInit {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
candidate.id,
);
const locomotive = schedule?.trainSet?.locomotive;
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
if (!schedule || !locomotive) continue;
const limits = await this.capacityLimits(locomotive);
const budget = await this.remainingBudget(schedule, limits, wagonDims);
@@ -937,7 +940,7 @@ export class BookingBatchService implements OnModuleInit {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
target.scheduleId,
);
const locomotive = schedule?.trainSet?.locomotive;
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
if (!schedule || !locomotive) return false;
const wagonDims = await this.loadWagonDims();
const limits = await this.capacityLimits(locomotive);
@@ -1034,7 +1037,7 @@ export class BookingBatchService implements OnModuleInit {
}
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
const locomotive = schedule?.trainSet?.locomotive;
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
if (!schedule || !locomotive) {
throw new ConflictException(
"Export train is no longer available for reservation",
@@ -1351,7 +1354,7 @@ export class BookingBatchService implements OnModuleInit {
};
});
const loco = s.trainSet?.locomotive ?? null;
const loco = trainSetLocomotiveLimits(s.trainSet);
// The board renders ONE booking window — the schedule's own frozen window
// (windowOpensAt/windowClosesAt + phase deadlines returned below). Bookings
@@ -1411,10 +1414,12 @@ export class BookingBatchService implements OnModuleInit {
trainName: s.trainSet.train.trainName ?? null,
}
: null,
// Identity from the primary (legacy) locomotive; limit figures from the
// whole set's effective minimum — what the fill engine actually spends.
locomotive: loco
? {
code: loco.code,
name: loco.name ?? null,
code: s.trainSet?.locomotive?.code ?? '',
name: s.trainSet?.locomotive?.name ?? null,
maxPullWeightTons: Number(loco.maxPullWeightTons),
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
}
@@ -1464,7 +1469,7 @@ export class BookingBatchService implements OnModuleInit {
weightTons: number;
lengthMeters: number;
}>,
loco: Locomotive | null,
loco: LocomotiveLimits | null,
maxWagons: number | null,
): BatchBoardSchedule["capacity"] {
const allocated = items.filter((i) => i.state === "ALLOCATED");
@@ -1499,7 +1504,7 @@ export class BookingBatchService implements OnModuleInit {
s: TrainSchedule,
items: BatchBoardBooking[],
): BatchBoardSchedule {
const loco = s.trainSet?.locomotive ?? null;
const loco = trainSetLocomotiveLimits(s.trainSet);
return {
scheduleId: s.id,
@@ -1531,10 +1536,12 @@ export class BookingBatchService implements OnModuleInit {
trainName: s.trainSet.train.trainName ?? null,
}
: null,
// Identity from the primary (legacy) locomotive; limit figures from the
// whole set's effective minimum — what the fill engine actually spends.
locomotive: loco
? {
code: loco.code,
name: loco.name ?? null,
code: s.trainSet?.locomotive?.code ?? '',
name: s.trainSet?.locomotive?.name ?? null,
maxPullWeightTons: Number(loco.maxPullWeightTons),
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
}
@@ -1600,7 +1607,7 @@ export class BookingBatchService implements OnModuleInit {
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule || !this.isFillable(schedule)) return 0;
const locomotive = schedule.trainSet?.locomotive;
const locomotive = trainSetLocomotiveLimits(schedule.trainSet);
if (!schedule.trainSetId || !locomotive) {
this.logger.warn(
`Schedule ${scheduleId} has no locomotive/train set — skipped.`,
@@ -1827,7 +1834,7 @@ export class BookingBatchService implements OnModuleInit {
for (const id of scheduleIds) {
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(id);
const locomotive = schedule?.trainSet?.locomotive;
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
if (!schedule || !schedule.trainSetId || !locomotive) {
this.logger.warn(
`Schedule ${id} has no locomotive/train set — skipped.`,
@@ -2469,7 +2476,7 @@ export class BookingBatchService implements OnModuleInit {
} | null> {
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
const locomotive = schedule?.trainSet?.locomotive;
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
if (!schedule || !locomotive) return null;
const wagonDims = await this.loadWagonDims();
const limits = await this.capacityLimits(locomotive);
@@ -3114,8 +3121,7 @@ export class BookingBatchService implements OnModuleInit {
const containers = (b: Booking): number =>
(b.bookingContainers ?? []).reduce((sum, c) => sum + Number(c.quantity ?? 0), 0);
const totalContainers = containers(primary) + containers(partner);
const cargoTons =
Number(primary.cargoTotalWeightVgm ?? 0) + Number(partner.cargoTotalWeightVgm ?? 0);
const cargoTons = bookingCargoTons(primary) + bookingCargoTons(partner);
// Consolidation shares TEU slots, never rated payload: the pair still needs
// enough wagons to carry its combined cargo, so the weight axis bounds the
@@ -3222,7 +3228,7 @@ export class BookingBatchService implements OnModuleInit {
const byLength = containerWagonsForLines(booking.bookingContainers ?? []);
const capacityTons = this.dimsFor(booking, wagonDims).capacityTons;
const cargoTons = Number(booking.cargoTotalWeightVgm ?? 0);
const cargoTons = bookingCargoTons(booking);
const byWeight =
cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0;
@@ -3243,7 +3249,7 @@ export class BookingBatchService implements OnModuleInit {
return {
wagons,
weightTons: bookingGrossWeightTons(
Number(booking.cargoTotalWeightVgm ?? 0),
bookingCargoTons(booking),
wagons,
dims.tareWeightTons,
),
@@ -3270,7 +3276,7 @@ export class BookingBatchService implements OnModuleInit {
* caps deliberately do not apply here (a mis-set global row once capped
* every train at 14m and no export booking could board).
*/
private async capacityLimits(locomotive: Locomotive): Promise<TrainLimits> {
private async capacityLimits(locomotive: LocomotiveLimits): Promise<TrainLimits> {
const wagonTypes = await this.loadWagonTypeDimensions();
const derived = deriveTrainCapacityFromLocomotive(
{
@@ -3304,7 +3310,7 @@ export class BookingBatchService implements OnModuleInit {
*/
private async syncScheduleMaxWagons(
schedule: TrainSchedule,
locomotive: Locomotive,
locomotive: LocomotiveLimits,
): Promise<void> {
const physicalWagons = await this.builtTrainWagonCount(schedule);
const maxWagons =
@@ -3605,14 +3611,14 @@ export class BookingBatchService implements OnModuleInit {
}
/**
* Built train: FULL when every physical wagon slot is taken — the consist is
* the capacity, weight/length were settled at build time.
* No built train: FULL on ANY capacity axis — out of wagon slots, or out of
* pull weight / train length for even one more loaded wagon. The old
* slot-only check let a weight-bound train (PW2: weight binds at 37 wagons =
* 3522.4T of 3500+90T, slots bind at 44) cycle its booking window forever
* instead of finalizing — 7 phantom slots kept it "not full" while nothing
* could board.
* FULL is DIRECTIONAL: the schedule's trade direction is full when the
* border-crossing edge (which every export/import must ride) can't take one
* more minimal wagon on any axis — slots for built trains (the consist is
* the capacity, weight/length settled at build), all three axes otherwise
* (PW2: weight binds at 37 wagons = 3522.4T of 3500+90T, slots bind at 44).
* Home-side legs may still run empty; intercity ride-alongs keep filling
* them via the per-leg budget and never consult this flag. Domestic routes
* (no border) are full only when every edge is closed.
*/
async isScheduleFull(scheduleId: string): Promise<boolean> {
const schedule =
@@ -3663,48 +3669,69 @@ export class BookingBatchService implements OnModuleInit {
/** See {@link isScheduleFull} — same check for callers that already hold the full graph. */
private async isTrainFull(schedule: TrainSchedule): Promise<boolean> {
// Built train: the physical consist is the only capacity axis, and a wagon
// is committed to its booking for the WHOLE trip — wagon allocation has no
// leg concept, so a wagon hauling Negad→Mojo cargo can never be re-sold for
// the Doraleh→Negad edge it merely passes through. Count commitments
// train-wide, not per corridor edge: the per-edge budget read "free slots"
// on pass-through legs of a sold-out consist, so the window of a full train
// cycled OPEN forever instead of concluding DONE (and the day pool's
// leftover bookings were never expired).
const physicalWagons = await this.builtTrainWagonCount(schedule);
if (physicalWagons != null) {
return (await this.committedWagons(schedule)) >= physicalWagons;
}
if ((await this.remainingWagons(schedule)) <= 0) return true;
const locomotive = schedule.trainSet?.locomotive;
if (!locomotive) return false; // no weight/length limits to bind against
// "Full" means full FOR THE TRAIN'S TRADE DIRECTION. Every export and
// every import must cross the ET↔DJ border edge, so once that edge can't
// take one more minimal wagon the booking window may close — even while
// home-side legs still run empty. Intercity ride-alongs never consult this
// flag; they keep booking the free legs through the per-leg budget.
// A single-country (domestic) corridor has no mandatory edge, so it is
// full only when EVERY edge is closed on some axis.
const wagonDims = await this.loadWagonDims();
const limits = await this.capacityLimits(locomotive);
const physicalWagons = await this.builtTrainWagonCount(schedule);
let limits: TrainLimits;
if (physicalWagons != null) {
// The consist is the capacity; weight/length were settled at build time.
// remainingBudget swaps in the physical wagon count per edge itself.
limits = {
base: {
wagons: physicalWagons,
weightTons: Number.POSITIVE_INFINITY,
lengthMeters: Number.POSITIVE_INFINITY,
},
tolerance: { weightTons: 0, lengthMeters: 0 },
};
} else {
const locomotive = trainSetLocomotiveLimits(schedule.trainSet);
// No loco, no built train: only the slot axis exists to bind against.
if (!locomotive) return (await this.remainingWagons(schedule)) <= 0;
limits = await this.capacityLimits(locomotive);
}
const budget = await this.remainingBudget(schedule, limits, wagonDims);
return budget.isExhausted(this.minPerWagonNeed(wagonDims));
const minNeed = this.minPerWagonNeed(wagonDims);
const border = await this.borderLeg(budget.stops);
if (border) {
return !budget.fits(
{
wagons: 1,
weightTons: minNeed.grossWeightTons,
lengthMeters: minNeed.lengthMeters,
},
border,
);
}
return budget.isExhausted(minNeed);
}
/**
* Wagons the schedule's allocated + reserved bookings occupy train-wide,
* regardless of which corridor leg each rides. Deduped by booking id — a
* booking mid-settle can momentarily be both linked and reserved.
* The corridor's single border-crossing edge (last home-country stop → first
* far-country stop), or null when every stop is in one country. This is the
* edge every EXPORT and IMPORT booking must ride, whichever sub-corridor it
* books — which makes it the train's directional fullness gauge.
*/
private async committedWagons(schedule: TrainSchedule): Promise<number> {
const wagonDims = await this.loadWagonDims();
const allocated = (schedule.scheduleBookings ?? [])
.map((sb) => sb.booking)
.filter((b): b is Booking => Boolean(b));
const reserved = await this.bookingsRepository.findReservedForSchedule(
schedule.id,
);
const byId = new Map(
[...allocated, ...reserved].map((b) => [b.id, b] as const),
);
let total = 0;
for (const booking of byId.values()) {
total += this.wagonsFor(booking, wagonDims);
}
return total;
private async borderLeg(stops: string[]): Promise<CorridorLeg | null> {
if (stops.length < 2) return null;
const yards = await this.dataSource
.getRepository(Yard)
.find({ where: { id: In(stops) } });
const countryOf = new Map(yards.map((y) => [y.id, y.country]));
const first = countryOf.get(stops[0]);
if (!first) return null;
const crossIdx = stops.findIndex((id) => {
const country = countryOf.get(id);
return country != null && country !== first;
});
if (crossIdx <= 0) return null;
return { fromEdge: crossIdx - 1, toEdge: crossIdx };
}
/**

View File

@@ -1,3 +1,4 @@
import { bookingCargoTons } from './train-capacity.util';
import type { Booking } from '../bookings/entities/booking.entity';
import type { WagonType } from '../wagon-types/entities/wagon-type.entity';
import {
@@ -187,5 +188,5 @@ export function summarizeFleetWarnings(
}
export function totalAssignedWeight(bookings: Booking[]): number {
return roundTons(bookings.reduce((sum, b) => sum + Number(b.cargoTotalWeightVgm ?? 0), 0));
return roundTons(bookings.reduce((sum, b) => sum + bookingCargoTons(b), 0));
}

View File

@@ -7,6 +7,7 @@ import {
grossWagonWeightTons,
minLocomotiveLimits,
sizePartialOfferWagons,
trainSetLocomotiveLimits,
} from './train-capacity.util';
describe('train-capacity.util', () => {
@@ -205,6 +206,37 @@ describe('train-capacity.util', () => {
expect(limits?.overageToleranceTons).toBe(20);
});
it('ignores unconfigured (null) tolerances instead of zeroing the set (S-2026-00024)', () => {
// LOCO-019 had 90T tolerance, LOCO-020 had none configured: the set must
// keep the 90, not collapse to 0 and reject 3547.6T on a 3500T train.
const limits = minLocomotiveLimits([
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: null },
]);
expect(limits?.overageToleranceTons).toBe(90);
// All unconfigured → no tolerance.
const none = minLocomotiveLimits([
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760 },
]);
expect(none?.overageToleranceTons).toBe(0);
});
it('trainSetLocomotiveLimits prefers link rows and falls back to the legacy single loco', () => {
const l1 = { maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 };
const l2 = { maxPullWeightTons: 3600, maxTrainLengthMeters: 700, overageToleranceTons: null };
expect(
trainSetLocomotiveLimits({ locomotive: null, locomotives: [{ locomotive: l1 }, { locomotive: l2 }] }),
).toEqual({
maxPullWeightTons: 3500,
maxTrainLengthMeters: 700,
overageToleranceTons: 90,
overageToleranceMeters: 0,
});
expect(trainSetLocomotiveLimits({ locomotive: l1 })?.maxPullWeightTons).toBe(3500);
expect(trainSetLocomotiveLimits(null)).toBeNull();
expect(trainSetLocomotiveLimits({ locomotive: null, locomotives: [] })).toBeNull();
});
describe('sizePartialOfferWagons', () => {
it('sizes a bulk split by the WEIGHT axis when the pull limit binds, not wagon slots', () => {
// The 3500T-train scenario: two 1000T bookings boarded gross (each 15 PW2

View File

@@ -86,6 +86,27 @@ function num(value: unknown, fallback = 0): number {
return Number.isFinite(n) ? n : fallback;
}
/**
* Cargo tons of a booking: the stored VGM total when present, else the sum of
* its container lines (quantity × VGM per unit). The portal's container flow
* stores per-line VGM and leaves `cargoTotalWeightVgm` at 0 — reading the
* total alone made every such booking weigh only its tare.
*/
export function bookingCargoTons(booking: {
cargoTotalWeightVgm?: number | string | null;
bookingContainers?: Array<{
quantity?: number | null;
vgmPerUnitTons?: number | string | null;
}> | null;
}): number {
const total = num(booking.cargoTotalWeightVgm);
if (total > 0) return total;
return (booking.bookingContainers ?? []).reduce(
(sum, line) => sum + num(line.quantity) * num(line.vgmPerUnitTons),
0,
);
}
/** Gross weight of one loaded wagon: it hauls itself plus its cargo. */
export function grossWagonWeightTons(slot: Pick<ConsistSlot, 'tareWeightTons' | 'cargoTons'>): number {
return num(slot.tareWeightTons) + num(slot.cargoTons);
@@ -260,11 +281,34 @@ export function minLocomotiveLimits(
};
}
function minConfigured(values: Array<number | string | null | undefined>): number {
function minConfigured(values: Array<number | null | undefined>): number {
const configured = values.filter((v) => v != null).map((v) => num(v));
return configured.length ? Math.min(...configured) : 0;
}
/**
* Effective limits for a whole train set: min across its linked locomotives,
* falling back to the legacy single `locomotive` column for sets created
* before multi-loco support. Null when the set has no locomotive at all.
*/
export function trainSetLocomotiveLimits(
trainSet?: {
locomotive?: LocomotiveLimits | null;
locomotives?: Array<{ locomotive?: LocomotiveLimits | null }> | null;
} | null,
): LocomotiveLimits | null {
if (!trainSet) return null;
const linked = (trainSet.locomotives ?? [])
.map((link) => link.locomotive)
.filter((l): l is LocomotiveLimits => Boolean(l));
const pool = linked.length
? linked
: trainSet.locomotive
? [trainSet.locomotive]
: [];
return minLocomotiveLimits(pool);
}
/** Per-booking train length from wagon count and freight-specific wagon type length. */
export function bookingTrainLengthMeters(
freightType: string | null | undefined,

View File

@@ -122,6 +122,7 @@ import {
roundTons,
sumWagonsRequired,
type TrainLimitConfig,
maxEdgeConsistUsage,
validateContainerPlacements,
validateMixedTrainLimitsPerEdge,
type ContainerPlacementInput,
@@ -130,9 +131,12 @@ import {
import { deriveScheduleDirection } from './derive-schedule-direction.util';
import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util';
import {
bookingCargoTons,
deriveTrainCapacityFromLocomotive,
minLocomotiveLimits,
trainSetLocomotiveLimits,
wagonTypeDimensionsFromEntity,
LocomotiveLimits,
WagonTypeDimensions,
} from './train-capacity.util';
import {
@@ -1706,25 +1710,36 @@ export class TrainSchedulingService {
relations: { wagonType: true },
})
: null;
const planTareTons = consistWagons
const planTareTons = roundTons(
wagonPlan.reduce((sum, slot) => sum + Number(slot.tareWeightTons ?? 0), 0),
);
const consistTareTons = consistWagons
? roundTons(
consistWagons.reduce(
(sum, wagon) => sum + Number(wagon.wagonType?.tareWeightTons ?? 0),
0,
),
)
: roundTons(
wagonPlan.reduce((sum, slot) => sum + Number(slot.tareWeightTons ?? 0), 0),
);
const grossWeightTons = roundTons(totalWeightTons + planTareTons);
: planTareTons;
// The pull limit binds on the HEAVIEST LEG, not the whole-route sum —
// disjoint legs (intercity Gelan→Adama + export Adama→Doraleh) are never
// hauled at the same time. Coupled-but-unplanned wagons ride every edge,
// so their tare rides on top of the binding edge.
const emptyConsistTareTons = Math.max(0, consistTareTons - planTareTons);
const edgeUsage = maxEdgeConsistUsage(
wagonPlan,
await this.stopYardsForSchedule(schedule),
);
const grossWeightTons = roundTons(edgeUsage.grossWeightTons + emptyConsistTareTons);
if (!dto.forceAssign && weightCapWithOverage < grossWeightTons) {
throw new BadRequestException(
`Train set locomotives cannot pull ${grossWeightTons}T gross (${totalWeightTons}T cargo + ${planTareTons}T wagon tare)`,
`Train set locomotives cannot pull ${grossWeightTons}T gross on the heaviest leg (limit ${roundTons(weightCapWithOverage)}T incl. tolerance)`,
);
}
if (!dto.forceAssign && lengthCapWithOverage < totalLengthMeters) {
const maxEdgeLengthMeters = roundTons(edgeUsage.lengthMeters);
if (!dto.forceAssign && lengthCapWithOverage < maxEdgeLengthMeters) {
throw new BadRequestException(
`Train set locomotives cannot support ${totalLengthMeters}m`,
`Train set locomotives cannot support ${maxEdgeLengthMeters}m`,
);
}
@@ -4081,9 +4096,6 @@ export class TrainSchedulingService {
}
const totalWeightTons = totalAssignedWeight(fittingBookings);
// Every weight limit below (global max, loco pull) is a GROSS axis, so the
// figure spent against it must be gross too — cargo alone under-reports the
// train by the full consist tare and disagrees with the assign path.
const totalTareTons = roundTons(
wagonPlan.reduce((sum, w) => sum + (Number(w.tareWeightTons) || 0), 0),
);
@@ -4091,12 +4103,13 @@ export class TrainSchedulingService {
const totalLengthMeters = roundTons(
wagonPlan.reduce((sum, w) => sum + w.lengthMeters, 0),
);
if (grossWeightTons > trainLimits.maxWeightTons) {
const message = `Total gross weight ${grossWeightTons}T (${totalWeightTons}T cargo + ${totalTareTons}T wagon tare) exceeds max train weight ${trainLimits.maxWeightTons}T`;
if (!violations.includes(message) && !warnings.includes(message)) {
pushLimit([message]);
}
}
// Weight/length limits are enforced PER EDGE by validateMixedTrainLimitsPerEdge
// above — the whole-route totals here are informational (summary) only. The
// locomotive checks below also compare the heaviest single edge: a train is
// never heavier than its heaviest leg, so disjoint legs must not be summed.
const edgeUsage = maxEdgeConsistUsage(wagonPlan, stops);
const maxEdgeGrossTons = roundTons(edgeUsage.grossWeightTons);
const maxEdgeLengthMeters = roundTons(edgeUsage.lengthMeters);
let assignedLocomotives: Locomotive[] = [];
if (targetScheduleId) {
@@ -4119,9 +4132,9 @@ export class TrainSchedulingService {
if (
setLimits &&
(setLimits.maxPullWeightTons + (Number(setLimits.overageToleranceTons) || 0) <
grossWeightTons ||
maxEdgeGrossTons ||
setLimits.maxTrainLengthMeters + (Number(setLimits.overageToleranceMeters) || 0) <
totalLengthMeters)
maxEdgeLengthMeters)
) {
pushLimit([
'Assigned locomotives cannot support the total train weight and length',
@@ -4140,9 +4153,9 @@ export class TrainSchedulingService {
!inServiceLocomotives.some(
(l) =>
Number(l.maxPullWeightTons) + (Number(l.overageToleranceTons) || 0) >=
grossWeightTons &&
maxEdgeGrossTons &&
Number(l.maxTrainLengthMeters) + (Number(l.overageToleranceMeters) || 0) >=
totalLengthMeters,
maxEdgeLengthMeters,
)
) {
pushLimit(['No locomotive can support the total train weight and length']);
@@ -4201,10 +4214,7 @@ export class TrainSchedulingService {
maxTrainLengthMeters?: number;
maxWagonsPerTrain?: number;
},
locomotive?: Pick<
Locomotive,
'maxPullWeightTons' | 'maxTrainLengthMeters' | 'overageToleranceTons' | 'overageToleranceMeters'
>,
locomotive?: LocomotiveLimits | null,
): Promise<Required<TrainLimitConfig>> {
const row = await this.loadGlobalRulesRow();
const configured = this.configService?.get<{
@@ -6512,7 +6522,7 @@ export class TrainSchedulingService {
>,
tareDims: Awaited<ReturnType<TrainSchedulingService['loadWagonTareDims']>>,
): number {
const cargo = Number(booking.cargoTotalWeightVgm ?? 0);
const cargo = bookingCargoTons(booking);
const fallback =
booking.freightType === 'BULK' ? tareDims.bulk : tareDims.container;
// Same first-configured-type resolution the batch engine's dimsFor uses.
@@ -6878,6 +6888,18 @@ export class TrainSchedulingService {
// Ordered corridor stops (route milestones; falls back to the two
// endpoints) — lets the UI draw per-segment occupancy and label legs.
stops: this.mapScheduleStops(schedule),
// Gross ceiling the validator holds each leg to: the set's weakest
// locomotive pull limit plus its overage tolerance. Booking weightTons
// above are gross too, so the strip can sum them per leg against this.
maxGrossWeightTons: (() => {
const setLimits = trainSetLocomotiveLimits(schedule.trainSet);
return setLimits
? roundTons(
Number(setLimits.maxPullWeightTons) +
(Number(setLimits.overageToleranceTons) || 0),
)
: null;
})(),
// True when the wagon plan above is served from the frozen snapshot (schedule
// is dispatched/arrived/cancelled) rather than the live joins — the UI can badge
// it "historical" and skip re-pin affordances.
@@ -6973,7 +6995,10 @@ export class TrainSchedulingService {
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
};
const limits = await this.resolveTrainLimitConfig(undefined, schedule.trainSet.locomotive);
const limits = await this.resolveTrainLimitConfig(
undefined,
trainSetLocomotiveLimits(schedule.trainSet),
);
const validation = await this.validateBookingsForScheduling(
previewDto,
@@ -7099,7 +7124,7 @@ export class TrainSchedulingService {
};
const limits = await this.resolveTrainLimitConfig(
undefined,
schedule.trainSet.locomotive,
trainSetLocomotiveLimits(schedule.trainSet),
);
let validation: Awaited<ReturnType<TrainSchedulingService['validateBookingsForScheduling']>>;
@@ -7643,7 +7668,7 @@ export class TrainSchedulingService {
};
const limits = await this.resolveTrainLimitConfig(
undefined,
schedule.trainSet.locomotive,
trainSetLocomotiveLimits(schedule.trainSet),
);
let validation: Awaited<ReturnType<TrainSchedulingService['validateBookingsForScheduling']>>;

View File

@@ -9,6 +9,7 @@ import {
containerWagonsForLines,
expandBookingContainerUnits,
expandContainerItems,
maxEdgeConsistUsage,
roundTons,
sumWagonsRequired,
validate20ftContainerRules,
@@ -279,3 +280,54 @@ describe('containerWagonsForLines — TEU-aware, ceil booking total once', () =>
expect(containerWagonsForLines([])).toBe(0);
});
});
describe('maxEdgeConsistUsage — the binding edge, not the whole-route sum', () => {
const slot = (
tare: number,
cargo: number,
length: number,
board?: string | null,
alight?: string | null,
) =>
({
tareWeightTons: tare,
assignedWeightTons: cargo,
lengthMeters: length,
boardYardId: board ?? null,
alightYardId: alight ?? null,
}) as never;
const stops = ['a', 'b', 'c'];
it('does not sum disjoint legs: intercity a→b + export b→c', () => {
const plan = [
slot(24, 65, 14, null, 'b'), // intercity, rides a→b only
slot(24, 65, 14, 'b', null), // export, rides b→c only
];
// Each edge carries one slot: 89T gross / 14m — never 178T.
expect(maxEdgeConsistUsage(plan, stops)).toEqual({
grossWeightTons: 89,
lengthMeters: 14,
});
});
it('sums overlapping legs on their shared edge (the S-2026-00024 shape)', () => {
// 20 intercity a→b wagons + 20 export a→c wagons, 23.94T tare, 64.75T cargo:
// shared edge a→b carries all 40 slots = 3547.6T gross.
const plan = [
...Array.from({ length: 20 }, () => slot(23.94, 64.75, 14, null, 'b')),
...Array.from({ length: 20 }, () => slot(23.94, 64.75, 14, null, null)),
];
const usage = maxEdgeConsistUsage(plan, stops);
expect(usage.grossWeightTons).toBeCloseTo(3547.6, 1);
expect(usage.lengthMeters).toBe(560);
});
it('degrades to whole-train totals on a two-stop route', () => {
const plan = [slot(24, 65, 14), slot(24, 65, 14)];
expect(maxEdgeConsistUsage(plan, ['a', 'b'])).toEqual({
grossWeightTons: 178,
lengthMeters: 28,
});
});
});

View File

@@ -539,15 +539,9 @@ export function validateMixedTrainLimitsPerEdge(
stops: string[],
): string[] {
if (stops.length <= 2) return validateMixedTrainLimits(wagonPlan, wagonTypes, limits);
const lastIdx = stops.length - 1;
const spans = wagonPlan.map((slot) => {
const from = slot.boardYardId ? stops.indexOf(slot.boardYardId) : 0;
const to = slot.alightYardId ? stops.indexOf(slot.alightYardId) : lastIdx;
// A yard missing from the stop list keeps the slot on the whole route.
return { from: from >= 0 ? from : 0, to: to > 0 ? to : lastIdx };
});
const spans = slotSpans(wagonPlan, stops);
const violations = new Set<string>();
for (let edge = 0; edge < lastIdx; edge += 1) {
for (let edge = 0; edge < stops.length - 1; edge += 1) {
const active = wagonPlan.filter(
(_, i) => spans[i].from <= edge && edge < spans[i].to,
);
@@ -559,6 +553,52 @@ export function validateMixedTrainLimitsPerEdge(
return [...violations];
}
/** Per-slot stop-index spans; a yard missing from the stop list keeps the slot on the whole route. */
function slotSpans(
wagonPlan: WagonPlanSlot[],
stops: string[],
): Array<{ from: number; to: number }> {
const lastIdx = stops.length - 1;
return wagonPlan.map((slot) => {
const from = slot.boardYardId ? stops.indexOf(slot.boardYardId) : 0;
const to = slot.alightYardId ? stops.indexOf(slot.alightYardId) : lastIdx;
return { from: from >= 0 ? from : 0, to: to > 0 ? to : lastIdx };
});
}
/**
* The corridor's binding edge: gross tons (tare + assigned cargo) and length
* summed over only the slots riding each edge, maxed across edges. This is the
* figure a locomotive pull/length limit must be compared against — a train is
* never heavier than its heaviest single leg, so summing disjoint legs
* (intercity Gelan→Adama + export Adama→Doraleh) over-reports the train.
* Two stops or fewer degrade to the whole-train totals.
*/
export function maxEdgeConsistUsage(
wagonPlan: WagonPlanSlot[],
stops: string[],
): { grossWeightTons: number; lengthMeters: number } {
const totals = (slots: WagonPlanSlot[]) => ({
grossWeightTons: slots.reduce(
(sum, w) =>
sum + Number(w.tareWeightTons ?? 0) + Number(w.assignedWeightTons ?? 0),
0,
),
lengthMeters: slots.reduce((sum, w) => sum + Number(w.lengthMeters ?? 0), 0),
});
if (stops.length <= 2) return totals(wagonPlan);
const spans = slotSpans(wagonPlan, stops);
const usage = { grossWeightTons: 0, lengthMeters: 0 };
for (let edge = 0; edge < stops.length - 1; edge += 1) {
const active = totals(
wagonPlan.filter((_, i) => spans[i].from <= edge && edge < spans[i].to),
);
usage.grossWeightTons = Math.max(usage.grossWeightTons, active.grossWeightTons);
usage.lengthMeters = Math.max(usage.lengthMeters, active.lengthMeters);
}
return usage;
}
export function validate20ftContainerRules(
units: ContainerUnitRow[],
placements: ContainerPlacementInput[],

View File

@@ -15,6 +15,7 @@ import {
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { FleetManage, FleetView } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
import { BuildTrainDto } from './dto/build-train.dto';
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
@@ -27,12 +28,12 @@ import { TrainBuilderService } from './train-builder.service';
@ApiTags('train-builder')
@ApiBearerAuth()
@Controller('train-builder')
@FleetView()
@FleetView(FREIGHT_PERMS.trains.view)
export class TrainBuilderController {
constructor(private readonly trainBuilderService: TrainBuilderService) {}
@Post()
@FleetManage()
@FleetManage(FREIGHT_PERMS.trains.create)
@ApiOperation({ summary: 'Build a train: code + yard + 2+ locomotives (+ optional wagons)' })
build(@Body() dto: BuildTrainDto) {
return this.trainBuilderService.buildTrain(dto);
@@ -60,7 +61,7 @@ export class TrainBuilderController {
}
@Put(':id/locomotives')
@FleetManage()
@FleetManage(FREIGHT_PERMS.trains.update)
@ApiOperation({ summary: 'Replace the locomotive set (minimum 2, same yard)' })
setLocomotives(
@Param('id', ParseUUIDPipe) id: string,
@@ -70,7 +71,7 @@ export class TrainBuilderController {
}
@Patch(':id/details')
@FleetManage()
@FleetManage(FREIGHT_PERMS.trains.update)
@ApiOperation({
summary: "Edit the train's name and fixed import/export run numbers",
})
@@ -82,7 +83,7 @@ export class TrainBuilderController {
}
@Patch(':id/yard')
@FleetManage()
@FleetManage(FREIGHT_PERMS.trains.update)
@ApiOperation({
summary: 'Relocate the train — its locomotives and wagons move to the new yard with it',
})
@@ -91,14 +92,14 @@ export class TrainBuilderController {
}
@Post(':id/wagons')
@FleetManage()
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
@ApiOperation({ summary: "Append AVAILABLE wagons from the train's yard to the consist" })
assignWagons(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignTrainWagonsDto) {
return this.trainBuilderService.assignWagons(id, dto);
}
@Delete(':id/wagons/:wagonId')
@FleetManage()
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
@ApiOperation({ summary: 'Detach one wagon from the consist' })
removeWagon(
@Param('id', ParseUUIDPipe) id: string,
@@ -108,7 +109,7 @@ export class TrainBuilderController {
}
@Post(':id/wagons/:wagonId/maintenance')
@FleetManage()
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
@ApiOperation({ summary: 'Detach one wagon and move it to MAINTENANCE status' })
sendWagonToMaintenance(
@Param('id', ParseUUIDPipe) id: string,
@@ -118,14 +119,14 @@ export class TrainBuilderController {
}
@Post(':id/reorder-wagons')
@FleetManage()
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
@ApiOperation({ summary: 'Persist a drag-reorder of the full consist' })
reorderWagons(@Param('id', ParseUUIDPipe) id: string, @Body() dto: ReorderTrainWagonsDto) {
return this.trainBuilderService.reorderWagons(id, dto);
}
@Post(':id/deactivate')
@FleetManage()
@FleetManage(FREIGHT_PERMS.trains.update)
@ApiOperation({
summary: 'Deactivate the train (park it) — only allowed with no active schedule',
})
@@ -134,14 +135,14 @@ export class TrainBuilderController {
}
@Post(':id/activate')
@FleetManage()
@FleetManage(FREIGHT_PERMS.trains.update)
@ApiOperation({ summary: 'Reactivate a deactivated train back to AVAILABLE' })
activate(@Param('id', ParseUUIDPipe) id: string) {
return this.trainBuilderService.activate(id);
}
@Delete(':id')
@FleetManage()
@FleetManage(FREIGHT_PERMS.trains.delete)
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Disband the train (release wagons and locomotives)' })
disband(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -12,18 +12,19 @@ import {
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { FleetManage, FleetView } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { CreateTrainDto } from "./dto/create-train.dto";
import { UpdateTrainDto } from "./dto/update-train.dto";
import { TrainsService } from "./trains.service";
@ApiTags("trains")
@Controller("trains")
@FleetView()
@FleetView(FREIGHT_PERMS.trains.view)
export class TrainsController {
constructor(private readonly trainsService: TrainsService) {}
@Post()
@FleetManage()
@FleetManage(FREIGHT_PERMS.trains.create)
@ApiOperation({ summary: "Register a new train" })
create(@Body() dto: CreateTrainDto) {
return this.trainsService.create(dto);
@@ -42,14 +43,14 @@ export class TrainsController {
}
@Patch(":id")
@FleetManage()
@FleetManage(FREIGHT_PERMS.trains.update)
@ApiOperation({ summary: "Update a train" })
update(@Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateTrainDto) {
return this.trainsService.update(id, dto);
}
@Delete(":id")
@FleetManage()
@FleetManage(FREIGHT_PERMS.trains.delete)
@ApiOperation({ summary: "Delete a train" })
remove(@Param("id", ParseUUIDPipe) id: string) {
return this.trainsService.remove(id);

View File

@@ -19,6 +19,7 @@ import {
WagonTransferHistoryAll,
WagonTransferRequest,
} from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { BulkFulfillTransferRequestsDto } from './dto/bulk-fulfill-transfer-requests.dto';
import { CreateTransferRequestDto } from './dto/create-transfer-request.dto';
import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto';
@@ -31,7 +32,7 @@ import { WagonTransferRequestsService } from './wagon-transfer-requests.service'
*/
@ApiTags('wagon-transfer-requests')
@Controller('wagon-transfer-requests')
@FleetView()
@FleetView(FREIGHT_PERMS.wagons.view)
export class WagonTransferRequestsController {
constructor(private readonly service: WagonTransferRequestsService) {}
@@ -110,7 +111,7 @@ export class WagonTransferRequestsController {
}
@Post(':id/cancel')
@FleetManage()
@FleetManage(FREIGHT_PERMS.wagons.transferRequest)
@ApiOperation({ summary: 'Withdraw a pending transfer request' })
cancel(@Param('id', ParseUUIDPipe) id: string) {
return this.service.cancelRequest(id);

View File

@@ -12,7 +12,8 @@ import {
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { FleetManage, FleetView } from '../../common/booking-guards';
import { FleetManage, FleetView, StaffReference } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { CreateWagonDto } from './dto/create-wagon.dto';
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
import { UpdateWagonDto } from './dto/update-wagon.dto';
@@ -23,31 +24,36 @@ import { BulkSetWagonStatusDto } from './dto/bulk-set-wagon-status.dto';
import { WagonsService } from './wagons.service';
@ApiTags('wagons')
// No class-level guard: reads (list, by-id, movements) are login-only reference
// data — any staff can fetch wagon data for a cross-flow view without the
// fleet:view that drives the Fleet sidebar. Every mutation has its @FleetManage().
@Controller('wagons')
@FleetView()
export class WagonsController {
constructor(private readonly wagonsService: WagonsService) {}
@Post()
@FleetManage()
@FleetManage(FREIGHT_PERMS.wagons.create)
@ApiOperation({ summary: 'Create a new wagon' })
create(@Body() dto: CreateWagonDto) {
return this.wagonsService.create(dto);
}
@Get()
@StaffReference()
@ApiOperation({ summary: 'List all wagons' })
findAll(@Query() query: ListWagonsQueryDto) {
return this.wagonsService.findAll(query);
}
@Get(':id')
@StaffReference()
@ApiOperation({ summary: 'Get a wagon by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.wagonsService.findById(id);
}
@Get(':id/movements')
@StaffReference()
@ApiOperation({
summary: "Wagon movement ledger (loaded legs, empty repositions, manual moves), newest first",
})
@@ -56,42 +62,42 @@ export class WagonsController {
}
@Patch(':id')
@FleetManage()
@FleetManage(FREIGHT_PERMS.wagons.update)
@ApiOperation({ summary: 'Update a wagon' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWagonDto) {
return this.wagonsService.update(id, dto);
}
@Delete(':id')
@FleetManage()
@FleetManage(FREIGHT_PERMS.wagons.delete)
@ApiOperation({ summary: 'Delete a wagon' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.wagonsService.remove(id);
}
@Post(':id/assign-train')
@FleetManage()
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
@ApiOperation({ summary: 'Assign wagon to a train' })
assignToTrain(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignWagonToTrainDto) {
return this.wagonsService.assignToTrain(id, dto);
}
@Post(':id/unassign-train')
@FleetManage()
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
@ApiOperation({ summary: 'Unassign wagon from train' })
unassignFromTrain(@Param('id', ParseUUIDPipe) id: string) {
return this.wagonsService.unassignFromTrain(id);
}
@Post('bulk-transfer')
@FleetManage()
@FleetManage(FREIGHT_PERMS.wagons.update)
@ApiOperation({ summary: 'Transfer multiple wagons to a destination yard' })
bulkTransfer(@Body() dto: BulkTransferWagonsDto, @CurrentUser() user: TCurrentUser) {
return this.wagonsService.bulkTransfer(dto, user?.id);
}
@Post('bulk-status')
@FleetManage()
@FleetManage(FREIGHT_PERMS.wagons.update)
@ApiOperation({ summary: 'Set the status of multiple wagons' })
bulkSetStatus(@Body() dto: BulkSetWagonStatusDto) {
return this.wagonsService.bulkSetStatus(dto);
@@ -100,12 +106,12 @@ export class WagonsController {
// Separate controller for trainspecific reorder (registered in module)
@Controller('trains/:trainId/reorder-wagons')
@FleetView()
@FleetView(FREIGHT_PERMS.trains.view)
export class TrainWagonsReorderController {
constructor(private readonly wagonsService: WagonsService) {}
@Post()
@FleetManage()
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
@ApiOperation({ summary: 'Reorder wagons of a train' })
reorder(@Param('trainId', ParseUUIDPipe) trainId: string, @Body() dto: ReorderWagonsDto) {
return this.wagonsService.reorderWagons(trainId, dto);

View File

@@ -52,4 +52,8 @@ export class BookingHandover extends BaseEntity {
/** EDR last-mile: when the goods were delivered to the customer. */
@Column({ name: 'delivered_at', type: 'timestamptz', nullable: true })
deliveredAt?: Date | null;
/** URL to the signer's saved signature image, if available at sign time. */
@Column({ name: 'signature_image_url', type: 'text', nullable: true })
signatureImageUrl?: string | null;
}

View File

@@ -287,6 +287,7 @@ export class HandoverService {
handoverId: string,
userId?: string | null,
signerName?: string | null,
signatureImageUrl?: string | null,
): Promise<BookingHandover> {
const repo = this.dataSource.getRepository(BookingHandover);
const handover = await repo.findOne({ where: { id: handoverId } });
@@ -297,6 +298,7 @@ export class HandoverService {
handover.signedAt = new Date();
handover.signedByUserId = userId ?? null;
handover.signerName = signerName?.trim() || null;
handover.signatureImageUrl = signatureImageUrl ?? null;
return repo.save(handover);
}
@@ -305,6 +307,7 @@ export class HandoverService {
bookingId: string,
userId?: string | null,
signerName?: string | null,
signatureImageUrl?: string | null,
): Promise<void> {
await this.dataSource
.getRepository(BookingHandover)
@@ -314,6 +317,7 @@ export class HandoverService {
signedAt: new Date(),
signedByUserId: userId ?? null,
signerName: signerName?.trim() || null,
signatureImageUrl: signatureImageUrl ?? null,
},
);
}

View File

@@ -2504,27 +2504,37 @@ export class WarehouseInventoryService {
});
if (result.unloadedCount > 0) {
let document = await this.interchangeDocuments.generateFromSchedule({
scheduleId,
direction: 'EXPORT',
handoverLocation: schedule.destinationName ?? 'Djibouti Port',
handoverFrom: 'EDR',
handoverTo: 'Djibouti Port Operator',
portOperatorName: 'Doraleh Multipurpose Port',
generatedBy: performedBy ?? 'EDR Operations',
remarks: 'Generated after export unloading at Djibouti Port; signed by EDR and Djibouti Port Operator.',
});
if (document.status !== 'ACKNOWLEDGED') {
document = await this.interchangeDocuments.acknowledge(document.id, {
acknowledgedBy: 'Djibouti Port Operator',
remarks: 'Auto acknowledged after Djibouti export unloading.',
// Best-effort: the unload is already committed — a paperwork failure must
// not fail the response (it did once: items unloaded, request 500'd, and
// the document only appeared after a manual retry days later). The doc
// backfills on any retry since already-unloaded items count as unloaded.
try {
let document = await this.interchangeDocuments.generateFromSchedule({
scheduleId,
direction: 'EXPORT',
handoverLocation: schedule.destinationName ?? 'Djibouti Port',
handoverFrom: 'EDR',
handoverTo: 'Djibouti Port Operator',
portOperatorName: 'Doraleh Multipurpose Port',
generatedBy: performedBy ?? 'EDR Operations',
remarks: 'Generated after export unloading at Djibouti Port; signed by EDR and Djibouti Port Operator.',
});
if (document.status !== 'ACKNOWLEDGED') {
document = await this.interchangeDocuments.acknowledge(document.id, {
acknowledgedBy: 'Djibouti Port Operator',
remarks: 'Auto acknowledged after Djibouti export unloading.',
});
}
result.interchangeDocument = {
id: document.id,
documentNo: document.documentNo,
status: document.status,
};
} catch (err) {
this.logger.warn(
`Export interchange document generation failed for schedule ${scheduleId}: ${(err as Error).message} — rerun the Djibouti unloading to regenerate it`,
);
}
result.interchangeDocument = {
id: document.id,
documentNo: document.documentNo,
status: document.status,
};
}
return result;
@@ -4087,10 +4097,11 @@ export class WarehouseInventoryService {
await this.invoices.assertClearanceAllowed(item.id);
const approvedAt = new Date();
const signatureImageUrl = signature?.signatureImageUrl ?? null;
const approval = {
approvedAt: approvedAt.toISOString(),
signerDisplayName: name,
signatureImageUrl: signature?.signatureImageUrl ?? null,
signatureImageUrl,
userId,
};
const existingNotes = this.stripCustomerDeliveryApproval(item.notes);
@@ -4114,7 +4125,7 @@ export class WarehouseInventoryService {
// Sign the structured handover record(s) for this booking (self-haul: before
// the truck leaves). Kept alongside the legacy approval note.
await this.handover.signForBooking(bookingId, userId, name);
await this.handover.signForBooking(bookingId, userId, name, signatureImageUrl);
return {
bookingId,
@@ -4149,6 +4160,8 @@ export class WarehouseInventoryService {
throw new BadRequestException('Please enter your full name to sign the handover');
}
const signature = await this.signatures.getForUser(userId).catch(() => null);
const [h]: Array<{
bookingId: string;
reference: string;
@@ -4181,7 +4194,7 @@ export class WarehouseInventoryService {
);
if (inv) await this.invoices.assertClearanceAllowed(inv.id);
const signed = await this.handover.sign(handoverId, userId, name);
const signed = await this.handover.sign(handoverId, userId, name, signature?.signatureImageUrl ?? null);
const allSigned = await this.handover.isFullySigned(h.bookingId);
if (inv) {

View File

@@ -70,9 +70,15 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [
*/
export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [
perm('a3000001-0001-4000-8000-000000000001', 'edr_freight_app:contracts:view', 'View contracts'),
perm('a3000001-0001-4000-8000-000000000002', 'edr_freight_app:contracts:staff_accept', 'Accept contract intake'),
perm('a3000001-0001-4000-8000-000000000003', 'edr_freight_app:contracts:request_changes', 'Request contract changes'),
perm('a3000001-0001-4000-8000-000000000004', 'edr_freight_app:contracts:reject', 'Reject contract'),
// Intake actions are split per freight type (bulk vs container) — fresh ids
// because the seeder upserts ON CONFLICT (key); reusing the old ids with new
// keys would PK-collide with the legacy staff_accept/request_changes/reject rows.
perm('a3000001-0001-4000-8000-000000000011', 'edr_freight_app:contracts:staff_accept:bulk', 'Accept bulk contract intake'),
perm('a3000001-0001-4000-8000-000000000012', 'edr_freight_app:contracts:staff_accept:container', 'Accept container contract intake'),
perm('a3000001-0001-4000-8000-000000000013', 'edr_freight_app:contracts:request_changes:bulk', 'Request bulk contract changes'),
perm('a3000001-0001-4000-8000-000000000014', 'edr_freight_app:contracts:request_changes:container', 'Request container contract changes'),
perm('a3000001-0001-4000-8000-000000000015', 'edr_freight_app:contracts:reject:bulk', 'Reject bulk contract'),
perm('a3000001-0001-4000-8000-000000000016', 'edr_freight_app:contracts:reject:container', 'Reject container contract'),
perm('a3000001-0001-4000-8000-000000000005', 'edr_freight_app:contracts:approve_line_staff', 'Approve contract as line staff'),
perm('a3000001-0001-4000-8000-000000000006', 'edr_freight_app:contracts:approve_director', 'Approve contract as director'),
perm('a3000001-0001-4000-8000-000000000007', 'edr_freight_app:contracts:approve_ceo', 'Approve contract as CEO'),
@@ -212,6 +218,10 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [
perm('e1f00001-0001-4000-8000-000000000002', 'edr_freight_app:cargoes:create', 'Create cargo'),
perm('e1f00001-0001-4000-8000-000000000003', 'edr_freight_app:cargoes:update', 'Update cargo'),
perm('e1f00001-0001-4000-8000-000000000004', 'edr_freight_app:cargoes:delete', 'Delete cargo'),
// NB: id prefixes must stay hex — 'e1g…' once crashed the boot seeder
// (postgres: invalid input syntax for type uuid).
perm('e1900001-0001-4000-8000-000000000001', 'edr_freight_app:consignments:view', 'View consignments'),
perm('e1900001-0001-4000-8000-000000000002', 'edr_freight_app:consignments:create', 'Create consignment'),
];
// G. Fleet — road & telemetry
@@ -302,8 +312,6 @@ export const SCHEDULING_EXTRA_PERMISSIONS: FreightPermissionSeed[] = [
// L. Administration & settings (split from the coarse admin umbrella)
export const CONFIG_SETTINGS_PERMISSIONS: FreightPermissionSeed[] = [
perm('b3a00001-0001-4000-8000-000000000001', 'edr_freight_app:config:contract_validity:view', 'View contract validity periods'),
perm('b3a00001-0001-4000-8000-000000000002', 'edr_freight_app:config:contract_validity:manage', 'Manage contract validity periods'),
perm('b4a00001-0001-4000-8000-000000000001', 'edr_freight_app:settings:file_upload:view', 'View file-upload settings'),
perm('b4a00001-0001-4000-8000-000000000002', 'edr_freight_app:settings:file_upload:manage', 'Manage file-upload settings'),
perm('b4b00001-0001-4000-8000-000000000001', 'edr_freight_app:settings:dropdown:view', 'View dropdown settings'),
@@ -371,9 +379,18 @@ export const FREIGHT_PERMS = {
},
contracts: {
view: 'edr_freight_app:contracts:view',
staffAccept: 'edr_freight_app:contracts:staff_accept',
requestChanges: 'edr_freight_app:contracts:request_changes',
reject: 'edr_freight_app:contracts:reject',
staffAccept: {
bulk: 'edr_freight_app:contracts:staff_accept:bulk',
container: 'edr_freight_app:contracts:staff_accept:container',
},
requestChanges: {
bulk: 'edr_freight_app:contracts:request_changes:bulk',
container: 'edr_freight_app:contracts:request_changes:container',
},
reject: {
bulk: 'edr_freight_app:contracts:reject:bulk',
container: 'edr_freight_app:contracts:reject:container',
},
approveLineStaff: 'edr_freight_app:contracts:approve_line_staff',
approveDirector: 'edr_freight_app:contracts:approve_director',
approveCeo: 'edr_freight_app:contracts:approve_ceo',
@@ -495,6 +512,10 @@ export const FREIGHT_PERMS = {
update: 'edr_freight_app:cargoes:update',
delete: 'edr_freight_app:cargoes:delete',
},
consignments: {
view: 'edr_freight_app:consignments:view',
create: 'edr_freight_app:consignments:create',
},
vehicles: {
view: 'edr_freight_app:vehicles:view',
create: 'edr_freight_app:vehicles:create',
@@ -594,12 +615,6 @@ export const FREIGHT_PERMS = {
cancel: 'edr_freight_app:warehouse_fee_invoices:cancel',
pay: 'edr_freight_app:warehouse_fee_invoices:pay',
},
config: {
contractValidity: {
view: 'edr_freight_app:config:contract_validity:view',
manage: 'edr_freight_app:config:contract_validity:manage',
},
},
settings: {
fileUpload: {
view: 'edr_freight_app:settings:file_upload:view',
@@ -661,9 +676,56 @@ export const FREIGHT_PERMS = {
},
} as const;
/** Both arms of a freight-type-split permission (for one-of route guards). */
export const bothFreightTypes = (p: { bulk: string; container: string }): string[] => [
p.bulk,
p.container,
];
/** The arm of a freight-type-split permission matching a contract's freightType. */
export const forFreightType = (
p: { bulk: string; container: string },
freightType: string,
): string => (freightType === 'BULK' ? p.bulk : p.container);
const allRuleEngineViewKeys = () =>
RULE_ENGINE_RESOURCE_SLUGS.map((s) => FREIGHT_PERMS.ruleEngine.view(s));
/**
* Granular equivalents of the legacy fleet:view + fleet:manage pair.
* Deliberately excludes the wagon-transfer keys — those were always separate
* grants (requester vs OCC vs admin history), not part of fleet:manage.
*/
const FLEET_GRANULAR_KEYS: string[] = [
FREIGHT_PERMS.locomotives.view,
FREIGHT_PERMS.locomotives.create,
FREIGHT_PERMS.locomotives.update,
FREIGHT_PERMS.locomotives.delete,
FREIGHT_PERMS.wagons.view,
FREIGHT_PERMS.wagons.create,
FREIGHT_PERMS.wagons.update,
FREIGHT_PERMS.wagons.delete,
FREIGHT_PERMS.trains.view,
FREIGHT_PERMS.trains.create,
FREIGHT_PERMS.trains.update,
FREIGHT_PERMS.trains.delete,
FREIGHT_PERMS.trains.assignWagons,
FREIGHT_PERMS.routes.view,
FREIGHT_PERMS.routes.create,
FREIGHT_PERMS.routes.update,
FREIGHT_PERMS.routes.delete,
FREIGHT_PERMS.containers.view,
FREIGHT_PERMS.containers.create,
FREIGHT_PERMS.containers.update,
FREIGHT_PERMS.containers.delete,
FREIGHT_PERMS.cargoes.view,
FREIGHT_PERMS.cargoes.create,
FREIGHT_PERMS.cargoes.update,
FREIGHT_PERMS.cargoes.delete,
FREIGHT_PERMS.consignments.view,
FREIGHT_PERMS.consignments.create,
];
export const ROLE_PERMISSION_PRESETS = {
// Marketing / line staff: drives a booking from intake through line-staff
// approval and contract generation/signing — i.e. until the contract is ready
@@ -677,9 +739,9 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.bookings.rejectApproval,
FREIGHT_PERMS.bookings.cancel,
FREIGHT_PERMS.contracts.view,
FREIGHT_PERMS.contracts.staffAccept,
FREIGHT_PERMS.contracts.requestChanges,
FREIGHT_PERMS.contracts.reject,
...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept),
...bothFreightTypes(FREIGHT_PERMS.contracts.requestChanges),
...bothFreightTypes(FREIGHT_PERMS.contracts.reject),
FREIGHT_PERMS.contracts.approveLineStaff,
...allRuleEngineViewKeys(),
],
@@ -692,6 +754,7 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.trainScheduling.manage,
FREIGHT_PERMS.fleet.view,
FREIGHT_PERMS.fleet.manage,
...FLEET_GRANULAR_KEYS,
// Path A (no customs): Operations reviews the customer's self-clearance docs
// — on the contract for ONE_TIME contracts, and PER BOOKING for GENERAL
// contracts (booking-level document review → finalize → CLEARANCE_READY).
@@ -768,9 +831,9 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.bookings.reviewDocuments,
FREIGHT_PERMS.bookings.finalizeClearance,
FREIGHT_PERMS.contracts.view,
FREIGHT_PERMS.contracts.staffAccept,
FREIGHT_PERMS.contracts.requestChanges,
FREIGHT_PERMS.contracts.reject,
...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept),
...bothFreightTypes(FREIGHT_PERMS.contracts.requestChanges),
...bothFreightTypes(FREIGHT_PERMS.contracts.reject),
FREIGHT_PERMS.contracts.approveLineStaff,
FREIGHT_PERMS.contracts.generateContract,
FREIGHT_PERMS.contracts.signStaff,