mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 02:58:11 +00:00
Merge remote-tracking branch 'origin/dev' into tests
Merging remote repo
This commit is contained in:
@@ -12,7 +12,7 @@ import {
|
|||||||
ensurePostgresSchemas,
|
ensurePostgresSchemas,
|
||||||
APPLICATION_SEARCH_PATH,
|
APPLICATION_SEARCH_PATH,
|
||||||
} from "./config/ensure-postgres-schemas";
|
} 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 { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module";
|
||||||
|
|
||||||
import appConfig from "./config/app.config";
|
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";
|
import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder";
|
||||||
//New Trains, Wagons, Container and Cargo management modules
|
//New Trains, Wagons, Container and Cargo management modules
|
||||||
import { TrainsModule } from "./modules/trains/trains.module";
|
import { TrainsModule } from "./modules/trains/trains.module";
|
||||||
import { VerifaydaModule } from './modules/verifayda/verifayda.module';
|
import { VerifaydaModule } from "./modules/verifayda/verifayda.module";
|
||||||
import { FleetHistoryModule } from './modules/fleet-history/fleet-history.module';
|
import { FleetHistoryModule } from "./modules/fleet-history/fleet-history.module";
|
||||||
import { WagonsModule } from "./modules/wagons/wagons.module";
|
import { WagonsModule } from "./modules/wagons/wagons.module";
|
||||||
import { ContainersModule } from "./modules/container-management/containers.module";
|
import { ContainersModule } from "./modules/container-management/containers.module";
|
||||||
import { CargoesModule } from "./modules/cargoes/cargoes.module";
|
import { CargoesModule } from "./modules/cargoes/cargoes.module";
|
||||||
@@ -104,7 +104,13 @@ import { LoggerMiddleware } from "./logger.middleware";
|
|||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig, faydaConfig],
|
load: [
|
||||||
|
appConfig,
|
||||||
|
databaseConfig,
|
||||||
|
telebirrConfig,
|
||||||
|
rabbitmqConfig,
|
||||||
|
faydaConfig,
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
EventEmitterModule.forRoot(),
|
EventEmitterModule.forRoot(),
|
||||||
@@ -223,7 +229,7 @@ import { LoggerMiddleware } from "./logger.middleware";
|
|||||||
})
|
})
|
||||||
export class AppModule implements OnApplicationBootstrap {
|
export class AppModule implements OnApplicationBootstrap {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly seeder: DataSeeder,
|
// private readonly seeder: DataSeeder,
|
||||||
private readonly edrOrgSeeder: EdrOrgSeeder,
|
private readonly edrOrgSeeder: EdrOrgSeeder,
|
||||||
private readonly freightPositionsSeeder: FreightPositionsSeeder,
|
private readonly freightPositionsSeeder: FreightPositionsSeeder,
|
||||||
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
|
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
|
||||||
@@ -258,7 +264,7 @@ export class AppModule implements OnApplicationBootstrap {
|
|||||||
// freightPositionsSeeder → seeds Position + PositionPermission rows
|
// freightPositionsSeeder → seeds Position + PositionPermission rows
|
||||||
// (depends on edrOrgSeeder, must run after)
|
// (depends on edrOrgSeeder, must run after)
|
||||||
await this.freightPermissionKeyMigrationSeeder.run();
|
await this.freightPermissionKeyMigrationSeeder.run();
|
||||||
await this.seeder.run();
|
// await this.seeder.run();
|
||||||
await this.edrOrgSeeder.run();
|
await this.edrOrgSeeder.run();
|
||||||
await this.freightPositionsSeeder.run();
|
await this.freightPositionsSeeder.run();
|
||||||
|
|
||||||
|
|||||||
@@ -29,9 +29,22 @@ export const TrainSchedulingView = () =>
|
|||||||
export const TrainSchedulingManage = () =>
|
export const TrainSchedulingManage = () =>
|
||||||
BookingStaff(FREIGHT_PERMS.trainScheduling.manage);
|
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). */
|
/** Requester creates a wagon-transfer request (count-only, no wagon picks). */
|
||||||
export const WagonTransferRequest = () =>
|
export const WagonTransferRequest = () =>
|
||||||
|
|||||||
@@ -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 step’s 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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(
|
export function assertCanApproveBookingStep(
|
||||||
user: TCurrentUser | MeLikeUser | null | undefined,
|
user: TCurrentUser | MeLikeUser | null | undefined,
|
||||||
requiredRole: string,
|
requiredRole: string,
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ const TRIGGER_ROUTE_LABELS: Partial<Record<Rate['trigger'], string>> = {
|
|||||||
REEFER: 'Reefer (refrigerated) surcharge',
|
REEFER: 'Reefer (refrigerated) surcharge',
|
||||||
WITH_RETURN: 'Empty-container return service',
|
WITH_RETURN: 'Empty-container return service',
|
||||||
SHIPPING_LINE: 'Shipping line handling',
|
SHIPPING_LINE: 'Shipping line handling',
|
||||||
CONSOLIDATION: 'Container consolidation (extra document)',
|
CONSOLIDATION: 'Penalty (container consolidation)',
|
||||||
LASHING: 'Cargo lashing and securing',
|
LASHING: 'Cargo lashing and securing',
|
||||||
CANCELLATION: 'Booking cancellation fee',
|
CANCELLATION: 'Booking cancellation fee',
|
||||||
DEMURRAGE: 'Demurrage / wagon detention',
|
DEMURRAGE: 'Demurrage / wagon detention',
|
||||||
|
|||||||
@@ -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
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The customs clearance service fee is no longer prepaid via its own
|
||||||
|
* `clearance`-source invoice — it is billed as a CUSTOMS_CLEARANCE line on the
|
||||||
|
* booking invoice, together with the freight (see BookingPricingService).
|
||||||
|
*
|
||||||
|
* - Contracts/bookings parked at the payment gate move straight to the
|
||||||
|
* document step (the gate no longer exists — nothing could ever pay them).
|
||||||
|
* - Open (unpaid) clearance invoices are expired; PAID ones stay as history.
|
||||||
|
* NOTE: a ONE_TIME customs contract that already PAID its prepaid fee but
|
||||||
|
* has not booked yet will be billed the fee again on its booking invoice —
|
||||||
|
* accepted for dev data; reverses the old AddClearanceFeePayment migration.
|
||||||
|
* - clearance_fee_paid_at columns are dropped from contracts and bookings.
|
||||||
|
*/
|
||||||
|
export class DropClearanceFeePrepay2860000000000 implements MigrationInterface {
|
||||||
|
name = 'DropClearanceFeePrepay2860000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.contracts
|
||||||
|
SET status = 'AWAITING_CLEARANCE_DOCUMENTS', updated_at = now()
|
||||||
|
WHERE status = 'AWAITING_CLEARANCE_PAYMENT';
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.contracts
|
||||||
|
SET clearance_status = 'AWAITING_DOCUMENTS', updated_at = now()
|
||||||
|
WHERE clearance_status = 'AWAITING_PAYMENT';
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.bookings
|
||||||
|
SET status = 'AWAITING_DOCUMENTS', updated_at = now()
|
||||||
|
WHERE status = 'AWAITING_CLEARANCE_PAYMENT';
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.invoices
|
||||||
|
SET status = 'EXPIRED', updated_at = now()
|
||||||
|
WHERE source = 'clearance'
|
||||||
|
AND status IN ('DRAFT', 'ISSUED', 'PENDING', 'PARTIALLY_PAID', 'OVERDUE');
|
||||||
|
`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.contracts DROP COLUMN IF EXISTS clearance_fee_paid_at;`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS clearance_fee_paid_at;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// Moved rows and expired invoices stay — only the columns come back.
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.contracts ADD COLUMN IF NOT EXISTS clearance_fee_paid_at timestamptz;`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS clearance_fee_paid_at timestamptz;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customs clearance fees are now sold per cargo kind: container fees name a
|
||||||
|
* container type (billed PER_CONTAINER / PER_WAGON), bulk fees carry no type
|
||||||
|
* (billed PER_TON / PER_WAGON). The old one-FLAT-fee-per-route shape cannot be
|
||||||
|
* mapped to a kind — retired (SUPERSEDED + soft-deleted) exactly like the
|
||||||
|
* base-freight and return-surcharge reshapes, kept readable for snapshot
|
||||||
|
* history. Per-kind replacements must be re-entered; a customs contract or
|
||||||
|
* booking without a matching fee hard-blocks. Contracts that already froze a
|
||||||
|
* FLAT snapshot keep billing it (legacy honoured at booking pricing).
|
||||||
|
*/
|
||||||
|
export class CustomsClearancePerKind2870000000000 implements MigrationInterface {
|
||||||
|
name = 'CustomsClearancePerKind2870000000000';
|
||||||
|
|
||||||
|
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 rate_unit = 'FLAT';
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(): Promise<void> {
|
||||||
|
// Retired rates stay retired — re-enter per-kind rates instead.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lashing is now sold per cargo kind, like the customs clearance fee:
|
||||||
|
* container rates name a container type (PER_CONTAINER / PER_WAGON), bulk
|
||||||
|
* rates carry no type (PER_TON / PER_WAGON). The old flat-per-booking shape
|
||||||
|
* cannot be mapped to a kind — retired (SUPERSEDED + soft-deleted), kept
|
||||||
|
* readable for snapshot history. Per-kind replacements must be re-entered;
|
||||||
|
* an unconfigured lashing rate simply bills nothing (lenient, like
|
||||||
|
* hazard/reefer). Matched on trigger, not rate_type — CONSOLIDATION rates
|
||||||
|
* share the LASHING rate_type and must survive.
|
||||||
|
*/
|
||||||
|
export class LashingPerKind2880000000000 implements MigrationInterface {
|
||||||
|
name = 'LashingPerKind2880000000000';
|
||||||
|
|
||||||
|
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 "trigger" = 'LASHING'
|
||||||
|
AND rate_unit = 'FLAT';
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(): Promise<void> {
|
||||||
|
// Retired rates stay retired — re-enter per-kind rates instead.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lashing is now BULK-only and sold per trade direction (IMPORT / EXPORT),
|
||||||
|
* optionally narrowed to one leaf commodity. Rates that no longer fit —
|
||||||
|
* container-scoped, or carrying no direction — cannot be mapped and are
|
||||||
|
* retired (SUPERSEDED + soft-deleted), kept readable for snapshot history.
|
||||||
|
* Matched on trigger, not rate_type (CONSOLIDATION shares rate_type LASHING).
|
||||||
|
*/
|
||||||
|
export class LashingBulkOnlyPerDirection2890000000000 implements MigrationInterface {
|
||||||
|
name = 'LashingBulkOnlyPerDirection2890000000000';
|
||||||
|
|
||||||
|
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 "trigger" = 'LASHING'
|
||||||
|
AND (container_type_id IS NOT NULL
|
||||||
|
OR trade_direction IS NULL
|
||||||
|
OR trade_direction NOT IN ('IMPORT', 'EXPORT'));
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(): Promise<void> {
|
||||||
|
// Retired rates stay retired — re-enter per-direction bulk rates instead.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ApiProperty } from "@nestjs/swagger";
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
import { IsBoolean, IsEmail, IsObject, IsOptional, IsString, MinLength } from "class-validator";
|
import { Type } from "class-transformer";
|
||||||
|
import { IsBoolean, IsEmail, IsOptional, IsString, MinLength, ValidateNested } from "class-validator";
|
||||||
|
|
||||||
class CreateOrganizationUserNameDto {
|
class CreateOrganizationUserNameDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@@ -29,7 +30,8 @@ export class CreateOrganizationUserDto {
|
|||||||
phoneNumber?: string;
|
phoneNumber?: string;
|
||||||
|
|
||||||
@ApiProperty({ type: CreateOrganizationUserNameDto })
|
@ApiProperty({ type: CreateOrganizationUserNameDto })
|
||||||
@IsObject()
|
@ValidateNested()
|
||||||
|
@Type(() => CreateOrganizationUserNameDto)
|
||||||
name!: CreateOrganizationUserNameDto;
|
name!: CreateOrganizationUserNameDto;
|
||||||
|
|
||||||
@ApiProperty({ required: false, default: false })
|
@ApiProperty({ required: false, default: false })
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
InvoiceLineInput,
|
InvoiceLineInput,
|
||||||
} from "../billing/billing.service";
|
} from "../billing/billing.service";
|
||||||
import { Invoice } from "../billing/entities/invoice.entity";
|
import { Invoice } from "../billing/entities/invoice.entity";
|
||||||
import { CLEARANCE_BOOKING_INVOICE_TYPE } from "../contracts/clearance-fee.service";
|
|
||||||
import { FirstMileService } from "../first-mile/first-mile.service";
|
import { FirstMileService } from "../first-mile/first-mile.service";
|
||||||
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
|
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
|
||||||
import { PriceLineItemDto } from "./dto/generate-price-response.dto";
|
import { PriceLineItemDto } from "./dto/generate-price-response.dto";
|
||||||
@@ -121,8 +120,7 @@ export class BookingInvoiceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Expire the booking's currently-open invoices (freight PREPAID and the
|
* Expire the booking's currently-open freight (PREPAID) invoice when the booking is
|
||||||
* per-shipment clearance fee) when the booking is
|
|
||||||
* cancelled or rejected — the counterpart to the pay-window-expiry path
|
* cancelled or rejected — the counterpart to the pay-window-expiry path
|
||||||
* (which also calls {@link BillingService.expirePayable}). Stops a terminated
|
* (which also calls {@link BillingService.expirePayable}). Stops a terminated
|
||||||
* booking from leaving a payable invoice open. No-op when the booking has no
|
* booking from leaving a payable invoice open. No-op when the booking has no
|
||||||
@@ -133,15 +131,6 @@ export class BookingInvoiceService {
|
|||||||
bookingId: string,
|
bookingId: string,
|
||||||
manager?: EntityManager,
|
manager?: EntityManager,
|
||||||
): Promise<Invoice | null> {
|
): Promise<Invoice | null> {
|
||||||
// The per-shipment clearance fee (GENERAL contracts) bills this same booking
|
|
||||||
// id under its own source/type — retire it alongside the freight invoice, or
|
|
||||||
// a cancelled shipment keeps a payable clearance invoice open.
|
|
||||||
await this.billing.expirePayable(
|
|
||||||
Freight.InvoiceSource.Clearance,
|
|
||||||
bookingId,
|
|
||||||
CLEARANCE_BOOKING_INVOICE_TYPE,
|
|
||||||
manager,
|
|
||||||
);
|
|
||||||
return this.billing.expirePayable(
|
return this.billing.expirePayable(
|
||||||
Freight.InvoiceSource.Booking,
|
Freight.InvoiceSource.Booking,
|
||||||
bookingId,
|
bookingId,
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ describe('BookingPricingService — domestic corridor', () => {
|
|||||||
ratesService as never,
|
ratesService as never,
|
||||||
exchangeService as never,
|
exchangeService as never,
|
||||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
|
{} as never,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -240,3 +241,235 @@ describe('BookingPricingService — domestic corridor', () => {
|
|||||||
expect(result.blocked[0]).toContain('rate is configured');
|
expect(result.blocked[0]).toContain('rate is configured');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('BookingPricingService — customs clearance fee billed on the booking price', () => {
|
||||||
|
const DJ = 'yard-dj';
|
||||||
|
|
||||||
|
const containerFee20: Rate = {
|
||||||
|
id: 'rate-cc-20',
|
||||||
|
rateType: 'CUSTOMS_CLEARANCE',
|
||||||
|
trigger: 'CUSTOMS_CLEARANCE',
|
||||||
|
currency: 'USD',
|
||||||
|
rateValue: 100,
|
||||||
|
rateUnit: 'PER_CONTAINER',
|
||||||
|
status: 'LIVE',
|
||||||
|
containerTypeId: 'ct-20',
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
originYardId: DJ,
|
||||||
|
destinationYardId: DIRE,
|
||||||
|
} as Rate;
|
||||||
|
|
||||||
|
const bulkFeePerTon: Rate = {
|
||||||
|
...containerFee20,
|
||||||
|
id: 'rate-cc-bulk',
|
||||||
|
rateValue: 5,
|
||||||
|
rateUnit: 'PER_TON',
|
||||||
|
containerTypeId: null,
|
||||||
|
} as Rate;
|
||||||
|
|
||||||
|
const emptyEval = {
|
||||||
|
priorityScore: 0,
|
||||||
|
appliedModifiers: [],
|
||||||
|
containerWeightResults: [],
|
||||||
|
warnings: [],
|
||||||
|
hardBlocked: [],
|
||||||
|
requiresDirectorApproval: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeService = (opts: {
|
||||||
|
snapshots?: unknown[];
|
||||||
|
liveRates?: Rate[];
|
||||||
|
wagonCapacity?: number;
|
||||||
|
}) =>
|
||||||
|
new BookingPricingService(
|
||||||
|
{
|
||||||
|
calculateWagonCount: jest.fn().mockResolvedValue(0),
|
||||||
|
findContractRateSnapshots: jest.fn().mockResolvedValue(opts.snapshots ?? []),
|
||||||
|
} as never,
|
||||||
|
{ evaluate: jest.fn().mockResolvedValue(emptyEval) } as never,
|
||||||
|
{
|
||||||
|
findById: jest.fn(async (id: string) => ({
|
||||||
|
id,
|
||||||
|
sizeFt: id === 'ct-40' ? 40 : 20,
|
||||||
|
isReefer: false,
|
||||||
|
code: id === 'ct-40' ? 'C40' : 'C20',
|
||||||
|
})),
|
||||||
|
} as never,
|
||||||
|
{ findLiveRates: jest.fn().mockResolvedValue(opts.liveRates ?? []) } as never,
|
||||||
|
{ getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never,
|
||||||
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
|
{
|
||||||
|
findById: jest.fn().mockResolvedValue({
|
||||||
|
wagonTypes:
|
||||||
|
opts.wagonCapacity !== undefined
|
||||||
|
? [{ capacityTons: opts.wagonCapacity }]
|
||||||
|
: [],
|
||||||
|
}),
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
const containerBooking = (overrides: Record<string, unknown> = {}) =>
|
||||||
|
({
|
||||||
|
id: 'b-cc',
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
paymentCurrency: 'USD',
|
||||||
|
customsClearingEnabled: true,
|
||||||
|
originYardId: DJ,
|
||||||
|
destinationYardId: DIRE,
|
||||||
|
bookingContainers: [
|
||||||
|
{ containerTypeId: 'ct-20', quantity: 4, vgmPerUnitTons: 10, wagonsRequired: 2 },
|
||||||
|
],
|
||||||
|
...overrides,
|
||||||
|
}) as unknown as Booking;
|
||||||
|
|
||||||
|
const bulkBooking = (overrides: Record<string, unknown> = {}) =>
|
||||||
|
({
|
||||||
|
id: 'b-cc-bulk',
|
||||||
|
freightType: 'BULK',
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
paymentCurrency: 'USD',
|
||||||
|
customsClearingEnabled: true,
|
||||||
|
cargoTypeId: 'cargo-1',
|
||||||
|
cargoTotalWeightVgm: 120,
|
||||||
|
originYardId: DJ,
|
||||||
|
destinationYardId: DIRE,
|
||||||
|
bookingContainers: [],
|
||||||
|
...overrides,
|
||||||
|
}) as unknown as Booking;
|
||||||
|
|
||||||
|
it('bills a container booking per box at its own container type fee', async () => {
|
||||||
|
const service = makeService({ liveRates: [containerFee20] });
|
||||||
|
const result = await service.computePriceForBooking(containerBooking());
|
||||||
|
|
||||||
|
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE_20FT');
|
||||||
|
expect(line).toBeDefined();
|
||||||
|
expect(line!.unit).toBe('PER_CONTAINER');
|
||||||
|
expect(line!.quantity).toBe(4);
|
||||||
|
expect(line!.amount).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bills a PER_WAGON container fee on the wagons the boxes occupy (two 20ft share one)', async () => {
|
||||||
|
const service = makeService({
|
||||||
|
liveRates: [{ ...containerFee20, rateUnit: 'PER_WAGON' } as Rate],
|
||||||
|
});
|
||||||
|
const result = await service.computePriceForBooking(containerBooking());
|
||||||
|
|
||||||
|
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE_20FT');
|
||||||
|
expect(line!.unit).toBe('PER_WAGON');
|
||||||
|
expect(line!.quantity).toBe(2);
|
||||||
|
expect(line!.amount).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hard-blocks a container type with no fee configured (never free clearance)', async () => {
|
||||||
|
const service = makeService({ liveRates: [bulkFeePerTon] });
|
||||||
|
const result = await service.computePriceForBooking(containerBooking());
|
||||||
|
|
||||||
|
expect(result.lineItems.some((l) => l.code.startsWith('CUSTOMS_CLEARANCE'))).toBe(false);
|
||||||
|
expect(result.hardBlocked.some((m) => m.includes('customs clearance'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bills a bulk booking per ton at the route bulk fee', async () => {
|
||||||
|
const service = makeService({ liveRates: [bulkFeePerTon] });
|
||||||
|
const result = await service.computePriceForBooking(bulkBooking());
|
||||||
|
|
||||||
|
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE');
|
||||||
|
expect(line!.unit).toBe('PER_TON');
|
||||||
|
expect(line!.quantity).toBe(120);
|
||||||
|
expect(line!.amount).toBe(600);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the fee scoped to the booking commodity wins over the catch-all', async () => {
|
||||||
|
const service = makeService({
|
||||||
|
liveRates: [
|
||||||
|
{ ...bulkFeePerTon, id: 'rate-cc-catchall', rateValue: 5 } as Rate,
|
||||||
|
{
|
||||||
|
...bulkFeePerTon,
|
||||||
|
id: 'rate-cc-sugar',
|
||||||
|
rateValue: 9,
|
||||||
|
cargoTypeId: 'cargo-1',
|
||||||
|
} as Rate,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const result = await service.computePriceForBooking(bulkBooking());
|
||||||
|
|
||||||
|
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE');
|
||||||
|
expect(line!.unitAmount).toBe(9); // commodity rate, not the 5 USD catch-all
|
||||||
|
expect(line!.amount).toBe(1080);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bills a PER_WAGON bulk fee on ceil(tons ÷ wagon capacity)', async () => {
|
||||||
|
const service = makeService({
|
||||||
|
liveRates: [{ ...bulkFeePerTon, rateUnit: 'PER_WAGON', rateValue: 50 } as Rate],
|
||||||
|
wagonCapacity: 60,
|
||||||
|
});
|
||||||
|
const result = await service.computePriceForBooking(bulkBooking());
|
||||||
|
|
||||||
|
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE');
|
||||||
|
expect(line!.unit).toBe('PER_WAGON');
|
||||||
|
expect(line!.quantity).toBe(2); // 120 t ÷ 60 t per wagon
|
||||||
|
expect(line!.amount).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks a PER_WAGON bulk fee when no wagon capacity is configured', async () => {
|
||||||
|
const service = makeService({
|
||||||
|
liveRates: [{ ...bulkFeePerTon, rateUnit: 'PER_WAGON' } as Rate],
|
||||||
|
});
|
||||||
|
const result = await service.computePriceForBooking(bulkBooking());
|
||||||
|
|
||||||
|
expect(result.hardBlocked.some((m) => m.includes('wagon'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers the contract frozen per-size snapshot over the live rate', async () => {
|
||||||
|
const service = makeService({
|
||||||
|
liveRates: [containerFee20],
|
||||||
|
snapshots: [
|
||||||
|
{
|
||||||
|
rateCode: 'CUSTOMS_CLEARANCE_20FT',
|
||||||
|
unitPrice: 80,
|
||||||
|
currency: 'USD',
|
||||||
|
unitOfMeasure: 'per_container',
|
||||||
|
isClearance: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const result = await service.computePriceForBooking(
|
||||||
|
containerBooking({ contractId: 'c-1' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE_20FT');
|
||||||
|
expect(line!.amount).toBe(320); // 4 × frozen 80, not live 100
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours a legacy FLAT snapshot once for the whole container booking', async () => {
|
||||||
|
const service = makeService({
|
||||||
|
liveRates: [],
|
||||||
|
snapshots: [
|
||||||
|
{
|
||||||
|
rateCode: 'CUSTOMS_CLEARANCE',
|
||||||
|
unitPrice: 500,
|
||||||
|
currency: 'USD',
|
||||||
|
unitOfMeasure: 'flat',
|
||||||
|
isClearance: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const result = await service.computePriceForBooking(
|
||||||
|
containerBooking({ contractId: 'c-legacy' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE');
|
||||||
|
expect(line!.unit).toBe('FLAT');
|
||||||
|
expect(line!.amount).toBe(500);
|
||||||
|
expect(result.hardBlocked.some((m) => m.includes('customs clearance'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds no fee line when customs clearing is disabled', async () => {
|
||||||
|
const service = makeService({ liveRates: [containerFee20] });
|
||||||
|
const result = await service.computePriceForBooking(
|
||||||
|
containerBooking({ customsClearingEnabled: false }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.lineItems.some((l) => l.code.startsWith('CUSTOMS_CLEARANCE'))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
|
||||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||||
import { RatesService } from '../rule-engine/services/rates.service';
|
import { RatesService } from '../rule-engine/services/rates.service';
|
||||||
import { Rate } from '../rule-engine/entities/rate.entity';
|
import { Rate } from '../rule-engine/entities/rate.entity';
|
||||||
@@ -10,7 +11,10 @@ import {
|
|||||||
BookingEvaluationInput,
|
BookingEvaluationInput,
|
||||||
RuleEngineService,
|
RuleEngineService,
|
||||||
} from '../rule-engine/rule-engine.service';
|
} from '../rule-engine/rule-engine.service';
|
||||||
import { containersPerWagonForSize } from '../rule-engine/container-type.util';
|
import {
|
||||||
|
containersPerWagonForSize,
|
||||||
|
wagonsPerUnitForSize,
|
||||||
|
} from '../rule-engine/container-type.util';
|
||||||
import { BookingsRepository } from './bookings.repository';
|
import { BookingsRepository } from './bookings.repository';
|
||||||
import { wagonRemainder } from './consolidation.service';
|
import { wagonRemainder } from './consolidation.service';
|
||||||
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
|
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
|
||||||
@@ -76,6 +80,7 @@ export class BookingPricingService {
|
|||||||
private readonly ratesService: RatesService,
|
private readonly ratesService: RatesService,
|
||||||
private readonly exchangeService: ExchangeService,
|
private readonly exchangeService: ExchangeService,
|
||||||
private readonly containerValidationService: ContainerValidationService,
|
private readonly containerValidationService: ContainerValidationService,
|
||||||
|
private readonly cargoTypesService: CargoTypesService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
|
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
|
||||||
@@ -165,8 +170,16 @@ export class BookingPricingService {
|
|||||||
const usdAmount = mod.calculatedAmount;
|
const usdAmount = mod.calculatedAmount;
|
||||||
|
|
||||||
const rate = rateById.get(mod.rateId);
|
const rate = rateById.get(mod.rateId);
|
||||||
const unit = rate?.rateUnit ?? 'FLAT';
|
// Derived/route-matched charges (import overweight, empty-container
|
||||||
const unitUsd = rate ? Number(rate.rateValue) : usdAmount;
|
// 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
|
// Per-unit count: FLAT and PER_INVOICE are billed once (qty 1); an
|
||||||
// explicit trigger (e.g. overweight tons) wins when present; otherwise
|
// explicit trigger (e.g. overweight tons) wins when present; otherwise
|
||||||
// derive from total ÷ unit price (the live unit price — a count, not a
|
// derive from total ÷ unit price (the live unit price — a count, not a
|
||||||
@@ -182,11 +195,11 @@ export class BookingPricingService {
|
|||||||
|
|
||||||
// H15: bill the frozen contract surcharge rate (already in the booking
|
// H15: bill the frozen contract surcharge rate (already in the booking
|
||||||
// currency) when this code has a snapshot; else keep the live amount.
|
// currency) when this code has a snapshot; else keep the live amount.
|
||||||
const frozen = this.frozenRateByCode(
|
// Derived charges skip the snapshot — import overweight prices off the
|
||||||
frozenRates,
|
// route's container freight, never a frozen OVERWEIGHT_PER_TON value.
|
||||||
mod.surchargeCode,
|
const frozen = isDerived
|
||||||
paymentCurrency,
|
? null
|
||||||
);
|
: this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency);
|
||||||
const unitAmount = frozen
|
const unitAmount = frozen
|
||||||
? Number(frozen.unitPrice)
|
? Number(frozen.unitPrice)
|
||||||
: isEtbBooking
|
: isEtbBooking
|
||||||
@@ -215,6 +228,23 @@ export class BookingPricingService {
|
|||||||
if (rate) usedRatesMap.set(rate.id, rate);
|
if (rate) usedRatesMap.set(rate.id, rate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Customs clearance service fee (Path B) — billed HERE, on the booking
|
||||||
|
// invoice with the freight; no separate prepaid clearance invoice. Sold per
|
||||||
|
// cargo kind: container bookings bill each container type's own fee (per
|
||||||
|
// box or per wagon), bulk bookings the route's bulk fee (per ton or per
|
||||||
|
// wagon). Frozen contract snapshots win over live rates; a customs booking
|
||||||
|
// with nothing configured hard-blocks — clearance never ships for free.
|
||||||
|
const clearanceBlocked: string[] = [];
|
||||||
|
if (booking.customsClearingEnabled) {
|
||||||
|
const clearance = await this.customsClearanceLines(booking, frozenRates, liveRates);
|
||||||
|
for (const line of clearance.lineItems) {
|
||||||
|
lineItems.push(line);
|
||||||
|
total += line.amount;
|
||||||
|
}
|
||||||
|
for (const rate of clearance.usedRates) usedRatesMap.set(rate.id, rate);
|
||||||
|
clearanceBlocked.push(...clearance.blocked);
|
||||||
|
}
|
||||||
|
|
||||||
// Overweight detail for the customer: map the engine's per-line results back
|
// Overweight detail for the customer: map the engine's per-line results back
|
||||||
// to the booking's container lines (same order) for code + weights. maxAllowed
|
// to the booking's container lines (same order) for code + weights. maxAllowed
|
||||||
// is derived from the line total minus the excess the engine computed.
|
// is derived from the line total minus the excess the engine computed.
|
||||||
@@ -252,7 +282,7 @@ export class BookingPricingService {
|
|||||||
appliedModifiers: ruleResult.appliedModifiers,
|
appliedModifiers: ruleResult.appliedModifiers,
|
||||||
priorityScore: ruleResult.priorityScore,
|
priorityScore: ruleResult.priorityScore,
|
||||||
warnings: [...ruleResult.warnings, ...baseWarnings],
|
warnings: [...ruleResult.warnings, ...baseWarnings],
|
||||||
hardBlocked: [...ruleResult.hardBlocked, ...baseBlocked],
|
hardBlocked: [...ruleResult.hardBlocked, ...baseBlocked, ...clearanceBlocked],
|
||||||
overweightLines,
|
overweightLines,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -313,6 +343,8 @@ export class BookingPricingService {
|
|||||||
hazardousQuantity: Number(bc.hazardousQuantity ?? 0),
|
hazardousQuantity: Number(bc.hazardousQuantity ?? 0),
|
||||||
reeferQuantity: Number(bc.reeferQuantity ?? 0),
|
reeferQuantity: Number(bc.reeferQuantity ?? 0),
|
||||||
returnQuantity: Number(bc.returnQuantity ?? 0),
|
returnQuantity: Number(bc.returnQuantity ?? 0),
|
||||||
|
// Wagon share per box — a PER_WAGON empty-return rate bills on it.
|
||||||
|
wagonsPerUnit: wagonsPerUnitForSize(ct.sizeFt),
|
||||||
},
|
},
|
||||||
perWagon: containersPerWagonForSize(ct.sizeFt),
|
perWagon: containersPerWagonForSize(ct.sizeFt),
|
||||||
quantity: qty,
|
quantity: qty,
|
||||||
@@ -330,6 +362,13 @@ export class BookingPricingService {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
: 0;
|
: 0;
|
||||||
|
// Bulk wagon estimate for PER_WAGON kind-scoped surcharges (lashing).
|
||||||
|
// Deliberately NOT totalWagons — that would shift wagon-count priority
|
||||||
|
// scoring for bulk bookings.
|
||||||
|
const bulkWagons =
|
||||||
|
booking.freightType === 'BULK'
|
||||||
|
? ((await this.bulkWagonCount(booking)) ?? 0)
|
||||||
|
: 0;
|
||||||
|
|
||||||
// Consolidation is system-managed: the CONSOLIDATION surcharge fires whenever
|
// Consolidation is system-managed: the CONSOLIDATION surcharge fires whenever
|
||||||
// a container type leaves a wagon partially filled. Aggregate by type first —
|
// a container type leaves a wagon partially filled. Aggregate by type first —
|
||||||
@@ -369,6 +408,8 @@ export class BookingPricingService {
|
|||||||
isGovernment: booking.isGovernment,
|
isGovernment: booking.isGovernment,
|
||||||
allowConsolidation,
|
allowConsolidation,
|
||||||
shippingLineId: booking.shippingLineId,
|
shippingLineId: booking.shippingLineId,
|
||||||
|
originYardId: booking.originYardId,
|
||||||
|
destinationYardId: booking.destinationYardId,
|
||||||
totalWagons,
|
totalWagons,
|
||||||
// Bulk tonnage scales PER_TON surcharges (e.g. the bulk reefer surcharge).
|
// Bulk tonnage scales PER_TON surcharges (e.g. the bulk reefer surcharge).
|
||||||
// Container freight carries 0 here — its surcharges scale by container count.
|
// Container freight carries 0 here — its surcharges scale by container count.
|
||||||
@@ -376,6 +417,7 @@ export class BookingPricingService {
|
|||||||
booking.freightType === 'BULK'
|
booking.freightType === 'BULK'
|
||||||
? Number(booking.cargoTotalWeightVgm ?? 0)
|
? Number(booking.cargoTotalWeightVgm ?? 0)
|
||||||
: 0,
|
: 0,
|
||||||
|
bulkWagons,
|
||||||
containers,
|
containers,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -884,6 +926,191 @@ export class BookingPricingService {
|
|||||||
return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency);
|
return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customs clearance service fee lines for a customs booking (Path B), billed
|
||||||
|
* with the freight. Container bookings bill each container line at its own
|
||||||
|
* container type's fee — PER_CONTAINER × boxes or PER_WAGON × the wagons the
|
||||||
|
* line occupies (two 20ft share one). Bulk bookings bill the route's type-less
|
||||||
|
* fee — PER_TON × tonnage or PER_WAGON × wagons the bulk occupies. Frozen
|
||||||
|
* contract snapshots (CUSTOMS_CLEARANCE_20FT / _40FT / CUSTOMS_CLEARANCE)
|
||||||
|
* win over live rates; contracts frozen before the per-kind model carry one
|
||||||
|
* FLAT CUSTOMS_CLEARANCE snapshot, honoured once for the whole booking.
|
||||||
|
*/
|
||||||
|
private async customsClearanceLines(
|
||||||
|
booking: Booking,
|
||||||
|
frozenRates: Map<string, ContractRateSnapshot> | null,
|
||||||
|
liveRates: Rate[],
|
||||||
|
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[]; blocked: string[] }> {
|
||||||
|
const lineItems: PriceLineItemDto[] = [];
|
||||||
|
const usedRates: Rate[] = [];
|
||||||
|
const blocked: string[] = [];
|
||||||
|
const currency = booking.paymentCurrency;
|
||||||
|
const isEtb = currency === 'ETB';
|
||||||
|
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||||||
|
const convert = (usd: number): number => (isEtb ? Math.round(usd * usdToEtb) : usd);
|
||||||
|
|
||||||
|
const onLeg = liveRates.filter(
|
||||||
|
(r) =>
|
||||||
|
r.rateType === 'CUSTOMS_CLEARANCE' &&
|
||||||
|
r.currency === 'USD' &&
|
||||||
|
r.tradeDirection === booking.tradeDirection &&
|
||||||
|
r.originYardId === booking.originYardId &&
|
||||||
|
r.destinationYardId === booking.destinationYardId,
|
||||||
|
);
|
||||||
|
const missingRateMessage = (scope: string): string =>
|
||||||
|
`No customs clearance service fee is configured for ${scope} on this ` +
|
||||||
|
'origin → destination. Ask EDR to configure the CUSTOMS_CLEARANCE rate for this route.';
|
||||||
|
|
||||||
|
if (booking.freightType === 'CONTAINER') {
|
||||||
|
// Legacy short-circuit: an old contract froze one flat fee — bill it once.
|
||||||
|
const hasPerSizeSnapshot =
|
||||||
|
frozenRates?.has('CUSTOMS_CLEARANCE_20FT') ||
|
||||||
|
frozenRates?.has('CUSTOMS_CLEARANCE_40FT');
|
||||||
|
const legacyFlat = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency);
|
||||||
|
if (legacyFlat && !hasPerSizeSnapshot) {
|
||||||
|
const amount = Number(legacyFlat.unitPrice);
|
||||||
|
if (amount > 0) {
|
||||||
|
lineItems.push({
|
||||||
|
code: 'CUSTOMS_CLEARANCE',
|
||||||
|
description: 'Customs clearance service',
|
||||||
|
amount,
|
||||||
|
unitAmount: amount,
|
||||||
|
unit: 'FLAT',
|
||||||
|
quantity: 1,
|
||||||
|
currency,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { lineItems, usedRates, blocked };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const bc of booking.bookingContainers ?? []) {
|
||||||
|
if (!bc.containerTypeId) continue;
|
||||||
|
const qty = Number(bc.quantity || 0);
|
||||||
|
if (!(qty > 0)) continue;
|
||||||
|
let sizeFt = 0;
|
||||||
|
try {
|
||||||
|
sizeFt =
|
||||||
|
Number((await this.containerTypesService.findById(bc.containerTypeId)).sizeFt) || 0;
|
||||||
|
} catch {
|
||||||
|
// unknown type — falls through to the live per-type lookup below
|
||||||
|
}
|
||||||
|
const frozen = sizeFt
|
||||||
|
? this.frozenRateByCode(frozenRates, `CUSTOMS_CLEARANCE_${sizeFt}FT`, currency)
|
||||||
|
: null;
|
||||||
|
const live = onLeg.find((r) => r.containerTypeId === bc.containerTypeId);
|
||||||
|
if (!frozen && !live) {
|
||||||
|
blocked.push(missingRateMessage(`${sizeFt || '?'}ft containers`));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const unit = frozen
|
||||||
|
? this.rateUnitFromSnapshot(frozen.unitOfMeasure)
|
||||||
|
: live!.rateUnit;
|
||||||
|
const unitAmount = frozen
|
||||||
|
? Number(frozen.unitPrice)
|
||||||
|
: convert(Number(live!.rateValue));
|
||||||
|
const billedQty =
|
||||||
|
unit === 'PER_WAGON' ? Math.ceil(qty * wagonsPerUnitForSize(sizeFt)) : qty;
|
||||||
|
const amount = unit === 'FLAT' ? unitAmount : unitAmount * billedQty;
|
||||||
|
if (!(amount > 0)) continue;
|
||||||
|
lineItems.push({
|
||||||
|
code: sizeFt ? `CUSTOMS_CLEARANCE_${sizeFt}FT` : 'CUSTOMS_CLEARANCE',
|
||||||
|
description: `Customs clearance service${sizeFt ? ` (${sizeFt}ft)` : ''}`,
|
||||||
|
amount,
|
||||||
|
unitAmount,
|
||||||
|
unit,
|
||||||
|
quantity: unit === 'FLAT' ? 1 : billedQty,
|
||||||
|
currency,
|
||||||
|
});
|
||||||
|
if (live && !frozen) usedRates.push(live);
|
||||||
|
}
|
||||||
|
return { lineItems, usedRates, blocked };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk — one fee for the whole booking. The bulk snapshot and the legacy
|
||||||
|
// flat snapshot share the CUSTOMS_CLEARANCE code; both are the agreed fee.
|
||||||
|
// Live lookup: the rate scoped to the booking's commodity wins; a
|
||||||
|
// commodity-less rate (legacy) is the catch-all fallback.
|
||||||
|
const frozen = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency);
|
||||||
|
const live =
|
||||||
|
(booking.cargoTypeId
|
||||||
|
? onLeg.find(
|
||||||
|
(r) => !r.containerTypeId && r.cargoTypeId === booking.cargoTypeId,
|
||||||
|
)
|
||||||
|
: undefined) ?? onLeg.find((r) => !r.containerTypeId && !r.cargoTypeId);
|
||||||
|
if (!frozen && !live) {
|
||||||
|
blocked.push(missingRateMessage('bulk cargo'));
|
||||||
|
return { lineItems, usedRates, blocked };
|
||||||
|
}
|
||||||
|
const unit = frozen ? this.rateUnitFromSnapshot(frozen.unitOfMeasure) : live!.rateUnit;
|
||||||
|
const unitAmount = frozen ? Number(frozen.unitPrice) : convert(Number(live!.rateValue));
|
||||||
|
let billedQty = 1;
|
||||||
|
if (unit === 'PER_TON') {
|
||||||
|
billedQty = Math.max(0, Number(booking.cargoTotalWeightVgm ?? 0));
|
||||||
|
} else if (unit === 'PER_WAGON') {
|
||||||
|
const wagons = await this.bulkWagonCount(booking);
|
||||||
|
if (wagons == null) {
|
||||||
|
blocked.push(
|
||||||
|
'The bulk customs clearance fee is per wagon, but this cargo type has ' +
|
||||||
|
'no wagon type with a capacity configured — the wagon count cannot ' +
|
||||||
|
'be derived. Ask EDR to configure the cargo type’s wagon types.',
|
||||||
|
);
|
||||||
|
return { lineItems, usedRates, blocked };
|
||||||
|
}
|
||||||
|
billedQty = wagons;
|
||||||
|
}
|
||||||
|
const amount = unit === 'FLAT' ? unitAmount : unitAmount * billedQty;
|
||||||
|
if (amount > 0) {
|
||||||
|
lineItems.push({
|
||||||
|
code: 'CUSTOMS_CLEARANCE',
|
||||||
|
description: 'Customs clearance service (bulk)',
|
||||||
|
amount,
|
||||||
|
unitAmount,
|
||||||
|
unit,
|
||||||
|
quantity: unit === 'FLAT' ? 1 : billedQty,
|
||||||
|
currency,
|
||||||
|
});
|
||||||
|
if (live && !frozen) usedRates.push(live);
|
||||||
|
}
|
||||||
|
return { lineItems, usedRates, blocked };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Snapshot unit-of-measure → the rate unit the billing math applies. */
|
||||||
|
private rateUnitFromSnapshot(unitOfMeasure: string): string {
|
||||||
|
switch (unitOfMeasure) {
|
||||||
|
case 'per_wagon':
|
||||||
|
return 'PER_WAGON';
|
||||||
|
case 'per_ton':
|
||||||
|
return 'PER_TON';
|
||||||
|
case 'per_container':
|
||||||
|
return 'PER_CONTAINER';
|
||||||
|
default:
|
||||||
|
return 'FLAT';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wagons a bulk booking occupies — ceil(tons ÷ rated capacity), using the
|
||||||
|
* largest-capacity wagon type its cargo type allows. Null when the chain is
|
||||||
|
* unconfigured (no cargo type, no wagon types, no capacity).
|
||||||
|
* ponytail: pricing-time estimate off the biggest allowed wagon; scheduling
|
||||||
|
* may stock a smaller type and use more wagons.
|
||||||
|
*/
|
||||||
|
private async bulkWagonCount(booking: Booking): Promise<number | null> {
|
||||||
|
const tons = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||||
|
if (!(tons > 0) || !booking.cargoTypeId) return null;
|
||||||
|
try {
|
||||||
|
const cargo = await this.cargoTypesService.findById(booking.cargoTypeId);
|
||||||
|
const capacity = Math.max(
|
||||||
|
0,
|
||||||
|
...(cargo.wagonTypes ?? []).map((w) => Number(w.capacityTons) || 0),
|
||||||
|
);
|
||||||
|
if (!(capacity > 0)) return null;
|
||||||
|
return Math.max(1, Math.ceil(tons / capacity));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private lineItemsSignature(items: PriceLineItemDto[]): string {
|
private lineItemsSignature(items: PriceLineItemDto[]): string {
|
||||||
return JSON.stringify(
|
return JSON.stringify(
|
||||||
[...items]
|
[...items]
|
||||||
|
|||||||
@@ -605,11 +605,6 @@ export class BookingTransitionService {
|
|||||||
files: Express.Multer.File[],
|
files: Express.Multer.File[],
|
||||||
): Promise<Booking> {
|
): Promise<Booking> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
if (booking.status === "AWAITING_CLEARANCE_PAYMENT") {
|
|
||||||
throw new ConflictException(
|
|
||||||
"The customs clearance service fee for this shipment has not been paid yet — pay it from the portal to unlock document upload.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
assertBookingStatus(booking, [
|
assertBookingStatus(booking, [
|
||||||
"AWAITING_DOCUMENTS",
|
"AWAITING_DOCUMENTS",
|
||||||
"DOCUMENTS_UNDER_REVIEW",
|
"DOCUMENTS_UNDER_REVIEW",
|
||||||
|
|||||||
@@ -252,8 +252,11 @@ export class BookingsController {
|
|||||||
return this.bookingsService.findAll(filter, companyId);
|
return this.bookingsService.findAll(filter, companyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Powers the customer-detail bookings tab, so `customers:view` reaches it too
|
||||||
|
// — otherwise a staffer granted only the customer permission gets a page whose
|
||||||
|
// tabs 403 individually.
|
||||||
@Get("by-company/:companyId/customer-view")
|
@Get("by-company/:companyId/customer-view")
|
||||||
@BookingView()
|
@BookingStaff([FREIGHT_PERMS.customers.view, FREIGHT_PERMS.bookings.view])
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "List bookings for a company (customer-view shape, backoffice)",
|
summary: "List bookings for a company (customer-view shape, backoffice)",
|
||||||
})
|
})
|
||||||
@@ -436,18 +439,26 @@ export class BookingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id/customer-truck-assignment/freight-order')
|
@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(
|
async customerTruckFreightOrder(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@CurrentUser() user: TCurrentUser,
|
@CurrentUser() user: TCurrentUser,
|
||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
|
@Query('copies') copies?: string,
|
||||||
) {
|
) {
|
||||||
const booking = await this.bookingsService.findById(id);
|
const booking = await this.bookingsService.findById(id);
|
||||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
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 } =
|
const { filename, buffer } =
|
||||||
await this.bookingsService.customerTruckFreightOrderCopies(id);
|
await this.bookingsService.customerTruckFreightOrderCopies(id, extraCopyIndexes);
|
||||||
res.setHeader('Content-Type', 'application/pdf');
|
res.setHeader('Content-Type', 'application/pdf');
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
res.send(buffer);
|
res.send(buffer);
|
||||||
|
|||||||
@@ -142,8 +142,21 @@ export class BookingsService {
|
|||||||
return this.findById(bookingId);
|
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(
|
async customerTruckFreightOrderCopies(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
|
extraCopyIndexes: number[] = [],
|
||||||
): Promise<{ filename: string; buffer: Buffer }> {
|
): Promise<{ filename: string; buffer: Buffer }> {
|
||||||
const booking = await this.findById(bookingId);
|
const booking = await this.findById(bookingId);
|
||||||
if (!booking.customerTruckAssignedAt) {
|
if (!booking.customerTruckAssignedAt) {
|
||||||
@@ -171,7 +184,12 @@ export class BookingsService {
|
|||||||
[bookingId],
|
[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
|
// Chromium when available; otherwise the styled tabular fallback (never the
|
||||||
// generic text dump — the freight order is an outward-facing gate document).
|
// generic text dump — the freight order is an outward-facing gate document).
|
||||||
const buffer = await this.pdfRender.htmlToPdfBuffer(html, {
|
const buffer = await this.pdfRender.htmlToPdfBuffer(html, {
|
||||||
@@ -268,6 +286,7 @@ export class BookingsService {
|
|||||||
arrivedAt: string | null;
|
arrivedAt: string | null;
|
||||||
containers: string | null;
|
containers: string | null;
|
||||||
}>,
|
}>,
|
||||||
|
extraCopies: string[] = [],
|
||||||
): string {
|
): string {
|
||||||
const esc = (v: unknown) => this.escapeHtml(String(v ?? '-'));
|
const esc = (v: unknown) => this.escapeHtml(String(v ?? '-'));
|
||||||
const assignedAt = booking.customerTruckAssignedAt
|
const assignedAt = booking.customerTruckAssignedAt
|
||||||
@@ -386,6 +405,7 @@ export class BookingsService {
|
|||||||
<body>
|
<body>
|
||||||
${copy('Copy 1: Port Operations Copy')}
|
${copy('Copy 1: Port Operations Copy')}
|
||||||
${copy('Copy 2: Gate Security & Carrier Copy')}
|
${copy('Copy 2: Gate Security & Carrier Copy')}
|
||||||
|
${extraCopies.map((label) => copy(label)).join('')}
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
@@ -422,6 +442,8 @@ export class BookingsService {
|
|||||||
isReefer?: boolean;
|
isReefer?: boolean;
|
||||||
isGovernment?: boolean;
|
isGovernment?: boolean;
|
||||||
shippingLineId?: string | null;
|
shippingLineId?: string | null;
|
||||||
|
originYardId?: string | null;
|
||||||
|
destinationYardId?: string | null;
|
||||||
bulkTons?: number;
|
bulkTons?: number;
|
||||||
containers: CreateBookingContainerDto[];
|
containers: CreateBookingContainerDto[];
|
||||||
}): Promise<BookingEvaluationInput> {
|
}): Promise<BookingEvaluationInput> {
|
||||||
@@ -467,6 +489,8 @@ export class BookingsService {
|
|||||||
isGovernment: dto.isGovernment ?? false,
|
isGovernment: dto.isGovernment ?? false,
|
||||||
allowConsolidation,
|
allowConsolidation,
|
||||||
shippingLineId: dto.shippingLineId,
|
shippingLineId: dto.shippingLineId,
|
||||||
|
originYardId: dto.originYardId ?? null,
|
||||||
|
destinationYardId: dto.destinationYardId ?? null,
|
||||||
totalWagons,
|
totalWagons,
|
||||||
bulkTons: dto.freightType === 'BULK' ? Number(dto.bulkTons ?? 0) : 0,
|
bulkTons: dto.freightType === 'BULK' ? Number(dto.bulkTons ?? 0) : 0,
|
||||||
containers,
|
containers,
|
||||||
@@ -788,6 +812,8 @@ export class BookingsService {
|
|||||||
isReefer: dto.isReefer,
|
isReefer: dto.isReefer,
|
||||||
isGovernment,
|
isGovernment,
|
||||||
shippingLineId: dto.shippingLineId,
|
shippingLineId: dto.shippingLineId,
|
||||||
|
originYardId: dto.originYardId,
|
||||||
|
destinationYardId: dto.destinationYardId,
|
||||||
bulkTons: dto.cargoTotalWeightVgm,
|
bulkTons: dto.cargoTotalWeightVgm,
|
||||||
containers,
|
containers,
|
||||||
});
|
});
|
||||||
@@ -998,6 +1024,8 @@ export class BookingsService {
|
|||||||
isHazardous: dto.isHazardous ?? existing.isHazardous,
|
isHazardous: dto.isHazardous ?? existing.isHazardous,
|
||||||
isReefer: dto.isReefer ?? existing.isReefer,
|
isReefer: dto.isReefer ?? existing.isReefer,
|
||||||
shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined,
|
shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined,
|
||||||
|
originYardId: dto.originYardId ?? existing.originYardId,
|
||||||
|
destinationYardId: dto.destinationYardId ?? existing.destinationYardId,
|
||||||
bulkTons: dto.cargoTotalWeightVgm ?? Number(existing.cargoTotalWeightVgm ?? 0),
|
bulkTons: dto.cargoTotalWeightVgm ?? Number(existing.cargoTotalWeightVgm ?? 0),
|
||||||
containers,
|
containers,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ export const BOOKING_STATUSES = [
|
|||||||
'CONTRACT_ACTIVE',
|
'CONTRACT_ACTIVE',
|
||||||
'CONTRACT_CLOSED',
|
'CONTRACT_CLOSED',
|
||||||
// Post counter-sign document-clearance gate (GL workflow).
|
// Post counter-sign document-clearance gate (GL workflow).
|
||||||
'AWAITING_CLEARANCE_PAYMENT', // clearance fee invoiced, unpaid — docs locked
|
|
||||||
'AWAITING_DOCUMENTS',
|
'AWAITING_DOCUMENTS',
|
||||||
'DOCUMENTS_UNDER_REVIEW',
|
'DOCUMENTS_UNDER_REVIEW',
|
||||||
'CLEARANCE_READY',
|
'CLEARANCE_READY',
|
||||||
@@ -521,10 +520,6 @@ export class Booking extends BaseEntity {
|
|||||||
@Column({ name: 'clearance_current_phase', type: 'varchar', length: 40, nullable: true })
|
@Column({ name: 'clearance_current_phase', type: 'varchar', length: 40, nullable: true })
|
||||||
clearanceCurrentPhase?: string | null;
|
clearanceCurrentPhase?: string | null;
|
||||||
|
|
||||||
/** When the prepaid customs clearance service fee settled (GENERAL + customs). */
|
|
||||||
@Column({ name: 'clearance_fee_paid_at', type: 'timestamptz', nullable: true })
|
|
||||||
clearanceFeePaidAt?: Date | null;
|
|
||||||
|
|
||||||
@Column({ name: 'duty_required', type: 'boolean', nullable: true })
|
@Column({ name: 'duty_required', type: 'boolean', nullable: true })
|
||||||
dutyRequired?: boolean | null;
|
dutyRequired?: boolean | null;
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { FleetManage, FleetView } from '../../common/booking-guards';
|
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||||
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
import { CreateCargoDto } from './dto/create-cargo.dto';
|
import { CreateCargoDto } from './dto/create-cargo.dto';
|
||||||
import { UpdateCargoDto } from './dto/update-cargo.dto';
|
import { UpdateCargoDto } from './dto/update-cargo.dto';
|
||||||
import { LoadCargoDto } from './dto/load-cargo.dto';
|
import { LoadCargoDto } from './dto/load-cargo.dto';
|
||||||
@@ -19,12 +20,12 @@ import { CargoesService } from './cargoes.service';
|
|||||||
|
|
||||||
@ApiTags('cargoes')
|
@ApiTags('cargoes')
|
||||||
@Controller('cargoes')
|
@Controller('cargoes')
|
||||||
@FleetView()
|
@FleetView(FREIGHT_PERMS.cargoes.view)
|
||||||
export class CargoesController {
|
export class CargoesController {
|
||||||
constructor(private readonly cargoesService: CargoesService) {}
|
constructor(private readonly cargoesService: CargoesService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@FleetManage()
|
@FleetManage(FREIGHT_PERMS.cargoes.create)
|
||||||
@ApiOperation({ summary: 'Create a new cargo' })
|
@ApiOperation({ summary: 'Create a new cargo' })
|
||||||
create(@Body() dto: CreateCargoDto) {
|
create(@Body() dto: CreateCargoDto) {
|
||||||
return this.cargoesService.create(dto);
|
return this.cargoesService.create(dto);
|
||||||
@@ -43,35 +44,35 @@ export class CargoesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@FleetManage()
|
@FleetManage(FREIGHT_PERMS.cargoes.update)
|
||||||
@ApiOperation({ summary: 'Update a cargo' })
|
@ApiOperation({ summary: 'Update a cargo' })
|
||||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCargoDto) {
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCargoDto) {
|
||||||
return this.cargoesService.update(id, dto);
|
return this.cargoesService.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@FleetManage()
|
@FleetManage(FREIGHT_PERMS.cargoes.delete)
|
||||||
@ApiOperation({ summary: 'Delete a cargo' })
|
@ApiOperation({ summary: 'Delete a cargo' })
|
||||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.cargoesService.remove(id);
|
return this.cargoesService.remove(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/load')
|
@Post(':id/load')
|
||||||
@FleetManage()
|
@FleetManage(FREIGHT_PERMS.cargoes.update)
|
||||||
@ApiOperation({ summary: 'Load cargo into a container' })
|
@ApiOperation({ summary: 'Load cargo into a container' })
|
||||||
load(@Param('id', ParseUUIDPipe) id: string, @Body() dto: LoadCargoDto) {
|
load(@Param('id', ParseUUIDPipe) id: string, @Body() dto: LoadCargoDto) {
|
||||||
return this.cargoesService.loadCargo(id, dto);
|
return this.cargoesService.loadCargo(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/unload')
|
@Post(':id/unload')
|
||||||
@FleetManage()
|
@FleetManage(FREIGHT_PERMS.cargoes.update)
|
||||||
@ApiOperation({ summary: 'Unload cargo from container' })
|
@ApiOperation({ summary: 'Unload cargo from container' })
|
||||||
unload(@Param('id', ParseUUIDPipe) id: string) {
|
unload(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.cargoesService.unloadCargo(id);
|
return this.cargoesService.unloadCargo(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/deliver')
|
@Post(':id/deliver')
|
||||||
@FleetManage()
|
@FleetManage(FREIGHT_PERMS.cargoes.update)
|
||||||
@ApiOperation({ summary: 'Mark cargo as delivered' })
|
@ApiOperation({ summary: 'Mark cargo as delivered' })
|
||||||
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto?: DeliverCargoDto) {
|
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto?: DeliverCargoDto) {
|
||||||
return this.cargoesService.deliverCargo(id, dto);
|
return this.cargoesService.deliverCargo(id, dto);
|
||||||
|
|||||||
@@ -11,13 +11,22 @@ import {
|
|||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
|
UseGuards,
|
||||||
UploadedFiles,
|
UploadedFiles,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { AnyFilesInterceptor } from "@nestjs/platform-express";
|
import { AnyFilesInterceptor } from "@nestjs/platform-express";
|
||||||
import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger";
|
import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger";
|
||||||
import { CurrentUser } from "@edr/api-common";
|
import { CurrentUser } from "@edr/api-common";
|
||||||
import { FreightAdmin } from "../../common/booking-guards";
|
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||||
|
import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard";
|
||||||
|
import { BookingStaff } from "../../common/booking-guards";
|
||||||
|
import {
|
||||||
|
assertFreightPermission,
|
||||||
|
hasFreightPermission,
|
||||||
|
} from "../../common/freight-permission.util";
|
||||||
|
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||||
import { FilesService } from "../files/files.service";
|
import { FilesService } from "../files/files.service";
|
||||||
import { CompaniesService } from "./companies.service";
|
import { CompaniesService } from "./companies.service";
|
||||||
import { CreateCompanyDto } from "./dto/create-company.dto";
|
import { CreateCompanyDto } from "./dto/create-company.dto";
|
||||||
@@ -59,6 +68,23 @@ interface CurrentIamUser {
|
|||||||
phoneNumber?: string;
|
phoneNumber?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which permission a status write needs. Approving/reactivating is a different
|
||||||
|
* authority from suspending, but both arrive on the same route with the target
|
||||||
|
* in the BODY — a route-level guard can't tell them apart, so the handlers
|
||||||
|
* assert against this map instead.
|
||||||
|
*
|
||||||
|
* Keyed by string so it serves both `CompanyStatus` and `ProfileStatus`
|
||||||
|
* (a superset: it adds `rejected`).
|
||||||
|
*/
|
||||||
|
const STATUS_PERM: Record<string, string> = {
|
||||||
|
active: FREIGHT_PERMS.customers.verify,
|
||||||
|
pending: FREIGHT_PERMS.customers.verify,
|
||||||
|
rejected: FREIGHT_PERMS.customers.verify,
|
||||||
|
suspended: FREIGHT_PERMS.customers.deactivate,
|
||||||
|
blacklisted: FREIGHT_PERMS.customers.deactivate,
|
||||||
|
};
|
||||||
|
|
||||||
@ApiTags("Companies")
|
@ApiTags("Companies")
|
||||||
@Controller("companies")
|
@Controller("companies")
|
||||||
export class CompaniesController {
|
export class CompaniesController {
|
||||||
@@ -410,7 +436,7 @@ export class CompaniesController {
|
|||||||
|
|
||||||
// Used by backoffice
|
// Used by backoffice
|
||||||
@Post()
|
@Post()
|
||||||
@FreightAdmin()
|
@BookingStaff(FREIGHT_PERMS.customers.create)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
"Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter)",
|
"Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter)",
|
||||||
@@ -421,12 +447,14 @@ export class CompaniesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get("stats")
|
@Get("stats")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.customers.view)
|
||||||
@ApiOperation({ summary: "Company counts by status (KPI strip)" })
|
@ApiOperation({ summary: "Company counts by status (KPI strip)" })
|
||||||
async getStats(): Promise<CompanyStatsResponseDto> {
|
async getStats(): Promise<CompanyStatsResponseDto> {
|
||||||
return this.companiesService.getCompanyStats();
|
return this.companiesService.getCompanyStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@BookingStaff(FREIGHT_PERMS.customers.view)
|
||||||
@ApiOperation({ summary: "List companies (paginated, filterable)" })
|
@ApiOperation({ summary: "List companies (paginated, filterable)" })
|
||||||
async findAll(
|
async findAll(
|
||||||
@Query() query: ListCompaniesQueryDto,
|
@Query() query: ListCompaniesQueryDto,
|
||||||
@@ -436,6 +464,7 @@ export class CompaniesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(":id")
|
@Get(":id")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.customers.view)
|
||||||
@ApiOperation({ summary: "Get company by ID" })
|
@ApiOperation({ summary: "Get company by ID" })
|
||||||
async findById(
|
async findById(
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@@ -446,30 +475,77 @@ export class CompaniesController {
|
|||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edits fields AND carries `status`, so it spans two authorities. The route
|
||||||
|
* guard is one-of (a status-only caller must get in); the asserts below are
|
||||||
|
* what actually authorize: touching `status` needs the permission
|
||||||
|
* {@link STATUS_PERM} maps it to, touching anything else needs
|
||||||
|
* `customers:update`. Both checks are required — without the second, a
|
||||||
|
* caller holding only `customers:deactivate` could rename the company.
|
||||||
|
*/
|
||||||
@Patch(":id")
|
@Patch(":id")
|
||||||
@FreightAdmin()
|
@BookingStaff([
|
||||||
|
FREIGHT_PERMS.customers.update,
|
||||||
|
FREIGHT_PERMS.customers.verify,
|
||||||
|
FREIGHT_PERMS.customers.deactivate,
|
||||||
|
])
|
||||||
@ApiOperation({ summary: "Update a company" })
|
@ApiOperation({ summary: "Update a company" })
|
||||||
async update(
|
async update(
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@Body() dto: UpdateCompanyDto,
|
@Body() dto: UpdateCompanyDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
): Promise<ResponseCompanyDto> {
|
): Promise<ResponseCompanyDto> {
|
||||||
|
const { status, ...fields } = dto;
|
||||||
|
if (status) assertFreightPermission(user, STATUS_PERM[status]);
|
||||||
|
if (Object.keys(fields).length > 0) {
|
||||||
|
assertFreightPermission(user, FREIGHT_PERMS.customers.update);
|
||||||
|
}
|
||||||
const company = await this.companiesService.updateCompany(id, dto);
|
const company = await this.companiesService.updateCompany(id, dto);
|
||||||
return new ResponseCompanyDto(company);
|
return new ResponseCompanyDto(company);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(":id")
|
@Delete(":id")
|
||||||
@FreightAdmin()
|
@BookingStaff(FREIGHT_PERMS.customers.deactivate)
|
||||||
@ApiOperation({ summary: "Soft-delete a company" })
|
@ApiOperation({ summary: "Soft-delete a company" })
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
async remove(@Param("id", ParseUUIDPipe) id: string): Promise<void> {
|
async remove(@Param("id", ParseUUIDPipe) id: string): Promise<void> {
|
||||||
await this.companiesService.deleteCompany(id);
|
await this.companiesService.deleteCompany(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dual-audience: staff read any customer's documents, and the portal reads
|
||||||
|
* its OWN during onboarding (`companiesService.getDocuments`). So the route
|
||||||
|
* is authenticated-only and the split happens here — same shape as
|
||||||
|
* `GET /contracts/:id`. Gating it on a staff permission alone would 403 every
|
||||||
|
* customer on their own documents.
|
||||||
|
*
|
||||||
|
* The staff arm is one-of because two pages consume it: the customer detail
|
||||||
|
* page (`customers:view`) and the contract-request detail page, whose route
|
||||||
|
* is gated on `contracts:view` — a contract reviewer without the customer
|
||||||
|
* permission still needs the applicant's documents.
|
||||||
|
*/
|
||||||
@Get(":companyId/documents")
|
@Get(":companyId/documents")
|
||||||
|
@UseGuards(JwtGuard)
|
||||||
@ApiOperation({ summary: "List documents uploaded for a company" })
|
@ApiOperation({ summary: "List documents uploaded for a company" })
|
||||||
async listDocuments(
|
async listDocuments(
|
||||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
) {
|
) {
|
||||||
|
const isStaff = [
|
||||||
|
FREIGHT_PERMS.customers.view,
|
||||||
|
FREIGHT_PERMS.contracts.view,
|
||||||
|
FREIGHT_PERMS.bookings.view,
|
||||||
|
].some((p) => hasFreightPermission(user, p));
|
||||||
|
|
||||||
|
if (!isStaff) {
|
||||||
|
const { company } = await this.companiesService.getCompanyInfoByUserId(
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
|
// Hidden as NotFound rather than Forbidden so company ids can't be probed.
|
||||||
|
if (company.id !== companyId) {
|
||||||
|
throw new NotFoundException(`Company ${companyId} not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
const files = await this.filesService.findByResource(companyId, "companies");
|
const files = await this.filesService.findByResource(companyId, "companies");
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
files.map(async (f) => ({
|
files.map(async (f) => ({
|
||||||
@@ -490,7 +566,7 @@ export class CompaniesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("documents/:fileId/request-change")
|
@Post("documents/:fileId/request-change")
|
||||||
@FreightAdmin()
|
@BookingStaff(FREIGHT_PERMS.customers.verify)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Ask the customer to correct one uploaded document",
|
summary: "Ask the customer to correct one uploaded document",
|
||||||
description:
|
description:
|
||||||
@@ -532,14 +608,23 @@ export class CompaniesController {
|
|||||||
return this.companiesService.uploadCompanyDocuments(companyId, files, user.id);
|
return this.companiesService.uploadCompanyDocuments(companyId, files, user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approve / reject / suspend / blacklist all arrive here with the target in
|
||||||
|
* the body, so authorization is per-status via {@link STATUS_PERM} rather
|
||||||
|
* than on the route (the guard is only the one-of gate).
|
||||||
|
*/
|
||||||
@Patch("company-profiles/:profileId/status")
|
@Patch("company-profiles/:profileId/status")
|
||||||
@FreightAdmin()
|
@BookingStaff([
|
||||||
|
FREIGHT_PERMS.customers.verify,
|
||||||
|
FREIGHT_PERMS.customers.deactivate,
|
||||||
|
])
|
||||||
@ApiOperation({ summary: "Update a company profile's approval status" })
|
@ApiOperation({ summary: "Update a company profile's approval status" })
|
||||||
async updateCompanyProfileStatus(
|
async updateCompanyProfileStatus(
|
||||||
@CurrentUser() user: CurrentIamUser,
|
@CurrentUser() user: TCurrentUser,
|
||||||
@Param("profileId", ParseUUIDPipe) profileId: string,
|
@Param("profileId", ParseUUIDPipe) profileId: string,
|
||||||
@Body() dto: UpdateCompanyProfileStatusDto,
|
@Body() dto: UpdateCompanyProfileStatusDto,
|
||||||
): Promise<ResponseCompanyProfileDto> {
|
): Promise<ResponseCompanyProfileDto> {
|
||||||
|
assertFreightPermission(user, STATUS_PERM[dto.status]);
|
||||||
const profile = await this.companiesService.setCompanyProfileStatus(
|
const profile = await this.companiesService.setCompanyProfileStatus(
|
||||||
profileId,
|
profileId,
|
||||||
dto.status,
|
dto.status,
|
||||||
@@ -550,7 +635,7 @@ export class CompaniesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(":companyId/change-requests")
|
@Get(":companyId/change-requests")
|
||||||
@FreightAdmin()
|
@BookingStaff(FREIGHT_PERMS.customers.view)
|
||||||
@ApiOperation({ summary: "List a company's profile change requests" })
|
@ApiOperation({ summary: "List a company's profile change requests" })
|
||||||
async listChangeRequests(
|
async listChangeRequests(
|
||||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||||
@@ -560,7 +645,7 @@ export class CompaniesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("change-requests/:id/approve")
|
@Post("change-requests/:id/approve")
|
||||||
@FreightAdmin()
|
@BookingStaff(FREIGHT_PERMS.customers.verify)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Approve a pending profile change request (applies the changes)",
|
summary: "Approve a pending profile change request (applies the changes)",
|
||||||
})
|
})
|
||||||
@@ -576,7 +661,7 @@ export class CompaniesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("change-requests/:id/reject")
|
@Post("change-requests/:id/reject")
|
||||||
@FreightAdmin()
|
@BookingStaff(FREIGHT_PERMS.customers.verify)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Reject a pending profile change request with a note",
|
summary: "Reject a pending profile change request with a note",
|
||||||
})
|
})
|
||||||
@@ -594,7 +679,7 @@ export class CompaniesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(":companyId/profiles")
|
@Post(":companyId/profiles")
|
||||||
@FreightAdmin()
|
@BookingStaff(FREIGHT_PERMS.customers.update)
|
||||||
@ApiOperation({ summary: "Add a profile (employee) to a company" })
|
@ApiOperation({ summary: "Add a profile (employee) to a company" })
|
||||||
async createProfile(
|
async createProfile(
|
||||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||||
@@ -608,6 +693,7 @@ export class CompaniesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(":companyId/profiles")
|
@Get(":companyId/profiles")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.customers.view)
|
||||||
@ApiOperation({ summary: "List profiles for a company" })
|
@ApiOperation({ summary: "List profiles for a company" })
|
||||||
async listProfiles(
|
async listProfiles(
|
||||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||||
@@ -618,6 +704,7 @@ export class CompaniesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get("profile/user/:userId")
|
@Get("profile/user/:userId")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.customers.view)
|
||||||
@ApiOperation({ summary: "Get profile by IAM user ID" })
|
@ApiOperation({ summary: "Get profile by IAM user ID" })
|
||||||
async findProfileByUser(
|
async findProfileByUser(
|
||||||
@Param("userId", ParseUUIDPipe) userId: string,
|
@Param("userId", ParseUUIDPipe) userId: string,
|
||||||
|
|||||||
@@ -10,18 +10,19 @@ import {
|
|||||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
|
||||||
import { FleetManage, FleetView } from "../../common/booking-guards";
|
import { FleetManage, FleetView } from "../../common/booking-guards";
|
||||||
|
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||||
import { ConsignmentsService } from "./consignments.service";
|
import { ConsignmentsService } from "./consignments.service";
|
||||||
import { CreateConsignmentDto } from "./dto/create-consignment.dto";
|
import { CreateConsignmentDto } from "./dto/create-consignment.dto";
|
||||||
import { FilterConsignmentDto } from "./dto/filter-consignment.dto";
|
import { FilterConsignmentDto } from "./dto/filter-consignment.dto";
|
||||||
|
|
||||||
@ApiTags("consignments")
|
@ApiTags("consignments")
|
||||||
@Controller("consignments")
|
@Controller("consignments")
|
||||||
@FleetView()
|
@FleetView(FREIGHT_PERMS.consignments.view)
|
||||||
export class ConsignmentsController {
|
export class ConsignmentsController {
|
||||||
constructor(private readonly consignmentsService: ConsignmentsService) {}
|
constructor(private readonly consignmentsService: ConsignmentsService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@FleetManage()
|
@FleetManage(FREIGHT_PERMS.consignments.create)
|
||||||
@ApiOperation({ summary: "Create a new consignment" })
|
@ApiOperation({ summary: "Create a new consignment" })
|
||||||
create(@Body() dto: CreateConsignmentDto) {
|
create(@Body() dto: CreateConsignmentDto) {
|
||||||
return this.consignmentsService.create(dto);
|
return this.consignmentsService.create(dto);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { FleetManage, FleetView } from '../../common/booking-guards';
|
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||||
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
import { CreateContainerDto } from './dto/create-container.dto';
|
import { CreateContainerDto } from './dto/create-container.dto';
|
||||||
import { UpdateContainerDto } from './dto/update-container.dto';
|
import { UpdateContainerDto } from './dto/update-container.dto';
|
||||||
import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto';
|
import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto';
|
||||||
@@ -18,12 +19,12 @@ import { ContainersService } from './containers.service';
|
|||||||
|
|
||||||
@ApiTags('containers')
|
@ApiTags('containers')
|
||||||
@Controller('containers')
|
@Controller('containers')
|
||||||
@FleetView()
|
@FleetView(FREIGHT_PERMS.containers.view)
|
||||||
export class ContainersController {
|
export class ContainersController {
|
||||||
constructor(private readonly containersService: ContainersService) {}
|
constructor(private readonly containersService: ContainersService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@FleetManage()
|
@FleetManage(FREIGHT_PERMS.containers.create)
|
||||||
@ApiOperation({ summary: 'Create a new container' })
|
@ApiOperation({ summary: 'Create a new container' })
|
||||||
create(@Body() dto: CreateContainerDto) {
|
create(@Body() dto: CreateContainerDto) {
|
||||||
return this.containersService.create(dto);
|
return this.containersService.create(dto);
|
||||||
@@ -42,28 +43,28 @@ export class ContainersController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@FleetManage()
|
@FleetManage(FREIGHT_PERMS.containers.update)
|
||||||
@ApiOperation({ summary: 'Update a container' })
|
@ApiOperation({ summary: 'Update a container' })
|
||||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContainerDto) {
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContainerDto) {
|
||||||
return this.containersService.update(id, dto);
|
return this.containersService.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@FleetManage()
|
@FleetManage(FREIGHT_PERMS.containers.delete)
|
||||||
@ApiOperation({ summary: 'Delete a container' })
|
@ApiOperation({ summary: 'Delete a container' })
|
||||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.containersService.remove(id);
|
return this.containersService.remove(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/assign-wagon')
|
@Post(':id/assign-wagon')
|
||||||
@FleetManage()
|
@FleetManage(FREIGHT_PERMS.containers.update)
|
||||||
@ApiOperation({ summary: 'Assign container to a wagon' })
|
@ApiOperation({ summary: 'Assign container to a wagon' })
|
||||||
assignToWagon(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignContainerToWagonDto) {
|
assignToWagon(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignContainerToWagonDto) {
|
||||||
return this.containersService.assignToWagon(id, dto);
|
return this.containersService.assignToWagon(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/unassign-wagon')
|
@Post(':id/unassign-wagon')
|
||||||
@FleetManage()
|
@FleetManage(FREIGHT_PERMS.containers.update)
|
||||||
@ApiOperation({ summary: 'Unassign container from wagon' })
|
@ApiOperation({ summary: 'Unassign container from wagon' })
|
||||||
unassignFromWagon(@Param('id', ParseUUIDPipe) id: string) {
|
unassignFromWagon(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.containersService.unassignFromWagon(id);
|
return this.containersService.unassignFromWagon(id);
|
||||||
|
|||||||
@@ -1,239 +0,0 @@
|
|||||||
import { Injectable, Logger, UnprocessableEntityException } from '@nestjs/common';
|
|
||||||
import { OnEvent } from '@nestjs/event-emitter';
|
|
||||||
import { Freight } from '@edr/types';
|
|
||||||
|
|
||||||
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
|
|
||||||
import { Invoice } from '../billing/entities/invoice.entity';
|
|
||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
|
||||||
import { ContractPricingBreakdown } from './contract-pricing.service';
|
|
||||||
import { ContractNotifierService } from './contract-notifier.service';
|
|
||||||
import { ContractsRepository } from './contracts.repository';
|
|
||||||
import { Contract } from './entities/contract.entity';
|
|
||||||
|
|
||||||
/** Invoice `type` for the contract-level fee (Path B ONE_TIME, after counter-sign). */
|
|
||||||
export const CLEARANCE_CONTRACT_INVOICE_TYPE = 'CLEARANCE_CONTRACT';
|
|
||||||
/** Invoice `type` for the per-shipment fee (Path B GENERAL, at shipment request). */
|
|
||||||
export const CLEARANCE_BOOKING_INVOICE_TYPE = 'CLEARANCE_BOOKING';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The prepaid customs clearance service fee (Path B) — the GL service charge,
|
|
||||||
* separate from both freight (booking invoice) and duty/tax (paid offline).
|
|
||||||
* Issued as its own `clearance`-source invoice and paid BEFORE the clearance
|
|
||||||
* document step opens and before GL touches the file:
|
|
||||||
* - ONE_TIME: once per contract, at staff counter-sign
|
|
||||||
* (AWAITING_CLEARANCE_PAYMENT → paid → AWAITING_CLEARANCE_DOCUMENTS);
|
|
||||||
* - GENERAL: once per shipment request, on the initiated booking instance
|
|
||||||
* (booking AWAITING_CLEARANCE_PAYMENT → paid → AWAITING_DOCUMENTS).
|
|
||||||
* The fee amount is the frozen CUSTOMS_CLEARANCE contract rate snapshot, so
|
|
||||||
* customers pay what their contract shows, not the live rate of the day.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class ClearanceFeeService {
|
|
||||||
private readonly logger = new Logger(ClearanceFeeService.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly billing: BillingService,
|
|
||||||
private readonly contractsRepository: ContractsRepository,
|
|
||||||
private readonly bookingsRepository: BookingsRepository,
|
|
||||||
private readonly notifier: ContractNotifierService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/** The frozen flat fee for a contract; falls back to the pricing breakdown. */
|
|
||||||
private async feeAmountOrNull(
|
|
||||||
contract: Contract,
|
|
||||||
): Promise<{ amount: number; currency: string } | null> {
|
|
||||||
const snapshots = await this.contractsRepository.findRateSnapshots(contract.id);
|
|
||||||
const snapshot = snapshots.find(
|
|
||||||
(s) => s.isClearance || s.rateCode === 'CUSTOMS_CLEARANCE',
|
|
||||||
);
|
|
||||||
if (snapshot && Number(snapshot.unitPrice) > 0) {
|
|
||||||
return { amount: Number(snapshot.unitPrice), currency: snapshot.currency };
|
|
||||||
}
|
|
||||||
const breakdown = contract.pricingBreakdown as ContractPricingBreakdown | null;
|
|
||||||
const line = breakdown?.lineItems?.find((l) => l.code === 'CUSTOMS_CLEARANCE');
|
|
||||||
if (line && Number(line.unitPrice) > 0) {
|
|
||||||
return { amount: Number(line.unitPrice), currency: breakdown!.currency };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async feeAmount(
|
|
||||||
contract: Contract,
|
|
||||||
): Promise<{ amount: number; currency: string }> {
|
|
||||||
const fee = await this.feeAmountOrNull(contract);
|
|
||||||
if (!fee) {
|
|
||||||
throw new UnprocessableEntityException(
|
|
||||||
`Contract ${contract.reference} has no frozen customs clearance fee — regenerate its price with a live CUSTOMS_CLEARANCE rate.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return fee;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the payment gate applies. Skipped for government/unlinked
|
|
||||||
* contracts (no company to bill — invoices require one, same rule the
|
|
||||||
* booking invoice applies) and for legacy customs contracts frozen before
|
|
||||||
* the fee existed (no CUSTOMS_CLEARANCE snapshot to bill from) — both keep
|
|
||||||
* the pre-fee flow instead of dead-ending.
|
|
||||||
*/
|
|
||||||
async gateApplies(contract: Contract): Promise<boolean> {
|
|
||||||
// Customs disabled → the prepay gate genuinely does not apply.
|
|
||||||
if (!contract.customsClearingEnabled) return false;
|
|
||||||
// No company to bill (government / unlinked) → the gate cannot raise an
|
|
||||||
// invoice, so it stays out of the flow (same rule the booking invoice uses).
|
|
||||||
if (!contract.companyId) return false;
|
|
||||||
// M26: customs IS enabled and billable. A missing frozen fee line must NOT
|
|
||||||
// silently waive the gate — that ships clearance for free. Hard-fail exactly
|
|
||||||
// as price generation does when no CUSTOMS_CLEARANCE rate is configured, so a
|
|
||||||
// missing fee blocks counter-sign / shipment instead of bypassing payment.
|
|
||||||
if ((await this.feeAmountOrNull(contract)) === null) {
|
|
||||||
throw new UnprocessableEntityException(
|
|
||||||
'No customs clearance service fee is configured. Ask the rates team to set a live CUSTOMS_CLEARANCE rate before submitting customs contracts.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Issue (idempotently) the ONE_TIME contract-level fee invoice. */
|
|
||||||
async issueForContract(contract: Contract): Promise<Invoice> {
|
|
||||||
const existing = await this.billing.findPayable(
|
|
||||||
Freight.InvoiceSource.Clearance,
|
|
||||||
contract.id,
|
|
||||||
CLEARANCE_CONTRACT_INVOICE_TYPE,
|
|
||||||
);
|
|
||||||
if (existing) return existing;
|
|
||||||
|
|
||||||
const { amount, currency } = await this.feeAmount(contract);
|
|
||||||
const invoice = await this.billing.generateInvoice({
|
|
||||||
source: Freight.InvoiceSource.Clearance,
|
|
||||||
sourceId: contract.id,
|
|
||||||
type: CLEARANCE_CONTRACT_INVOICE_TYPE,
|
|
||||||
companyId: contract.companyId!,
|
|
||||||
companyProfileId: contract.companyProfileId!,
|
|
||||||
currency,
|
|
||||||
lines: [
|
|
||||||
{
|
|
||||||
chargeType: 'CUSTOMS_CLEARANCE',
|
|
||||||
description: `Customs clearance service fee — contract ${contract.reference}`,
|
|
||||||
quantity: 1,
|
|
||||||
unitRate: amount,
|
|
||||||
amount,
|
|
||||||
currency,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
status: Freight.InvoiceStatus.Pending,
|
|
||||||
});
|
|
||||||
this.notifier.clearanceFeeDue(contract, amount, currency);
|
|
||||||
return invoice;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Issue (idempotently) the GENERAL per-shipment fee invoice on the booking. */
|
|
||||||
async issueForBooking(booking: Booking, contract: Contract): Promise<Invoice> {
|
|
||||||
const existing = await this.billing.findPayable(
|
|
||||||
Freight.InvoiceSource.Clearance,
|
|
||||||
booking.id,
|
|
||||||
CLEARANCE_BOOKING_INVOICE_TYPE,
|
|
||||||
);
|
|
||||||
if (existing) return existing;
|
|
||||||
|
|
||||||
const { amount, currency } = await this.feeAmount(contract);
|
|
||||||
const invoice = await this.billing.generateInvoice({
|
|
||||||
source: Freight.InvoiceSource.Clearance,
|
|
||||||
sourceId: booking.id,
|
|
||||||
type: CLEARANCE_BOOKING_INVOICE_TYPE,
|
|
||||||
companyId: booking.companyId ?? contract.companyId!,
|
|
||||||
companyProfileId: booking.companyProfileId ?? contract.companyProfileId!,
|
|
||||||
currency,
|
|
||||||
lines: [
|
|
||||||
{
|
|
||||||
chargeType: 'CUSTOMS_CLEARANCE',
|
|
||||||
description: `Customs clearance service fee — shipment ${booking.reference}`,
|
|
||||||
quantity: 1,
|
|
||||||
unitRate: amount,
|
|
||||||
amount,
|
|
||||||
currency,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
status: Freight.InvoiceStatus.Pending,
|
|
||||||
});
|
|
||||||
this.notifier.clearanceFeeDue(contract, amount, currency, booking.reference);
|
|
||||||
return invoice;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retire (idempotently) the unpaid contract-level fee invoice when the
|
|
||||||
* contract reaches a terminal state — a dead contract must not leave a
|
|
||||||
* payable clearance invoice open for the customer to settle. No-op when the
|
|
||||||
* fee was already paid or never invoiced (mirrors the booking cancel path,
|
|
||||||
* {@link BillingService.expirePayable}).
|
|
||||||
*/
|
|
||||||
async expireForContract(contractId: string): Promise<Invoice | null> {
|
|
||||||
return this.billing.expirePayable(
|
|
||||||
Freight.InvoiceSource.Clearance,
|
|
||||||
contractId,
|
|
||||||
CLEARANCE_CONTRACT_INVOICE_TYPE,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Settlement branch point for `clearance`-source invoices: unlock the
|
|
||||||
* document-upload step the fee was gating. Idempotent — a replayed event on
|
|
||||||
* an already-advanced contract/booking is a no-op.
|
|
||||||
*/
|
|
||||||
@OnEvent('clearance.invoice.paid')
|
|
||||||
async onClearanceInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
|
||||||
this.logger.log(
|
|
||||||
`clearance.invoice.paid (${payload.type}) for ${payload.sourceId} from ${payload.invoiceId}`,
|
|
||||||
);
|
|
||||||
switch (payload.type) {
|
|
||||||
case CLEARANCE_CONTRACT_INVOICE_TYPE:
|
|
||||||
await this.advanceContract(payload.sourceId);
|
|
||||||
break;
|
|
||||||
case CLEARANCE_BOOKING_INVOICE_TYPE:
|
|
||||||
await this.advanceBooking(payload.sourceId);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
this.logger.warn(
|
|
||||||
`Unhandled clearance invoice type "${payload.type}" paid (${payload.invoiceId})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async advanceContract(contractId: string): Promise<void> {
|
|
||||||
const contract = await this.contractsRepository.findById(contractId);
|
|
||||||
if (!contract) {
|
|
||||||
this.logger.warn(`Cannot advance unknown contract ${contractId} on clearance fee payment.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (contract.status !== 'AWAITING_CLEARANCE_PAYMENT') return;
|
|
||||||
|
|
||||||
await this.contractsRepository.update(contractId, {
|
|
||||||
status: 'AWAITING_CLEARANCE_DOCUMENTS',
|
|
||||||
clearanceStatus: 'AWAITING_DOCUMENTS',
|
|
||||||
clearanceFeePaidAt: new Date(),
|
|
||||||
} as never);
|
|
||||||
const updated = await this.contractsRepository.findByIdWithRelations(contractId);
|
|
||||||
if (updated) this.notifier.clearanceFeePaid(updated);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async advanceBooking(bookingId: string): Promise<void> {
|
|
||||||
const booking = await this.bookingsRepository.findById(bookingId);
|
|
||||||
if (!booking) {
|
|
||||||
this.logger.warn(`Cannot advance unknown booking ${bookingId} on clearance fee payment.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (booking.status !== 'AWAITING_CLEARANCE_PAYMENT') return;
|
|
||||||
|
|
||||||
await this.bookingsRepository.update(bookingId, {
|
|
||||||
status: 'AWAITING_DOCUMENTS',
|
|
||||||
clearanceFeePaidAt: new Date(),
|
|
||||||
} as never);
|
|
||||||
if (booking.contractId) {
|
|
||||||
const contract = await this.contractsRepository.findByIdWithRelations(
|
|
||||||
booking.contractId,
|
|
||||||
);
|
|
||||||
if (contract) this.notifier.clearanceFeePaid(contract, booking.reference);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -26,7 +26,6 @@ describe('ContractBookingService — quantity-cap completion', () => {
|
|||||||
{} as never, // milestoneService
|
{} as never, // milestoneService
|
||||||
{} as never, // workflowService
|
{} as never, // workflowService
|
||||||
{} as never, // invoiceService
|
{} as never, // invoiceService
|
||||||
{} as never, // clearanceFeeService
|
|
||||||
{ createdToStaff: jest.fn() } as never, // bookingNotifier
|
{ createdToStaff: jest.fn() } as never, // bookingNotifier
|
||||||
{} as never, // dataSource
|
{} as never, // dataSource
|
||||||
{} as never, // trainSchedulingService
|
{} as never, // trainSchedulingService
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
|
|||||||
milestoneService as never,
|
milestoneService as never,
|
||||||
{} as never, // workflowService
|
{} as never, // workflowService
|
||||||
invoiceService as never,
|
invoiceService as never,
|
||||||
{} as never, // clearanceFeeService
|
|
||||||
{ createdToStaff: jest.fn() } as never, // bookingNotifier
|
{ createdToStaff: jest.fn() } as never, // bookingNotifier
|
||||||
{} as never, // dataSource
|
{} as never, // dataSource
|
||||||
{} as never, // trainSchedulingService
|
{} as never, // trainSchedulingService
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ import { hasFreightPermission } from '../../common/freight-permission.util';
|
|||||||
import { Contract } from './entities/contract.entity';
|
import { Contract } from './entities/contract.entity';
|
||||||
import { ContractRoute } from './entities/contract-route.entity';
|
import { ContractRoute } from './entities/contract-route.entity';
|
||||||
import { ContractsRepository } from './contracts.repository';
|
import { ContractsRepository } from './contracts.repository';
|
||||||
import { ClearanceFeeService } from './clearance-fee.service';
|
|
||||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||||
import {
|
import {
|
||||||
@@ -97,7 +96,6 @@ export class ContractBookingService {
|
|||||||
private readonly milestoneService: ClearanceMilestoneService,
|
private readonly milestoneService: ClearanceMilestoneService,
|
||||||
private readonly workflowService: ClearanceWorkflowService,
|
private readonly workflowService: ClearanceWorkflowService,
|
||||||
private readonly invoiceService: BookingInvoiceService,
|
private readonly invoiceService: BookingInvoiceService,
|
||||||
private readonly clearanceFeeService: ClearanceFeeService,
|
|
||||||
private readonly bookingNotifier: BookingLifecycleNotifierService,
|
private readonly bookingNotifier: BookingLifecycleNotifierService,
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
@Inject(forwardRef(() => TrainSchedulingService))
|
@Inject(forwardRef(() => TrainSchedulingService))
|
||||||
@@ -289,6 +287,7 @@ export class ContractBookingService {
|
|||||||
tradeDirection: contract.tradeDirection,
|
tradeDirection: contract.tradeDirection,
|
||||||
freightType,
|
freightType,
|
||||||
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
||||||
|
cargoFreeText: dto.cargoFreeText?.trim() || null,
|
||||||
isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'),
|
isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'),
|
||||||
isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'),
|
isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'),
|
||||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||||
@@ -536,11 +535,8 @@ export class ContractBookingService {
|
|||||||
|
|
||||||
const route = await this.resolveRoute(contract, opts.contractRouteId);
|
const route = await this.resolveRoute(contract, opts.contractRouteId);
|
||||||
|
|
||||||
// Prepay gate: each shipment request owes its own flat clearance service
|
// No prepay gate: the clearance service fee is billed on the booking
|
||||||
// fee before the document step opens (the paid event advances the booking
|
// invoice at completion, so the document step opens immediately.
|
||||||
// to AWAITING_DOCUMENTS). Government/unlinked contracts skip the gate.
|
|
||||||
const feeGate = await this.clearanceFeeService.gateApplies(contract);
|
|
||||||
|
|
||||||
const booking = await insertWithGeneratedReference(
|
const booking = await insertWithGeneratedReference(
|
||||||
() => this.generateReference(),
|
() => this.generateReference(),
|
||||||
(reference) =>
|
(reference) =>
|
||||||
@@ -550,7 +546,7 @@ export class ContractBookingService {
|
|||||||
companyProfileId: contract.companyProfileId ?? null,
|
companyProfileId: contract.companyProfileId ?? null,
|
||||||
isGovernment: contract.isGovernment,
|
isGovernment: contract.isGovernment,
|
||||||
governmentInstitution: contract.governmentInstitution ?? null,
|
governmentInstitution: contract.governmentInstitution ?? null,
|
||||||
status: feeGate ? 'AWAITING_CLEARANCE_PAYMENT' : 'AWAITING_DOCUMENTS',
|
status: 'AWAITING_DOCUMENTS',
|
||||||
bookingType: 'ONE_TIME',
|
bookingType: 'ONE_TIME',
|
||||||
contractId: contract.id,
|
contractId: contract.id,
|
||||||
contractRouteId: route?.id ?? null,
|
contractRouteId: route?.id ?? null,
|
||||||
@@ -589,10 +585,6 @@ export class ContractBookingService {
|
|||||||
contract.tradeDirection,
|
contract.tradeDirection,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (feeGate) {
|
|
||||||
await this.clearanceFeeService.issueForBooking(booking, contract);
|
|
||||||
}
|
|
||||||
|
|
||||||
const created =
|
const created =
|
||||||
(await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking;
|
(await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking;
|
||||||
this.bookingNotifier.createdToStaff(created);
|
this.bookingNotifier.createdToStaff(created);
|
||||||
@@ -738,6 +730,7 @@ export class ContractBookingService {
|
|||||||
}
|
}
|
||||||
await this.bookingsRepository.update(booking.id, {
|
await this.bookingsRepository.update(booking.id, {
|
||||||
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
||||||
|
cargoFreeText: dto.cargoFreeText?.trim() || null,
|
||||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||||
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
|
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
|
||||||
} as never);
|
} as never);
|
||||||
|
|||||||
@@ -499,11 +499,6 @@ export class ContractClearanceService {
|
|||||||
files: Express.Multer.File[],
|
files: Express.Multer.File[],
|
||||||
): Promise<Contract> {
|
): Promise<Contract> {
|
||||||
const contract = await this.contractsService.findById(contractId);
|
const contract = await this.contractsService.findById(contractId);
|
||||||
if (contract.status === 'AWAITING_CLEARANCE_PAYMENT') {
|
|
||||||
throw new ConflictException(
|
|
||||||
'The customs clearance service fee has not been paid yet — pay it from the portal to unlock document upload.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
contract.status !== 'AWAITING_CLEARANCE_DOCUMENTS' &&
|
contract.status !== 'AWAITING_CLEARANCE_DOCUMENTS' &&
|
||||||
contract.status !== 'CLEARANCE_UNDER_REVIEW'
|
contract.status !== 'CLEARANCE_UNDER_REVIEW'
|
||||||
|
|||||||
@@ -178,26 +178,6 @@ export class ContractNotifierService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clearance service fee invoiced — customer must pay before document upload. */
|
|
||||||
clearanceFeeDue(c: Contract, amount: number, currency: string, shipmentRef?: string): void {
|
|
||||||
const scope = shipmentRef ? `shipment ${shipmentRef} under contract ${c.reference}` : `contract ${c.reference}`;
|
|
||||||
const msg =
|
|
||||||
`A customs clearance service fee of ${amount} ${currency} is due for ${scope}. ` +
|
|
||||||
`Please pay from the portal to unlock the clearance document upload.`;
|
|
||||||
void this.notifyContact(c, msg, 'CLEARANCE FEE DUE');
|
|
||||||
this.inApp(c, 'Clearance fee due', msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Clearance service fee settled — document upload is now open. */
|
|
||||||
clearanceFeePaid(c: Contract, shipmentRef?: string): void {
|
|
||||||
const scope = shipmentRef ? `shipment ${shipmentRef} under contract ${c.reference}` : `contract ${c.reference}`;
|
|
||||||
const msg =
|
|
||||||
`Your customs clearance service fee for ${scope} has been received. ` +
|
|
||||||
`You can now upload the clearance documents from the portal.`;
|
|
||||||
void this.notifyContact(c, msg, 'CLEARANCE FEE PAID');
|
|
||||||
this.inApp(c, 'Clearance fee paid', msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Clearance milestones needing customer action ──────────────────────────
|
// ── Clearance milestones needing customer action ──────────────────────────
|
||||||
|
|
||||||
/** GL advised duty & tax on the contract cycle — customer pays + uploads slip. */
|
/** GL advised duty & tax on the contract cycle — customer pays + uploads slip. */
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { Contract } from './entities/contract.entity';
|
|||||||
export interface ContractUnitRateLineItem {
|
export interface ContractUnitRateLineItem {
|
||||||
code: string;
|
code: string;
|
||||||
label: string;
|
label: string;
|
||||||
unit: 'per_container' | 'per_ton' | 'per_item' | 'per_km' | 'flat';
|
unit: 'per_container' | 'per_wagon' | 'per_ton' | 'per_item' | 'per_km' | 'flat';
|
||||||
unitPrice: number;
|
unitPrice: number;
|
||||||
containerSize?: string | null;
|
containerSize?: string | null;
|
||||||
conditionalOn?: string | null;
|
conditionalOn?: string | null;
|
||||||
@@ -37,8 +37,9 @@ function toContractUnit(rateUnit: string): ContractUnitRateLineItem['unit'] {
|
|||||||
return 'per_ton';
|
return 'per_ton';
|
||||||
case 'PER_KM':
|
case 'PER_KM':
|
||||||
return 'per_km';
|
return 'per_km';
|
||||||
case 'PER_CONTAINER':
|
|
||||||
case 'PER_WAGON':
|
case 'PER_WAGON':
|
||||||
|
return 'per_wagon';
|
||||||
|
case 'PER_CONTAINER':
|
||||||
return 'per_container';
|
return 'per_container';
|
||||||
default:
|
default:
|
||||||
return 'flat';
|
return 'flat';
|
||||||
@@ -188,49 +189,165 @@ export class ContractPricingService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Lashing / cargo securing — BULK only, shown when the contract's commodity
|
||||||
|
// needs lashing (cargoType.hasLashing). The commodity-scoped rate for the
|
||||||
|
// contract's direction wins over the commodity-wide catch-all; billed at
|
||||||
|
// booking on the live rate (per ton / per wagon), this line is display.
|
||||||
|
if (contract.freightType === 'BULK') {
|
||||||
|
const scope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
|
||||||
|
if (scope?.cargoType?.hasLashing) {
|
||||||
|
const onDirection = liveRates.filter(
|
||||||
|
(r) =>
|
||||||
|
r.trigger === 'LASHING' &&
|
||||||
|
r.currency === 'USD' &&
|
||||||
|
!r.containerTypeId &&
|
||||||
|
r.tradeDirection === contract.tradeDirection,
|
||||||
|
);
|
||||||
|
const lashing =
|
||||||
|
onDirection.find((r) => r.cargoTypeId === scope.cargoTypeId) ??
|
||||||
|
onDirection.find((r) => !r.cargoTypeId);
|
||||||
|
if (lashing && Number(lashing.rateValue) > 0) {
|
||||||
|
lineItems.push({
|
||||||
|
code: 'LASHING',
|
||||||
|
label: `Lashing / cargo securing (${scope.cargoType.cargoTypeName})`,
|
||||||
|
unit: toContractUnit(lashing.rateUnit),
|
||||||
|
unitPrice: convert(Number(lashing.rateValue)),
|
||||||
|
cargoTypeCode: scope.cargoType.code ?? null,
|
||||||
|
conditionalOn: 'has_lashing',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Empty-container return service — container contracts only, toggled on the
|
// Empty-container return service — container contracts only, toggled on the
|
||||||
// contract like hazard/reefer. Billed at booking per WITH_RETURN container.
|
// contract like hazard/reefer. Billed at booking per WITH_RETURN container.
|
||||||
if (
|
if (
|
||||||
contract.freightType === 'CONTAINER' &&
|
contract.freightType === 'CONTAINER' &&
|
||||||
contract.equipmentReturn === 'WITH_RETURN'
|
contract.equipmentReturn === 'WITH_RETURN'
|
||||||
) {
|
) {
|
||||||
const withReturn = liveRates.find(
|
// Return is sold per direction + route + container type (import-only) —
|
||||||
(r) => r.rateType === 'RETURN_SURCHARGE' && r.currency === 'USD',
|
// 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.
|
||||||
if (withReturn && Number(withReturn.rateValue) > 0) {
|
// ponytail: bookings bill the live route rate, not a frozen snapshot.
|
||||||
lineItems.push({
|
const route = [...(contract.routes ?? [])].sort(
|
||||||
code: 'RETURN_SURCHARGE',
|
(a, b) => a.sortOrder - b.sortOrder,
|
||||||
label: 'Empty container return',
|
)[0];
|
||||||
unit: toContractUnit(withReturn.rateUnit),
|
const onLeg = route
|
||||||
unitPrice: convert(Number(withReturn.rateValue)),
|
? liveRates.filter(
|
||||||
conditionalOn: 'with_return',
|
(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',
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Customs clearance service fee (Path B) — a FLAT prepaid fee, shown on the
|
// Customs clearance service fee (Path B) — billed on the booking invoice
|
||||||
// contract and billed via its own clearance invoice: after counter-sign for
|
// together with the freight. Sold per direction + route + cargo kind:
|
||||||
// ONE_TIME, per shipment request for GENERAL. Excluded from booking totals.
|
// container contracts freeze one fee line per contract size (each size's
|
||||||
// A customs contract may not proceed without a configured live rate.
|
// own container-type rate), bulk contracts freeze the route's bulk fee.
|
||||||
|
// A customs contract may not proceed without the fee(s) configured.
|
||||||
if (contract.customsClearingEnabled) {
|
if (contract.customsClearingEnabled) {
|
||||||
const clearance = liveRates.find(
|
// Strict, no route-less fallback.
|
||||||
(r) => r.rateType === 'CUSTOMS_CLEARANCE' && r.currency === 'USD',
|
// ponytail: multi-route contracts bill the first lane's fee; per-lane fees need per-route snapshots.
|
||||||
);
|
const route = [...(contract.routes ?? [])].sort(
|
||||||
if (!clearance || Number(clearance.rateValue) <= 0) {
|
(a, b) => a.sortOrder - b.sortOrder,
|
||||||
throw new UnprocessableEntityException(
|
)[0];
|
||||||
'No customs clearance service fee is configured. Ask the rates team to set a live CUSTOMS_CLEARANCE rate before submitting customs contracts.',
|
const onLeg = route
|
||||||
);
|
? liveRates.filter(
|
||||||
|
(r) =>
|
||||||
|
r.rateType === 'CUSTOMS_CLEARANCE' &&
|
||||||
|
r.currency === 'USD' &&
|
||||||
|
r.tradeDirection === contract.tradeDirection &&
|
||||||
|
r.originYardId === route.originYardId &&
|
||||||
|
r.destinationYardId === route.destinationYardId,
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
if (contract.freightType === 'CONTAINER') {
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
if (!rate || Number(rate.rateValue) <= 0) {
|
||||||
|
throw new UnprocessableEntityException(
|
||||||
|
`No customs clearance service fee is configured for ${size} containers on this direction and route. Ask the rates team to set a live CUSTOMS_CLEARANCE rate for this container type and origin → destination.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
lineItems.push({
|
||||||
|
// Distinct code per size so the frozen snapshots don't collide —
|
||||||
|
// booking pricing looks each size up by CUSTOMS_CLEARANCE_<FT>FT.
|
||||||
|
code: `CUSTOMS_CLEARANCE_${sizeFt}FT`,
|
||||||
|
label: `Customs clearance service (${size})`,
|
||||||
|
unit: toContractUnit(rate.rateUnit),
|
||||||
|
unitPrice: convert(Number(rate.rateValue)),
|
||||||
|
containerSize: size,
|
||||||
|
isClearance: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Bulk fee — the rate scoped to the contract's commodity wins; a
|
||||||
|
// commodity-less rate (legacy) is the catch-all fallback.
|
||||||
|
const scope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
|
||||||
|
const rate =
|
||||||
|
(scope?.cargoTypeId
|
||||||
|
? onLeg.find(
|
||||||
|
(r) => !r.containerTypeId && r.cargoTypeId === scope.cargoTypeId,
|
||||||
|
)
|
||||||
|
: undefined) ?? onLeg.find((r) => !r.containerTypeId && !r.cargoTypeId);
|
||||||
|
if (!rate || Number(rate.rateValue) <= 0) {
|
||||||
|
throw new UnprocessableEntityException(
|
||||||
|
'No bulk customs clearance service fee is configured for this cargo type on this direction and route. Ask the rates team to set a live bulk CUSTOMS_CLEARANCE rate for this commodity and origin → destination.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
lineItems.push({
|
||||||
|
code: 'CUSTOMS_CLEARANCE',
|
||||||
|
label: `Customs clearance service (${scope?.cargoType?.cargoTypeName ?? 'bulk'})`,
|
||||||
|
unit: toContractUnit(rate.rateUnit),
|
||||||
|
unitPrice: convert(Number(rate.rateValue)),
|
||||||
|
cargoTypeCode: scope?.cargoType?.code ?? null,
|
||||||
|
isClearance: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
lineItems.push({
|
|
||||||
code: 'CUSTOMS_CLEARANCE',
|
|
||||||
label:
|
|
||||||
contract.contractKind === 'GENERAL'
|
|
||||||
? 'Customs clearance service fee (per shipment request, prepaid)'
|
|
||||||
: 'Customs clearance service fee (prepaid)',
|
|
||||||
unit: toContractUnit(clearance.rateUnit),
|
|
||||||
unitPrice: convert(Number(clearance.rateValue)),
|
|
||||||
isClearance: true,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -18,7 +18,15 @@ import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
|||||||
import { ContractViewModel } from '../../contracts/contract-view-model.builder';
|
import { ContractViewModel } from '../../contracts/contract-view-model.builder';
|
||||||
import { MinioService } from '../minio/minio.service';
|
import { MinioService } from '../minio/minio.service';
|
||||||
import { FileRecord } from '../files/entities/file.entity';
|
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 { ContractDocumentHistoryService } from './contract-document-history.service';
|
||||||
import { ApprovalRulesService } from '../rule-engine/services/approval-rules.service';
|
import { ApprovalRulesService } from '../rule-engine/services/approval-rules.service';
|
||||||
import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
|
import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
|
||||||
@@ -28,7 +36,6 @@ import { SignaturesService } from '../signatures/signatures.service';
|
|||||||
import { OtpService } from '../otp/otp.service';
|
import { OtpService } from '../otp/otp.service';
|
||||||
import { ContractTemplatesService } from '../contract-templates/contract-templates.service';
|
import { ContractTemplatesService } from '../contract-templates/contract-templates.service';
|
||||||
import { ContractPricingService } from './contract-pricing.service';
|
import { ContractPricingService } from './contract-pricing.service';
|
||||||
import { ClearanceFeeService } from './clearance-fee.service';
|
|
||||||
import { ContractNotifierService } from './contract-notifier.service';
|
import { ContractNotifierService } from './contract-notifier.service';
|
||||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||||
import { ContractsRepository } from './contracts.repository';
|
import { ContractsRepository } from './contracts.repository';
|
||||||
@@ -154,7 +161,6 @@ export class ContractTransitionService {
|
|||||||
private readonly otpService: OtpService,
|
private readonly otpService: OtpService,
|
||||||
private readonly notifier: ContractNotifierService,
|
private readonly notifier: ContractNotifierService,
|
||||||
private readonly contractTemplates: ContractTemplatesService,
|
private readonly contractTemplates: ContractTemplatesService,
|
||||||
private readonly clearanceFeeService: ClearanceFeeService,
|
|
||||||
@InjectDataSource()
|
@InjectDataSource()
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
) {}
|
) {}
|
||||||
@@ -236,8 +242,15 @@ export class ContractTransitionService {
|
|||||||
actorId: string,
|
actorId: string,
|
||||||
validityDays: number,
|
validityDays: number,
|
||||||
documentSnapshot?: ContractDocumentSnapshotInput | null,
|
documentSnapshot?: ContractDocumentSnapshotInput | null,
|
||||||
|
user?: TCurrentUser | null,
|
||||||
): Promise<Contract> {
|
): Promise<Contract> {
|
||||||
const contract = await this.contractsService.findById(contractId);
|
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']);
|
assertContractStatus(contract, ['SUBMITTED']);
|
||||||
|
|
||||||
if (!Number.isInteger(validityDays) || validityDays < 1) {
|
if (!Number.isInteger(validityDays) || validityDays < 1) {
|
||||||
@@ -431,12 +444,11 @@ export class ContractTransitionService {
|
|||||||
if (!next) return false;
|
if (!next) return false;
|
||||||
if (!user) return false;
|
if (!user) return false;
|
||||||
|
|
||||||
try {
|
// Strict match: ONLY the approver whose turn it is (the next pending step's
|
||||||
assertCanApproveContractStep(user, next.requiredRole);
|
// role) may edit. Using the looser approve gate here let any approver who
|
||||||
return true;
|
// held a contract-approve permission keep the edit button after acting —
|
||||||
} catch {
|
// approval must hand edit rights to the next approver, not share them.
|
||||||
return false;
|
return canEditContractStep(user, next.requiredRole);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The role that currently holds editing rights, for UI messaging. */
|
/** The role that currently holds editing rights, for UI messaging. */
|
||||||
@@ -533,8 +545,13 @@ export class ContractTransitionService {
|
|||||||
contractId: string,
|
contractId: string,
|
||||||
note: string,
|
note: string,
|
||||||
actorId: string,
|
actorId: string,
|
||||||
|
user?: TCurrentUser | null,
|
||||||
): Promise<Contract> {
|
): Promise<Contract> {
|
||||||
const contract = await this.contractsService.findById(contractId);
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
assertFreightPermission(
|
||||||
|
user,
|
||||||
|
forFreightType(FREIGHT_PERMS.contracts.requestChanges, contract.freightType),
|
||||||
|
);
|
||||||
assertContractStatus(contract, ['SUBMITTED']);
|
assertContractStatus(contract, ['SUBMITTED']);
|
||||||
|
|
||||||
await this.contractsRepository.createReviewNote(
|
await this.contractsRepository.createReviewNote(
|
||||||
@@ -552,8 +569,17 @@ export class ContractTransitionService {
|
|||||||
return updated;
|
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);
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
assertFreightPermission(
|
||||||
|
user,
|
||||||
|
forFreightType(FREIGHT_PERMS.contracts.reject, contract.freightType),
|
||||||
|
);
|
||||||
assertContractStatus(contract, ['SUBMITTED', 'PENDING_APPROVAL']);
|
assertContractStatus(contract, ['SUBMITTED', 'PENDING_APPROVAL']);
|
||||||
|
|
||||||
await this.contractsRepository.createReviewNote(
|
await this.contractsRepository.createReviewNote(
|
||||||
@@ -563,10 +589,6 @@ export class ContractTransitionService {
|
|||||||
actorId,
|
actorId,
|
||||||
'STAFF',
|
'STAFF',
|
||||||
);
|
);
|
||||||
// Stop the open-invoice leak: a rejected contract must not leave a payable
|
|
||||||
// clearance fee invoice open. Mirror the booking cancel path (billing.expirePayable).
|
|
||||||
await this.clearanceFeeService.expireForContract(contractId);
|
|
||||||
|
|
||||||
await this.contractsRepository.update(contractId, {
|
await this.contractsRepository.update(contractId, {
|
||||||
status: 'REJECTED',
|
status: 'REJECTED',
|
||||||
} as never);
|
} as never);
|
||||||
@@ -626,10 +648,6 @@ export class ContractTransitionService {
|
|||||||
'STAFF',
|
'STAFF',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Stop the open-invoice leak: a rejected contract must not leave a payable
|
|
||||||
// clearance fee invoice open. Mirror the booking cancel path (billing.expirePayable).
|
|
||||||
await this.clearanceFeeService.expireForContract(contractId);
|
|
||||||
|
|
||||||
await this.contractsRepository.update(contractId, {
|
await this.contractsRepository.update(contractId, {
|
||||||
status: 'REJECTED',
|
status: 'REJECTED',
|
||||||
} as never);
|
} as never);
|
||||||
@@ -641,9 +659,9 @@ export class ContractTransitionService {
|
|||||||
/**
|
/**
|
||||||
* Internal send-back branch of rejectStep: return the contract to an earlier,
|
* Internal send-back branch of rejectStep: return the contract to an earlier,
|
||||||
* already-approved stage of the chain instead of rejecting it outright.
|
* already-approved stage of the chain instead of rejecting it outright.
|
||||||
* Deliberately NOT the terminal path: no clearance-fee expiry (the contract
|
* Deliberately NOT the terminal path: the contract is still alive and there
|
||||||
* is still alive) and no customer-facing REJECTION note — the trail is a
|
* is no customer-facing REJECTION note — the trail is a staff note plus a
|
||||||
* staff note plus a backoffice inbox ping.
|
* backoffice inbox ping.
|
||||||
*/
|
*/
|
||||||
private async sendBackToStep(
|
private async sendBackToStep(
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
@@ -1141,17 +1159,11 @@ export class ContractTransitionService {
|
|||||||
const cycleNumber = (contract.clearanceCycleNumber ?? 0) + 1;
|
const cycleNumber = (contract.clearanceCycleNumber ?? 0) + 1;
|
||||||
const cycle = await this.contractsRepository.openCycle(contractId, cycleNumber);
|
const cycle = await this.contractsRepository.openCycle(contractId, cycleNumber);
|
||||||
await this.milestoneService.seedPreBookingMilestones(contract, cycle.id);
|
await this.milestoneService.seedPreBookingMilestones(contract, cycle.id);
|
||||||
// Path B prepay gate: the customs clearance service fee is invoiced here
|
// No prepay gate: the customs clearance service fee (Path B) is billed on
|
||||||
// and must settle before the document step opens (the paid event advances
|
// the booking invoice together with the freight, so the document step
|
||||||
// to AWAITING_CLEARANCE_DOCUMENTS). Path A (self-clearance) has no GL fee.
|
// opens immediately.
|
||||||
if (await this.clearanceFeeService.gateApplies(contract)) {
|
updates.status = 'AWAITING_CLEARANCE_DOCUMENTS';
|
||||||
await this.clearanceFeeService.issueForContract(contract);
|
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
|
||||||
updates.status = 'AWAITING_CLEARANCE_PAYMENT';
|
|
||||||
updates.clearanceStatus = 'AWAITING_PAYMENT';
|
|
||||||
} else {
|
|
||||||
updates.status = 'AWAITING_CLEARANCE_DOCUMENTS';
|
|
||||||
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
|
|
||||||
}
|
|
||||||
updates.clearanceCycleNumber = cycleNumber;
|
updates.clearanceCycleNumber = cycleNumber;
|
||||||
} else {
|
} else {
|
||||||
// No contract-level clearance gate — DOMESTIC, or any GENERAL contract
|
// No contract-level clearance gate — DOMESTIC, or any GENERAL contract
|
||||||
|
|||||||
@@ -34,7 +34,11 @@ import {
|
|||||||
import { actorLabel } from '../warehouses/current-actor.util';
|
import { actorLabel } from '../warehouses/current-actor.util';
|
||||||
import { BookingStaff } from '../../common/booking-guards';
|
import { BookingStaff } from '../../common/booking-guards';
|
||||||
import { ContractDocumentHistoryService } from './contract-document-history.service';
|
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 {
|
import {
|
||||||
assertFreightPermission,
|
assertFreightPermission,
|
||||||
hasFreightPermission,
|
hasFreightPermission,
|
||||||
@@ -184,7 +188,10 @@ export class ContractsController {
|
|||||||
@CurrentUser() user: TCurrentUser,
|
@CurrentUser() user: TCurrentUser,
|
||||||
) {
|
) {
|
||||||
if (dto.isGovernment) {
|
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);
|
return this.contractsService.create(dto, files ?? [], user?.id);
|
||||||
}
|
}
|
||||||
@@ -337,23 +344,32 @@ export class ContractsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/staff/accept')
|
@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' })
|
@ApiOperation({ summary: 'Staff accept → set validity window + start approval chain' })
|
||||||
staffAccept(
|
staffAccept(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Body() dto: AcceptContractDto,
|
@Body() dto: AcceptContractDto,
|
||||||
@CurrentUser() user: AuthUserPayload,
|
@CurrentUser() user: TCurrentUser,
|
||||||
) {
|
) {
|
||||||
return this.transitionService.staffAccept(
|
return this.transitionService.staffAccept(
|
||||||
id,
|
id,
|
||||||
resolveAuthUserId(user),
|
resolveAuthUserId(user),
|
||||||
dto.validityDays,
|
dto.validityDays,
|
||||||
dto.documentSnapshot,
|
dto.documentSnapshot,
|
||||||
|
user,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Readable by anyone who may view the contract: the draft carries
|
||||||
|
// `editableByMe`, and the approval chain's approvers (identified by position
|
||||||
|
// type, not by staff_accept) must be able to fetch it to learn it is their
|
||||||
|
// turn. Gating this on staff_accept hid the edit dialog from every approver.
|
||||||
@Get(':id/document/draft')
|
@Get(':id/document/draft')
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.staffAccept)
|
@BookingStaff([
|
||||||
|
FREIGHT_PERMS.contracts.view,
|
||||||
|
...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept),
|
||||||
|
])
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
'Editable contract-document draft (this contract\'s snapshot, or the live template) for the accept/edit dialog',
|
'Editable contract-document draft (this contract\'s snapshot, or the live template) for the accept/edit dialog',
|
||||||
@@ -376,8 +392,15 @@ export class ContractsController {
|
|||||||
return this.documentHistory.list(id);
|
return this.documentHistory.list(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Coarse gate only. WHO may actually edit is turn-based, not a static
|
||||||
|
// permission, so `updateContractDocument` -> `assertDocumentEditable` is the
|
||||||
|
// real boundary: it admits only the approver whose step is currently pending
|
||||||
|
// (edit rights hand off down the chain on each approval).
|
||||||
@Put(':id/document/articles')
|
@Put(':id/document/articles')
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.staffAccept)
|
@BookingStaff([
|
||||||
|
FREIGHT_PERMS.contracts.view,
|
||||||
|
...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept),
|
||||||
|
])
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
'Edit this contract\'s document articles only (per-contract; never touches the six shared templates)',
|
'Edit this contract\'s document articles only (per-contract; never touches the six shared templates)',
|
||||||
@@ -396,29 +419,35 @@ export class ContractsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/staff/request-changes')
|
@Post(':id/staff/request-changes')
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.requestChanges)
|
@BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.requestChanges))
|
||||||
@ApiOperation({ summary: 'Staff return contract for customer updates' })
|
@ApiOperation({ summary: 'Staff return contract for customer updates' })
|
||||||
requestChanges(
|
requestChanges(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Body() dto: RequestChangesDto,
|
@Body() dto: RequestChangesDto,
|
||||||
@CurrentUser() user: AuthUserPayload,
|
@CurrentUser() user: TCurrentUser,
|
||||||
) {
|
) {
|
||||||
return this.transitionService.requestChanges(
|
return this.transitionService.requestChanges(
|
||||||
id,
|
id,
|
||||||
dto.note,
|
dto.note,
|
||||||
resolveAuthUserId(user),
|
resolveAuthUserId(user),
|
||||||
|
user,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/staff/reject')
|
@Post(':id/staff/reject')
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.reject)
|
@BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.reject))
|
||||||
@ApiOperation({ summary: 'Staff reject contract' })
|
@ApiOperation({ summary: 'Staff reject contract' })
|
||||||
reject(
|
reject(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Body() dto: RejectContractDto,
|
@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')
|
@Post(':id/approval-steps/:stepId/approve')
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import { ContractsController } from './contracts.controller';
|
|||||||
import { ContractsService } from './contracts.service';
|
import { ContractsService } from './contracts.service';
|
||||||
import { ContractsRepository } from './contracts.repository';
|
import { ContractsRepository } from './contracts.repository';
|
||||||
import { ContractPricingService } from './contract-pricing.service';
|
import { ContractPricingService } from './contract-pricing.service';
|
||||||
import { ClearanceFeeService } from './clearance-fee.service';
|
|
||||||
import { ContractNotifierService } from './contract-notifier.service';
|
import { ContractNotifierService } from './contract-notifier.service';
|
||||||
import { ContractTransitionService } from './contract-transition.service';
|
import { ContractTransitionService } from './contract-transition.service';
|
||||||
import { ContractClearanceService } from './contract-clearance.service';
|
import { ContractClearanceService } from './contract-clearance.service';
|
||||||
@@ -107,7 +106,6 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
|||||||
ContractsService,
|
ContractsService,
|
||||||
ContractsRepository,
|
ContractsRepository,
|
||||||
ContractPricingService,
|
ContractPricingService,
|
||||||
ClearanceFeeService,
|
|
||||||
ContractNotifierService,
|
ContractNotifierService,
|
||||||
ContractTransitionService,
|
ContractTransitionService,
|
||||||
ContractDocumentHistoryService,
|
ContractDocumentHistoryService,
|
||||||
|
|||||||
@@ -182,6 +182,13 @@ export class CreateBookingUnderContractDto {
|
|||||||
@Type(() => CreateBulkLineDto)
|
@Type(() => CreateBulkLineDto)
|
||||||
bulkLines?: CreateBulkLineDto[];
|
bulkLines?: CreateBulkLineDto[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'What the containers carry — captured per booking (container freight).',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
cargoFreeText?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ export class ContractRateSnapshot extends BaseEntity {
|
|||||||
conditionalOn?: string | null;
|
conditionalOn?: string | null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Customs clearance service fee line — billed up front via a clearance
|
* Customs clearance service fee line — billed on the booking invoice
|
||||||
* invoice, excluded from shipment booking totals.
|
* together with the freight (no separate prepaid clearance invoice).
|
||||||
*/
|
*/
|
||||||
@Column({ name: 'is_clearance', type: 'boolean', default: false })
|
@Column({ name: 'is_clearance', type: 'boolean', default: false })
|
||||||
isClearance!: boolean;
|
isClearance!: boolean;
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ export const CONTRACT_STATUSES = [
|
|||||||
'SIGNED_CUSTOMER',
|
'SIGNED_CUSTOMER',
|
||||||
'FULLY_EXECUTED',
|
'FULLY_EXECUTED',
|
||||||
'CONTRACT_ACTIVE',
|
'CONTRACT_ACTIVE',
|
||||||
'AWAITING_CLEARANCE_PAYMENT', // Path B — clearance fee invoiced, unpaid
|
|
||||||
'AWAITING_CLEARANCE_DOCUMENTS',
|
'AWAITING_CLEARANCE_DOCUMENTS',
|
||||||
'CLEARANCE_UNDER_REVIEW',
|
'CLEARANCE_UNDER_REVIEW',
|
||||||
'CLEARANCE_READY_FOR_BOOKING',
|
'CLEARANCE_READY_FOR_BOOKING',
|
||||||
@@ -85,7 +84,6 @@ export type ContractKindValue = (typeof CONTRACT_KINDS)[number];
|
|||||||
|
|
||||||
export const CONTRACT_CLEARANCE_STATUSES = [
|
export const CONTRACT_CLEARANCE_STATUSES = [
|
||||||
'NOT_APPLICABLE',
|
'NOT_APPLICABLE',
|
||||||
'AWAITING_PAYMENT', // Path B — clearance service fee must be paid first
|
|
||||||
'AWAITING_DOCUMENTS',
|
'AWAITING_DOCUMENTS',
|
||||||
'DOCUMENTS_UNDER_REVIEW',
|
'DOCUMENTS_UNDER_REVIEW',
|
||||||
'CLEARANCE_READY_FOR_BOOKING', // Path B — GL may create the booking
|
'CLEARANCE_READY_FOR_BOOKING', // Path B — GL may create the booking
|
||||||
@@ -217,10 +215,6 @@ export class Contract extends BaseEntity {
|
|||||||
@Column({ name: 'clearance_cycle_number', type: 'int', default: 0 })
|
@Column({ name: 'clearance_cycle_number', type: 'int', default: 0 })
|
||||||
clearanceCycleNumber!: number;
|
clearanceCycleNumber!: number;
|
||||||
|
|
||||||
/** When the prepaid customs clearance service fee settled (Path B ONE_TIME). */
|
|
||||||
@Column({ name: 'clearance_fee_paid_at', type: 'timestamptz', nullable: true })
|
|
||||||
clearanceFeePaidAt?: Date | null;
|
|
||||||
|
|
||||||
@Column({ name: 'pricing_breakdown', type: 'jsonb', nullable: true })
|
@Column({ name: 'pricing_breakdown', type: 'jsonb', nullable: true })
|
||||||
pricingBreakdown?: Record<string, unknown> | null;
|
pricingBreakdown?: Record<string, unknown> | null;
|
||||||
|
|
||||||
|
|||||||
@@ -54,11 +54,4 @@ export class InterchangeDocumentsController {
|
|||||||
dispute(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DisputeInterchangeDocumentDto) {
|
dispute(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DisputeInterchangeDocumentDto) {
|
||||||
return this.service.dispute(id, dto);
|
return this.service.dispute(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/cancel')
|
|
||||||
@BookingStaff(FREIGHT_PERMS.interchangeDocuments.cancel)
|
|
||||||
@ApiOperation({ summary: 'Cancel a draft/generated interchange document' })
|
|
||||||
cancel(@Param('id', ParseUUIDPipe) id: string) {
|
|
||||||
return this.service.cancel(id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -203,11 +203,12 @@ export class InterchangeDocumentsService {
|
|||||||
|
|
||||||
async dispute(id: string, dto: DisputeInterchangeDocumentDto): Promise<InterchangeDocument> {
|
async dispute(id: string, dto: DisputeInterchangeDocumentDto): Promise<InterchangeDocument> {
|
||||||
const document = await this.findOne(id);
|
const document = await this.findOne(id);
|
||||||
// A dispute can only be raised on a live handover — a GENERATED or already
|
// A dispute can only be raised BEFORE the handover is acknowledged — an
|
||||||
// ACKNOWLEDGED document. CANCELLED and already-DISPUTED are terminal here.
|
// acknowledged document is settled. DISPUTED itself is terminal and
|
||||||
if (!['GENERATED', 'ACKNOWLEDGED'].includes(document.status)) {
|
// read-only: the registered dispute cannot be re-raised or overwritten.
|
||||||
|
if (document.status !== 'GENERATED') {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Interchange document in ${document.status} status cannot be disputed (must be GENERATED or ACKNOWLEDGED)`,
|
`Interchange document in ${document.status} status cannot be disputed (must be GENERATED — an acknowledged handover is settled, a registered dispute is read-only)`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await this.dataSource.getRepository(InterchangeDocument).update(id, {
|
await this.dataSource.getRepository(InterchangeDocument).update(id, {
|
||||||
@@ -217,15 +218,6 @@ export class InterchangeDocumentsService {
|
|||||||
return this.findOne(id);
|
return this.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async cancel(id: string): Promise<InterchangeDocument> {
|
|
||||||
const document = await this.findOne(id);
|
|
||||||
if (!['DRAFT', 'GENERATED'].includes(document.status)) {
|
|
||||||
throw new BadRequestException(`Interchange document ${document.status} cannot be cancelled`);
|
|
||||||
}
|
|
||||||
await this.dataSource.getRepository(InterchangeDocument).update(id, { status: 'CANCELLED' });
|
|
||||||
return this.findOne(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getScheduleSnapshot(scheduleId: string): Promise<ScheduleSnapshot> {
|
private async getScheduleSnapshot(scheduleId: string): Promise<ScheduleSnapshot> {
|
||||||
const [schedule] = await this.dataSource.query(
|
const [schedule] = await this.dataSource.query(
|
||||||
`SELECT ts.id,
|
`SELECT ts.id,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
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 { CreateLocomotiveDto } from './dto/create-locomotive.dto';
|
||||||
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
|
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
|
||||||
import { UpdateLocomotiveDto } from './dto/update-locomotive.dto';
|
import { UpdateLocomotiveDto } from './dto/update-locomotive.dto';
|
||||||
@@ -9,39 +10,43 @@ import { LocomotivesService } from './locomotives.service';
|
|||||||
|
|
||||||
@ApiTags('locomotives')
|
@ApiTags('locomotives')
|
||||||
@ApiBearerAuth()
|
@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')
|
@Controller('locomotives')
|
||||||
@FleetView()
|
|
||||||
export class LocomotivesController {
|
export class LocomotivesController {
|
||||||
constructor(private readonly locomotivesService: LocomotivesService) {}
|
constructor(private readonly locomotivesService: LocomotivesService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@StaffReference()
|
||||||
@ApiOperation({ summary: 'List locomotives' })
|
@ApiOperation({ summary: 'List locomotives' })
|
||||||
findAll(@Query() filter: FilterLocomotivesDto) {
|
findAll(@Query() filter: FilterLocomotivesDto) {
|
||||||
return this.locomotivesService.findAll(filter);
|
return this.locomotivesService.findAll(filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@StaffReference()
|
||||||
@ApiOperation({ summary: 'Get a locomotive by ID' })
|
@ApiOperation({ summary: 'Get a locomotive by ID' })
|
||||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.locomotivesService.findById(id);
|
return this.locomotivesService.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@FleetManage()
|
@FleetManage(FREIGHT_PERMS.locomotives.create)
|
||||||
@ApiOperation({ summary: 'Create a locomotive' })
|
@ApiOperation({ summary: 'Create a locomotive' })
|
||||||
create(@Body() dto: CreateLocomotiveDto) {
|
create(@Body() dto: CreateLocomotiveDto) {
|
||||||
return this.locomotivesService.create(dto);
|
return this.locomotivesService.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@FleetManage()
|
@FleetManage(FREIGHT_PERMS.locomotives.update)
|
||||||
@ApiOperation({ summary: 'Update a locomotive' })
|
@ApiOperation({ summary: 'Update a locomotive' })
|
||||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLocomotiveDto) {
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLocomotiveDto) {
|
||||||
return this.locomotivesService.update(id, dto);
|
return this.locomotivesService.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/decommission')
|
@Post(':id/decommission')
|
||||||
@FleetManage()
|
@FleetManage(FREIGHT_PERMS.locomotives.delete)
|
||||||
@ApiOperation({ summary: 'Decommission a locomotive' })
|
@ApiOperation({ summary: 'Decommission a locomotive' })
|
||||||
decommission(@Param('id', ParseUUIDPipe) id: string) {
|
decommission(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.locomotivesService.decommission(id);
|
return this.locomotivesService.decommission(id);
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ export class CreateMaintenanceScheduleDto {
|
|||||||
@IsEnum(MaintenanceType)
|
@IsEnum(MaintenanceType)
|
||||||
maintenanceType!: MaintenanceType;
|
maintenanceType!: MaintenanceType;
|
||||||
|
|
||||||
|
/** What is serviced — matched against the interval for auto-scheduling. */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
serviceItem?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
description!: string;
|
description!: string;
|
||||||
|
|
||||||
@@ -81,7 +86,37 @@ export class UpdateMaintenanceScheduleDto {
|
|||||||
@IsNumber()
|
@IsNumber()
|
||||||
actualCost?: number;
|
actualCost?: number;
|
||||||
|
|
||||||
|
/** Odometer at completion — drives KM-based auto-scheduling of the next service. */
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
odometerReading?: number;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
notes?: string;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -30,6 +30,10 @@ export class MaintenanceSchedule extends BaseEntity {
|
|||||||
@Column({ name: 'maintenance_type', type: 'varchar' })
|
@Column({ name: 'maintenance_type', type: 'varchar' })
|
||||||
maintenanceType!: MaintenanceType;
|
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' })
|
@Column({ name: 'description' })
|
||||||
description!: string;
|
description!: string;
|
||||||
|
|
||||||
@@ -62,4 +66,8 @@ export class MaintenanceSchedule extends BaseEntity {
|
|||||||
|
|
||||||
@Column({ name: 'next_due_date', type: 'timestamptz', nullable: true })
|
@Column({ name: 'next_due_date', type: 'timestamptz', nullable: true })
|
||||||
nextDueDate?: Date;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,12 @@ import { BookingStaff } from '../../common/booking-guards';
|
|||||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
import { MaintenanceService } from './maintenance.service';
|
import { MaintenanceService } from './maintenance.service';
|
||||||
import { MaintenanceDepthService } from './maintenance-depth.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 {
|
import {
|
||||||
CreateWorkOrderDto,
|
CreateWorkOrderDto,
|
||||||
UpdateWorkOrderDto,
|
UpdateWorkOrderDto,
|
||||||
@@ -44,6 +49,34 @@ export class MaintenanceController {
|
|||||||
return this.maintenanceService.updateMaintenanceSchedule(id, dto);
|
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')
|
@Get('upcoming/:vehicleId')
|
||||||
@BookingStaff(FREIGHT_PERMS.maintenance.view)
|
@BookingStaff(FREIGHT_PERMS.maintenance.view)
|
||||||
@ApiOperation({ summary: 'Get upcoming maintenance' })
|
@ApiOperation({ summary: 'Get upcoming maintenance' })
|
||||||
|
|||||||
@@ -2,25 +2,37 @@ import { Module } from '@nestjs/common';
|
|||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { MaintenanceSchedule } from './entities/maintenance-schedule.entity';
|
import { MaintenanceSchedule } from './entities/maintenance-schedule.entity';
|
||||||
import { MaintenanceCost } from './entities/maintenance-cost.entity';
|
import { MaintenanceCost } from './entities/maintenance-cost.entity';
|
||||||
|
import { MaintenanceInterval } from './entities/maintenance-interval.entity';
|
||||||
import { WorkOrder } from './entities/work-order.entity';
|
import { WorkOrder } from './entities/work-order.entity';
|
||||||
import { Part } from './entities/part.entity';
|
import { Part } from './entities/part.entity';
|
||||||
import { Warranty } from './entities/warranty.entity';
|
import { Warranty } from './entities/warranty.entity';
|
||||||
import { MaintenanceService } from './maintenance.service';
|
import { MaintenanceService } from './maintenance.service';
|
||||||
import { MaintenanceDepthService } from './maintenance-depth.service';
|
import { MaintenanceDepthService } from './maintenance-depth.service';
|
||||||
import { MaintenanceRepository } from './maintenance.repository';
|
import { MaintenanceRepository } from './maintenance.repository';
|
||||||
|
import { MaintenanceIntervalRepository } from './maintenance-interval.repository';
|
||||||
import { WorkOrderRepository } from './work-order.repository';
|
import { WorkOrderRepository } from './work-order.repository';
|
||||||
import { PartRepository } from './part.repository';
|
import { PartRepository } from './part.repository';
|
||||||
import { WarrantyRepository } from './warranty.repository';
|
import { WarrantyRepository } from './warranty.repository';
|
||||||
import { MaintenanceController } from './maintenance.controller';
|
import { MaintenanceController } from './maintenance.controller';
|
||||||
|
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([MaintenanceSchedule, MaintenanceCost, WorkOrder, Part, Warranty]),
|
TypeOrmModule.forFeature([
|
||||||
|
MaintenanceSchedule,
|
||||||
|
MaintenanceCost,
|
||||||
|
MaintenanceInterval,
|
||||||
|
WorkOrder,
|
||||||
|
Part,
|
||||||
|
Warranty,
|
||||||
|
]),
|
||||||
|
NotificationInboxModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
MaintenanceService,
|
MaintenanceService,
|
||||||
MaintenanceDepthService,
|
MaintenanceDepthService,
|
||||||
MaintenanceRepository,
|
MaintenanceRepository,
|
||||||
|
MaintenanceIntervalRepository,
|
||||||
WorkOrderRepository,
|
WorkOrderRepository,
|
||||||
PartRepository,
|
PartRepository,
|
||||||
WarrantyRepository,
|
WarrantyRepository,
|
||||||
|
|||||||
@@ -47,4 +47,109 @@ export class MaintenanceRepository extends BaseRepository<MaintenanceSchedule> {
|
|||||||
.getRawOne();
|
.getRawOne();
|
||||||
return result?.total || 0;
|
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)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,28 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
|
import { NotificationAudience, NotificationType } from '@edr/types';
|
||||||
import { DataSource, Repository } from 'typeorm';
|
import { DataSource, Repository } from 'typeorm';
|
||||||
import { MaintenanceRepository } from './maintenance.repository';
|
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 { MaintenanceCost } from './entities/maintenance-cost.entity';
|
||||||
import { Vehicle, VehicleAvailability, VehicleStatus } from '../vehicles/entities/vehicle.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()
|
@Injectable()
|
||||||
export class MaintenanceService {
|
export class MaintenanceService {
|
||||||
|
private readonly logger = new Logger(MaintenanceService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly maintenanceRepository: MaintenanceRepository,
|
private readonly maintenanceRepository: MaintenanceRepository,
|
||||||
|
private readonly intervalRepository: MaintenanceIntervalRepository,
|
||||||
@InjectRepository(MaintenanceSchedule)
|
@InjectRepository(MaintenanceSchedule)
|
||||||
private readonly scheduleRepository: Repository<MaintenanceSchedule>,
|
private readonly scheduleRepository: Repository<MaintenanceSchedule>,
|
||||||
@InjectRepository(MaintenanceCost)
|
@InjectRepository(MaintenanceCost)
|
||||||
@@ -18,8 +30,44 @@ export class MaintenanceService {
|
|||||||
// Vehicle isn't registered in this module's TypeOrmModule.forFeature, so we
|
// Vehicle isn't registered in this module's TypeOrmModule.forFeature, so we
|
||||||
// reach it through the global DataSource rather than @InjectRepository.
|
// reach it through the global DataSource rather than @InjectRepository.
|
||||||
private readonly dataSource: DataSource,
|
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
|
* Reflect a maintenance schedule's lifecycle on the target vehicle. A vehicle
|
||||||
* under maintenance is taken out of service (MAINTENANCE + BUSY); once the
|
* under maintenance is taken out of service (MAINTENANCE + BUSY); once the
|
||||||
@@ -64,6 +112,10 @@ export class MaintenanceService {
|
|||||||
id: string,
|
id: string,
|
||||||
dto: UpdateMaintenanceScheduleDto,
|
dto: UpdateMaintenanceScheduleDto,
|
||||||
): Promise<MaintenanceSchedule> {
|
): 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, {
|
await this.scheduleRepository.update(id, {
|
||||||
...dto,
|
...dto,
|
||||||
completedDate: dto.completedDate ? new Date(dto.completedDate) : undefined,
|
completedDate: dto.completedDate ? new Date(dto.completedDate) : undefined,
|
||||||
@@ -78,6 +130,15 @@ export class MaintenanceService {
|
|||||||
) {
|
) {
|
||||||
// Maintenance finished/aborted → vehicle back in service.
|
// Maintenance finished/aborted → vehicle back in service.
|
||||||
await this.setVehicleMaintenanceState(updated.vehicleId, false);
|
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) {
|
} else if (dto.status === MaintenanceStatus.IN_PROGRESS) {
|
||||||
// Maintenance started → keep the vehicle out of service.
|
// Maintenance started → keep the vehicle out of service.
|
||||||
await this.setVehicleMaintenanceState(updated.vehicleId, true);
|
await this.setVehicleMaintenanceState(updated.vehicleId, true);
|
||||||
@@ -87,6 +148,83 @@ export class MaintenanceService {
|
|||||||
return updated!;
|
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) {
|
async getUpcomingMaintenance(vehicleId: string) {
|
||||||
return this.maintenanceRepository.getUpcomingMaintenance(vehicleId);
|
return this.maintenanceRepository.getUpcomingMaintenance(vehicleId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import {
|
|||||||
} from "@nestjs/swagger";
|
} from "@nestjs/swagger";
|
||||||
import { Response } from "express";
|
import { Response } from "express";
|
||||||
import { Public } from "@edr/api-common";
|
import { Public } from "@edr/api-common";
|
||||||
import { BookingView } from "../../common/booking-guards";
|
import { BookingStaff, BookingView } from "../../common/booking-guards";
|
||||||
|
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||||
import { PaymentService } from "./payment.service";
|
import { PaymentService } from "./payment.service";
|
||||||
import { IntentStatusDto } from "./payments.dto";
|
import { IntentStatusDto } from "./payments.dto";
|
||||||
|
|
||||||
@@ -25,7 +26,9 @@ import { IntentStatusDto } from "./payments.dto";
|
|||||||
export class PaymentController {
|
export class PaymentController {
|
||||||
constructor(private readonly paymentService: PaymentService) { }
|
constructor(private readonly paymentService: PaymentService) { }
|
||||||
|
|
||||||
|
// Customer-detail payments tab — same one-of rule as the bookings tab.
|
||||||
@Get("by-company/:companyId/customer-view")
|
@Get("by-company/:companyId/customer-view")
|
||||||
|
@BookingStaff([FREIGHT_PERMS.customers.view, FREIGHT_PERMS.payments.view])
|
||||||
@ApiOperation({ summary: "List payments for a company (customer-view shape, backoffice)" })
|
@ApiOperation({ summary: "List payments for a company (customer-view shape, backoffice)" })
|
||||||
findByCompanyCustomerView(
|
findByCompanyCustomerView(
|
||||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query
|
|||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
import { FleetManage, FleetView } from '../../common/booking-guards';
|
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||||
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
import { CreateRouteDto } from './dto/create-route.dto';
|
import { CreateRouteDto } from './dto/create-route.dto';
|
||||||
import { FilterRoutesDto } from './dto/filter-routes.dto';
|
import { FilterRoutesDto } from './dto/filter-routes.dto';
|
||||||
import { UpdateRouteDto } from './dto/update-route.dto';
|
import { UpdateRouteDto } from './dto/update-route.dto';
|
||||||
@@ -10,7 +11,7 @@ import { RoutesService } from './routes.service';
|
|||||||
@ApiTags('routes')
|
@ApiTags('routes')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('routes')
|
@Controller('routes')
|
||||||
@FleetView()
|
@FleetView(FREIGHT_PERMS.routes.view)
|
||||||
export class RoutesController {
|
export class RoutesController {
|
||||||
constructor(private readonly routesService: RoutesService) {}
|
constructor(private readonly routesService: RoutesService) {}
|
||||||
|
|
||||||
@@ -27,21 +28,21 @@ export class RoutesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@FleetManage()
|
@FleetManage(FREIGHT_PERMS.routes.create)
|
||||||
@ApiOperation({ summary: 'Create route' })
|
@ApiOperation({ summary: 'Create route' })
|
||||||
create(@Body() dto: CreateRouteDto) {
|
create(@Body() dto: CreateRouteDto) {
|
||||||
return this.routesService.create(dto);
|
return this.routesService.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@FleetManage()
|
@FleetManage(FREIGHT_PERMS.routes.update)
|
||||||
@ApiOperation({ summary: 'Update route' })
|
@ApiOperation({ summary: 'Update route' })
|
||||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateRouteDto) {
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateRouteDto) {
|
||||||
return this.routesService.update(id, dto);
|
return this.routesService.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@FleetManage()
|
@FleetManage(FREIGHT_PERMS.routes.delete)
|
||||||
@ApiOperation({ summary: 'Deactivate route' })
|
@ApiOperation({ summary: 'Deactivate route' })
|
||||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.routesService.deactivate(id);
|
return this.routesService.deactivate(id);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const;
|
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const;
|
||||||
const CURRENCIES = ['USD'] as const;
|
const CURRENCIES = ['USD'] as const;
|
||||||
export const INTERCITY_KINDS = ['CONTAINER', 'BULK'] as const;
|
export const INTERCITY_KINDS = ['CONTAINER', 'BULK'] as const;
|
||||||
|
export const CARGO_KINDS = ['CONTAINER', 'BULK'] as const;
|
||||||
|
|
||||||
export class CreateRateDto {
|
export class CreateRateDto {
|
||||||
@ApiProperty({ enum: RATE_APPLIES_TO, description: 'Friendly category the rate applies to' })
|
@ApiProperty({ enum: RATE_APPLIES_TO, description: 'Friendly category the rate applies to' })
|
||||||
@@ -47,6 +48,15 @@ export class CreateRateDto {
|
|||||||
@IsIn([...INTERCITY_KINDS])
|
@IsIn([...INTERCITY_KINDS])
|
||||||
intercityKind?: string;
|
intercityKind?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: CARGO_KINDS,
|
||||||
|
description:
|
||||||
|
'Whether a customs clearance rate covers containers or bulk. Required when trigger = CUSTOMS_CLEARANCE. Not stored — container fees carry a containerTypeId, bulk fees none.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn([...CARGO_KINDS])
|
||||||
|
cargoKind?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description:
|
description:
|
||||||
'FK to yards.id — origin of the leg this rate prices. Required for base freight (bulk/container/intercity), rejected for surcharges and first/last mile.',
|
'FK to yards.id — origin of the leg this rate prices. Required for base freight (bulk/container/intercity), rejected for surcharges and first/last mile.',
|
||||||
|
|||||||
@@ -125,6 +125,22 @@ export class ListRatesQueryDto extends PaginationQueryDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(50)
|
@MaxLength(50)
|
||||||
rateType?: string;
|
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 {
|
export class ListWeightLimitRulesQueryDto extends PaginationQueryDto {
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import type { RateAppliesTo, RateTrigger, RateUnit } from './rate.entity';
|
|||||||
export function allowedRateUnits(input: {
|
export function allowedRateUnits(input: {
|
||||||
appliesTo: RateAppliesTo;
|
appliesTo: RateAppliesTo;
|
||||||
trigger: RateTrigger;
|
trigger: RateTrigger;
|
||||||
|
/** CUSTOMS_CLEARANCE only: which cargo kind the fee covers. */
|
||||||
|
cargoKind?: 'CONTAINER' | 'BULK' | null;
|
||||||
}): RateUnit[] {
|
}): RateUnit[] {
|
||||||
const { appliesTo, trigger } = input;
|
const { appliesTo, trigger } = input;
|
||||||
|
|
||||||
@@ -29,16 +31,20 @@ export function allowedRateUnits(input: {
|
|||||||
case 'DEMURRAGE':
|
case 'DEMURRAGE':
|
||||||
return ['PER_CONTAINER', 'PER_TON'];
|
return ['PER_CONTAINER', 'PER_TON'];
|
||||||
case 'WITH_RETURN':
|
case 'WITH_RETURN':
|
||||||
// Container-only empty-return service — bills per returned container.
|
// Container-only empty-return service — per returned container, per
|
||||||
return ['PER_CONTAINER', 'FLAT'];
|
// wagon the empties ride back on, or a flat fee.
|
||||||
|
return ['PER_CONTAINER', 'PER_WAGON', 'FLAT'];
|
||||||
case 'CANCELLATION':
|
case 'CANCELLATION':
|
||||||
return ['FLAT', 'PER_INVOICE'];
|
return ['FLAT', 'PER_INVOICE'];
|
||||||
case 'CUSTOMS_CLEARANCE':
|
case 'CUSTOMS_CLEARANCE':
|
||||||
// Flat per clearance (ONE_TIME contract) / per shipment request (GENERAL).
|
// Sold per cargo kind: container fees bill per box or per wagon, bulk
|
||||||
return ['FLAT'];
|
// fees per ton or per wagon. Billed on the booking invoice.
|
||||||
|
return input.cargoKind === 'BULK'
|
||||||
|
? ['PER_TON', 'PER_WAGON']
|
||||||
|
: ['PER_CONTAINER', 'PER_WAGON'];
|
||||||
case 'LASHING':
|
case 'LASHING':
|
||||||
// Flat cargo-securing fee, billed once per booking.
|
// Bulk-only cargo securing — per ton or per wagon.
|
||||||
return ['FLAT'];
|
return ['PER_TON', 'PER_WAGON'];
|
||||||
case 'CONSOLIDATION':
|
case 'CONSOLIDATION':
|
||||||
return ['PER_CONTAINER', 'FLAT'];
|
return ['PER_CONTAINER', 'FLAT'];
|
||||||
case 'SHIPPING_LINE':
|
case 'SHIPPING_LINE':
|
||||||
@@ -74,6 +80,7 @@ export function defaultRateUnit(input: { appliesTo: RateAppliesTo; trigger: Rate
|
|||||||
export function isRateUnitAllowed(input: {
|
export function isRateUnitAllowed(input: {
|
||||||
appliesTo: RateAppliesTo;
|
appliesTo: RateAppliesTo;
|
||||||
trigger: RateTrigger;
|
trigger: RateTrigger;
|
||||||
|
cargoKind?: 'CONTAINER' | 'BULK' | null;
|
||||||
unit: RateUnit;
|
unit: RateUnit;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
return allowedRateUnits(input).includes(input.unit);
|
return allowedRateUnits(input).includes(input.unit);
|
||||||
|
|||||||
@@ -117,6 +117,21 @@ export class RatesRepository implements IRatesRepository {
|
|||||||
if (query.rateType) {
|
if (query.rateType) {
|
||||||
qb.andWhere('rate.rateType = :rateType', { rateType: 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) {
|
if (query.search) {
|
||||||
qb.andWhere(
|
qb.andWhere(
|
||||||
'(rate.rateType ILIKE :search OR rate.status ILIKE :search OR rate.rateUnit ILIKE :search OR rate.currency ILIKE :search)',
|
'(rate.rateType ILIKE :search OR rate.status ILIKE :search OR rate.rateUnit ILIKE :search OR rate.currency ILIKE :search)',
|
||||||
|
|||||||
@@ -76,3 +76,349 @@ describe('RuleEngineService — requested service without a configured surcharge
|
|||||||
expect(result.hardBlocked[0]).toContain('reefer');
|
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('PER_WAGON bills the wagons the empties ride back on, not the boxes', async () => {
|
||||||
|
// Same service, but the return rate is sold per wagon: 4× 20ft return =
|
||||||
|
// 2 wagons (two 20ft share a wagon) × 20 USD, not 4 × 20.
|
||||||
|
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, rateUnit: 'PER_WAGON' } as Rate]),
|
||||||
|
} as never,
|
||||||
|
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.evaluate(
|
||||||
|
returnInput({
|
||||||
|
containers: [
|
||||||
|
{
|
||||||
|
containerTypeId: 'ct-20',
|
||||||
|
quantity: 4,
|
||||||
|
vgmPerUnitTons: 10,
|
||||||
|
totalVgmTons: 40,
|
||||||
|
returnQuantity: 4,
|
||||||
|
wagonsPerUnit: 0.5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const ret = result.appliedModifiers.filter((m) => m.surchargeCode === 'RETURN_SURCHARGE');
|
||||||
|
expect(ret).toHaveLength(1);
|
||||||
|
expect(ret[0].triggerValue).toBe(2);
|
||||||
|
expect(ret[0].calculatedAmount).toBe(40);
|
||||||
|
expect(ret[0].billingUnit).toBe('PER_WAGON');
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RuleEngineService — lashing (bulk-only, per direction + commodity)', () => {
|
||||||
|
const lashingBulkImport: Rate = {
|
||||||
|
id: 'rate-lash-bulk',
|
||||||
|
rateType: 'LASHING',
|
||||||
|
trigger: 'LASHING',
|
||||||
|
rateValue: 2,
|
||||||
|
rateUnit: 'PER_TON',
|
||||||
|
currency: 'USD',
|
||||||
|
status: 'LIVE',
|
||||||
|
containerTypeId: null,
|
||||||
|
cargoTypeId: null,
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
originYardId: null,
|
||||||
|
destinationYardId: null,
|
||||||
|
} as Rate;
|
||||||
|
|
||||||
|
const buildService = (rates: Rate[]): RuleEngineService =>
|
||||||
|
new RuleEngineService(
|
||||||
|
{
|
||||||
|
findById: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ hasLashing: true, requiresDirectorApproval: false }),
|
||||||
|
} as never,
|
||||||
|
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||||
|
{ findActiveByContainerTypeId: jest.fn().mockResolvedValue([]) } as never,
|
||||||
|
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
|
||||||
|
{ findLiveRates: jest.fn().mockResolvedValue(rates) } as never,
|
||||||
|
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
const bulkInput = (overrides: Partial<BookingEvaluationInput> = {}): BookingEvaluationInput => ({
|
||||||
|
serviceTypeId: 'svc-1',
|
||||||
|
paymentCurrency: 'USD',
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
isHazardous: false,
|
||||||
|
cargoTypeId: 'cargo-sugar',
|
||||||
|
totalWagons: 0,
|
||||||
|
bulkTons: 100,
|
||||||
|
bulkWagons: 3,
|
||||||
|
containers: [],
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const lashingMods = (result: Awaited<ReturnType<RuleEngineService['evaluate']>>) =>
|
||||||
|
result.appliedModifiers.filter((m) => m.surchargeCode === 'LASHING');
|
||||||
|
|
||||||
|
it('bulk lashing bills per ton on the direction-matched rate', async () => {
|
||||||
|
const result = await buildService([lashingBulkImport]).evaluate(bulkInput());
|
||||||
|
const mods = lashingMods(result);
|
||||||
|
expect(mods).toHaveLength(1);
|
||||||
|
expect(mods[0].triggerValue).toBe(100);
|
||||||
|
expect(mods[0].calculatedAmount).toBe(200);
|
||||||
|
expect(mods[0].billingUnit).toBe('PER_TON');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a rate for the other direction never bills', async () => {
|
||||||
|
const result = await buildService([
|
||||||
|
{ ...lashingBulkImport, tradeDirection: 'EXPORT' } as Rate,
|
||||||
|
]).evaluate(bulkInput());
|
||||||
|
expect(lashingMods(result)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PER_WAGON bulk lashing bills the wagons the bulk occupies', async () => {
|
||||||
|
const result = await buildService([
|
||||||
|
{ ...lashingBulkImport, rateUnit: 'PER_WAGON', rateValue: 25 } as Rate,
|
||||||
|
]).evaluate(bulkInput());
|
||||||
|
const mods = lashingMods(result);
|
||||||
|
expect(mods[0].triggerValue).toBe(3);
|
||||||
|
expect(mods[0].calculatedAmount).toBe(75);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the commodity-scoped rate wins over the commodity-wide catch-all', async () => {
|
||||||
|
const result = await buildService([
|
||||||
|
lashingBulkImport,
|
||||||
|
{ ...lashingBulkImport, id: 'rate-lash-sugar', rateValue: 7, cargoTypeId: 'cargo-sugar' } as Rate,
|
||||||
|
]).evaluate(bulkInput());
|
||||||
|
const mods = lashingMods(result);
|
||||||
|
expect(mods).toHaveLength(1);
|
||||||
|
expect(mods[0].unitPriceUsd).toBe(7);
|
||||||
|
expect(mods[0].calculatedAmount).toBe(700);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('container bookings never incur lashing (bulk-only service)', async () => {
|
||||||
|
const result = await buildService([lashingBulkImport]).evaluate(
|
||||||
|
bulkInput({
|
||||||
|
cargoTypeId: null,
|
||||||
|
hasLashing: true,
|
||||||
|
containers: [
|
||||||
|
{ containerTypeId: 'ct-20', quantity: 4, vgmPerUnitTons: 10, totalVgmTons: 40 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(lashingMods(result)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no lashing charge when the cargo does not need lashing', async () => {
|
||||||
|
const service = new RuleEngineService(
|
||||||
|
{
|
||||||
|
findById: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ hasLashing: false, requiresDirectorApproval: false }),
|
||||||
|
} as never,
|
||||||
|
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||||
|
{ findActiveByContainerTypeId: jest.fn().mockResolvedValue([]) } as never,
|
||||||
|
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
|
||||||
|
{ findLiveRates: jest.fn().mockResolvedValue([lashingBulkImport]) } as never,
|
||||||
|
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
const result = await service.evaluate(bulkInput());
|
||||||
|
expect(lashingMods(result)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ export interface BookingContainerEvalInput {
|
|||||||
hazardousQuantity?: number;
|
hazardousQuantity?: number;
|
||||||
reeferQuantity?: number;
|
reeferQuantity?: number;
|
||||||
returnQuantity?: number;
|
returnQuantity?: number;
|
||||||
|
/**
|
||||||
|
* Wagon fraction one container of this line occupies (40ft = 1, 20ft = 0.5).
|
||||||
|
* Lets a PER_WAGON empty-return rate bill the wagons the returned empties
|
||||||
|
* ride back on. Missing ⇒ one wagon per container.
|
||||||
|
*/
|
||||||
|
wagonsPerUnit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BookingEvaluationInput {
|
export interface BookingEvaluationInput {
|
||||||
@@ -67,6 +73,12 @@ export interface BookingEvaluationInput {
|
|||||||
isGovernment?: boolean;
|
isGovernment?: boolean;
|
||||||
allowConsolidation?: boolean;
|
allowConsolidation?: boolean;
|
||||||
shippingLineId?: string | null;
|
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
|
* Booking's cargo type needs EDR-provided lashing/securing (cargoType
|
||||||
* hasLashing = true). Fires the flat LASHING surcharge. Resolved by the
|
* hasLashing = true). Fires the flat LASHING surcharge. Resolved by the
|
||||||
@@ -80,6 +92,12 @@ export interface BookingEvaluationInput {
|
|||||||
* container freight, which is scaled by container count instead.
|
* container freight, which is scaled by container count instead.
|
||||||
*/
|
*/
|
||||||
bulkTons?: number;
|
bulkTons?: number;
|
||||||
|
/**
|
||||||
|
* Wagons a BULK booking occupies (ceil(tons ÷ wagon capacity)), resolved by
|
||||||
|
* the pricing service. Scales PER_WAGON kind-scoped surcharges (lashing);
|
||||||
|
* 0/undefined when unknown — those charges then bill nothing.
|
||||||
|
*/
|
||||||
|
bulkWagons?: number;
|
||||||
containers: BookingContainerEvalInput[];
|
containers: BookingContainerEvalInput[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +109,15 @@ export interface AppliedCargoModifier {
|
|||||||
triggerValue: number | null;
|
triggerValue: number | null;
|
||||||
calculatedAmount: number;
|
calculatedAmount: number;
|
||||||
currency: string;
|
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 {
|
export interface ContainerWeightResult {
|
||||||
@@ -165,12 +192,16 @@ export class RuleEngineService {
|
|||||||
...(await this.capacityViolations(input.containers, input.tradeDirection)),
|
...(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) {
|
for (const container of input.containers) {
|
||||||
const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId(
|
const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId(
|
||||||
container.containerTypeId,
|
container.containerTypeId,
|
||||||
input.tradeDirection,
|
input.tradeDirection,
|
||||||
);
|
);
|
||||||
const rule = rules[0];
|
const rule = rules[0];
|
||||||
|
lineMaxVgmTons.push(rule ? Number(rule.maxVgmTons) : null);
|
||||||
let isOverweight = container.isOverweight ?? false;
|
let isOverweight = container.isOverweight ?? false;
|
||||||
let excess = container.overweightExcessTons ?? null;
|
let excess = container.overweightExcessTons ?? null;
|
||||||
|
|
||||||
@@ -273,13 +304,6 @@ export class RuleEngineService {
|
|||||||
input.containers.some((c) => Number(c.reeferQuantity ?? 0) > 0),
|
input.containers.some((c) => Number(c.reeferQuantity ?? 0) > 0),
|
||||||
label: 'refrigerated (reefer) cargo',
|
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) {
|
for (const svc of requestedServices) {
|
||||||
if (svc.wanted && !surchargeRates.some((r) => r.trigger === svc.trigger)) {
|
if (svc.wanted && !surchargeRates.some((r) => r.trigger === svc.trigger)) {
|
||||||
@@ -292,6 +316,17 @@ export class RuleEngineService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const rate of surchargeRates) {
|
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;
|
||||||
|
// Lashing is sold per cargo kind + container type — billed by the
|
||||||
|
// kind-aware block below, never by this generic loop.
|
||||||
|
if (rate.trigger === 'LASHING') continue;
|
||||||
const triggered = this.matchesTrigger(rate.trigger, {
|
const triggered = this.matchesTrigger(rate.trigger, {
|
||||||
isHazardous: input.isHazardous,
|
isHazardous: input.isHazardous,
|
||||||
hasReefer,
|
hasReefer,
|
||||||
@@ -384,6 +419,25 @@ 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);
|
||||||
|
|
||||||
|
if (hasLashing) {
|
||||||
|
appliedModifiers.push(...this.lashingCharges(input, liveRates));
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
priorityScore,
|
priorityScore,
|
||||||
appliedModifiers,
|
appliedModifiers,
|
||||||
@@ -394,6 +448,186 @@ 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);
|
||||||
|
// PER_WAGON bills the wagons the returned empties occupy (two 20ft share
|
||||||
|
// one wagon), PER_CONTAINER the boxes themselves, FLAT once per line.
|
||||||
|
const billed =
|
||||||
|
rate.rateUnit === 'PER_WAGON'
|
||||||
|
? Math.ceil(qty * (container.wagonsPerUnit ?? 1))
|
||||||
|
: qty;
|
||||||
|
const amount = rate.rateUnit === 'FLAT' ? rateValue : billed * rateValue;
|
||||||
|
if (!(amount > 0)) continue;
|
||||||
|
modifiers.push({
|
||||||
|
rateId: rate.id,
|
||||||
|
surchargeCode: this.surchargeCode(rate),
|
||||||
|
triggerValue: rate.rateUnit === 'FLAT' ? qty : billed,
|
||||||
|
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)] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cargo securing / lashing — BULK only, sold per trade direction, optionally
|
||||||
|
* narrowed to one leaf commodity (the commodity-scoped rate wins over the
|
||||||
|
* commodity-wide catch-all). Bills PER_TON × tonnage or PER_WAGON × the
|
||||||
|
* wagons the bulk occupies. Container bookings never incur lashing, and an
|
||||||
|
* unconfigured rate simply bills nothing — same leniency as hazard/reefer.
|
||||||
|
*/
|
||||||
|
private lashingCharges(
|
||||||
|
input: BookingEvaluationInput,
|
||||||
|
liveRates: Rate[],
|
||||||
|
): AppliedCargoModifier[] {
|
||||||
|
const modifiers: AppliedCargoModifier[] = [];
|
||||||
|
if (input.containers.length > 0) return modifiers; // bulk-only service
|
||||||
|
|
||||||
|
const onDirection = liveRates.filter(
|
||||||
|
(r) =>
|
||||||
|
r.trigger === 'LASHING' &&
|
||||||
|
r.currency === 'USD' &&
|
||||||
|
!r.containerTypeId &&
|
||||||
|
r.tradeDirection === input.tradeDirection,
|
||||||
|
);
|
||||||
|
const rate =
|
||||||
|
(input.cargoTypeId
|
||||||
|
? onDirection.find((r) => r.cargoTypeId === input.cargoTypeId)
|
||||||
|
: undefined) ?? onDirection.find((r) => !r.cargoTypeId);
|
||||||
|
if (!rate) return modifiers;
|
||||||
|
|
||||||
|
const billedQty =
|
||||||
|
rate.rateUnit === 'PER_TON'
|
||||||
|
? Math.max(0, Number(input.bulkTons ?? 0))
|
||||||
|
: rate.rateUnit === 'PER_WAGON'
|
||||||
|
? Math.max(0, Number(input.bulkWagons ?? 0))
|
||||||
|
: 1;
|
||||||
|
const rateValue = Number(rate.rateValue);
|
||||||
|
const amount = rate.rateUnit === 'FLAT' ? rateValue : billedQty * rateValue;
|
||||||
|
if (!(amount > 0)) return modifiers;
|
||||||
|
modifiers.push({
|
||||||
|
rateId: rate.id,
|
||||||
|
surchargeCode: this.surchargeCode(rate),
|
||||||
|
triggerValue: rate.rateUnit === 'FLAT' ? 1 : billedQty,
|
||||||
|
calculatedAmount: amount,
|
||||||
|
currency: rate.currency,
|
||||||
|
unitPriceUsd: rateValue,
|
||||||
|
billingUnit: rate.rateUnit,
|
||||||
|
});
|
||||||
|
return modifiers;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Messages for container lines whose total weight exceeds the hard capacity
|
* Messages for container lines whose total weight exceeds the hard capacity
|
||||||
* ceiling (weight_limit_rules.max_capacity_tons). Non-empty ⇒ the booking
|
* ceiling (weight_limit_rules.max_capacity_tons). Non-empty ⇒ the booking
|
||||||
|
|||||||
@@ -101,6 +101,23 @@ describe('RateChangeRequestsService', () => {
|
|||||||
expect(request.payload).toEqual({ rateValue: 200 });
|
expect(request.payload).toEqual({ rateValue: 200 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('carries a re-routed leg — a yard-only edit is a real change', async () => {
|
||||||
|
const { service } = build({
|
||||||
|
rate: liveRate({ originYardId: 'yard-a', destinationYardId: 'yard-b' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = await service.submit({
|
||||||
|
rateId: 'rate-1',
|
||||||
|
update: {
|
||||||
|
rateValue: 100,
|
||||||
|
originYardId: 'yard-a',
|
||||||
|
destinationYardId: 'yard-c',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(request.payload).toEqual({ destinationYardId: 'yard-c' });
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects a no-op — 100 posted against a live 100.0000 is not a change', async () => {
|
it('rejects a no-op — 100 posted against a live 100.0000 is not a change', async () => {
|
||||||
const { service } = build();
|
const { service } = build();
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ const DIFFABLE_FIELDS = [
|
|||||||
'tradeDirection',
|
'tradeDirection',
|
||||||
'containerTypeId',
|
'containerTypeId',
|
||||||
'cargoTypeId',
|
'cargoTypeId',
|
||||||
|
// The leg a route-scoped rate prices. Missing here, a re-routed LIVE rate
|
||||||
|
// diffed to nothing and the submit was refused as "nothing changed".
|
||||||
|
'originYardId',
|
||||||
|
'destinationYardId',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -69,18 +69,19 @@ export class RatesService {
|
|||||||
appliesTo: Rate['appliesTo'],
|
appliesTo: Rate['appliesTo'],
|
||||||
trigger: Rate['trigger'],
|
trigger: Rate['trigger'],
|
||||||
requestedUnit: Rate['rateUnit'] | undefined,
|
requestedUnit: Rate['rateUnit'] | undefined,
|
||||||
|
cargoKind?: 'CONTAINER' | 'BULK' | null,
|
||||||
): Rate['rateUnit'] {
|
): Rate['rateUnit'] {
|
||||||
// Overweight is per-ton, full stop — the admin form hides the unit field
|
// Overweight is per-ton, full stop — the admin form hides the unit field
|
||||||
// for it and omits rateUnit from the payload entirely.
|
// for it and omits rateUnit from the payload entirely.
|
||||||
if (trigger === 'OVERWEIGHT') return 'PER_TON';
|
if (trigger === 'OVERWEIGHT') return 'PER_TON';
|
||||||
|
|
||||||
const allowed = allowedRateUnits({ appliesTo, trigger });
|
const allowed = allowedRateUnits({ appliesTo, trigger, cargoKind });
|
||||||
if (!requestedUnit) {
|
if (!requestedUnit) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Pick a rate unit for this rate. Allowed: ${allowed.join(', ')}.`,
|
`Pick a rate unit for this rate. Allowed: ${allowed.join(', ')}.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!isRateUnitAllowed({ appliesTo, trigger, unit: requestedUnit })) {
|
if (!isRateUnitAllowed({ appliesTo, trigger, cargoKind, unit: requestedUnit })) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed.join(', ')}.`,
|
`Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed.join(', ')}.`,
|
||||||
);
|
);
|
||||||
@@ -93,6 +94,19 @@ export class RatesService {
|
|||||||
return trigger === 'ALWAYS' && BASE_FREIGHT_CATEGORIES.includes(appliesTo);
|
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.
|
* 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
|
* The railway only sells three shapes: import lands at the Djibouti ports and
|
||||||
@@ -126,7 +140,7 @@ export class RatesService {
|
|||||||
destinationYardId?: string | null;
|
destinationYardId?: string | null;
|
||||||
}): Promise<YardScope> {
|
}): Promise<YardScope> {
|
||||||
const { appliesTo, trigger, tradeDirection } = input;
|
const { appliesTo, trigger, tradeDirection } = input;
|
||||||
if (!this.isBaseFreight(appliesTo, trigger)) {
|
if (!this.isRouteScoped(appliesTo, trigger)) {
|
||||||
return { originYardId: null, destinationYardId: null };
|
return { originYardId: null, destinationYardId: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +148,7 @@ export class RatesService {
|
|||||||
const destinationYardId = input.destinationYardId ?? null;
|
const destinationYardId = input.destinationYardId ?? null;
|
||||||
if (!originYardId || !destinationYardId) {
|
if (!originYardId || !destinationYardId) {
|
||||||
throw new BadRequestException(
|
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) {
|
if (originYardId === destinationYardId) {
|
||||||
@@ -174,11 +188,76 @@ export class RatesService {
|
|||||||
trigger: Rate['trigger'];
|
trigger: Rate['trigger'];
|
||||||
tradeDirection: string | null;
|
tradeDirection: string | null;
|
||||||
intercityKind: string | null;
|
intercityKind: string | null;
|
||||||
|
cargoKind: string | null;
|
||||||
containerTypeId: string | null;
|
containerTypeId: string | null;
|
||||||
cargoTypeId: string | null;
|
cargoTypeId: string | null;
|
||||||
}): void {
|
}): void {
|
||||||
const { appliesTo, trigger, tradeDirection, intercityKind } = input;
|
const { appliesTo, trigger, tradeDirection, intercityKind, cargoKind } = input;
|
||||||
const { containerTypeId, cargoTypeId } = 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.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Sold per cargo kind: a container fee names the container type it covers
|
||||||
|
// (20ft and 40ft price differently); a bulk fee carries no type at all —
|
||||||
|
// that absence is what marks it as the bulk fee.
|
||||||
|
if (cargoKind !== 'CONTAINER' && cargoKind !== 'BULK') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'A customs clearance rate must say whether it covers containers or bulk.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (cargoKind === 'CONTAINER' && !containerTypeId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'A container customs clearance rate must name the container type it covers.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (cargoKind === 'BULK' && containerTypeId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'A bulk customs clearance rate cannot be scoped to a container type.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// The bulk customs fee names the commodity it covers (sugar and
|
||||||
|
// fertilizer clear differently).
|
||||||
|
if (cargoKind === 'BULK' && !cargoTypeId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'A bulk customs clearance rate must name the bulk cargo type it covers.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (cargoKind === 'CONTAINER' && cargoTypeId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'A container customs clearance rate cannot be scoped to a bulk cargo type.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (trigger === 'LASHING') {
|
||||||
|
// Bulk-only cargo securing, sold per direction. May narrow to one leaf
|
||||||
|
// commodity (specific wins over the commodity-wide catch-all).
|
||||||
|
if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'A lashing rate must say whether it covers IMPORT or EXPORT.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (containerTypeId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Lashing is bulk-only — it cannot be scoped to a container type.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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 (!this.isBaseFreight(appliesTo, trigger)) return;
|
||||||
|
|
||||||
if (appliesTo === 'INTERCITY') {
|
if (appliesTo === 'INTERCITY') {
|
||||||
@@ -247,13 +326,35 @@ export class RatesService {
|
|||||||
const trigger = dto.trigger as Rate['trigger'];
|
const trigger = dto.trigger as Rate['trigger'];
|
||||||
// Surcharges (trigger ≠ ALWAYS) carry no direction/scope — clear them so
|
// Surcharges (trigger ≠ ALWAYS) carry no direction/scope — clear them so
|
||||||
// the engine never accidentally narrows a surcharge by container/direction.
|
// the engine never accidentally narrows a surcharge by container/direction.
|
||||||
|
// Exceptions: customs clearance and empty-container return keep direction +
|
||||||
|
// container type — both are sold per lane (and per container type).
|
||||||
const isSurcharge = trigger !== 'ALWAYS';
|
const isSurcharge = trigger !== 'ALWAYS';
|
||||||
const containerTypeId = isSurcharge ? null : (dto.containerTypeId ?? null);
|
const cargoKind =
|
||||||
const cargoTypeId = isSurcharge ? null : (dto.cargoTypeId ?? null);
|
trigger === 'CUSTOMS_CLEARANCE'
|
||||||
|
? ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? null)
|
||||||
|
: null;
|
||||||
|
const containerTypeId =
|
||||||
|
trigger === 'WITH_RETURN' ||
|
||||||
|
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'CONTAINER')
|
||||||
|
? (dto.containerTypeId ?? null)
|
||||||
|
: isSurcharge
|
||||||
|
? null
|
||||||
|
: (dto.containerTypeId ?? null);
|
||||||
|
const cargoTypeId =
|
||||||
|
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') ||
|
||||||
|
trigger === 'LASHING'
|
||||||
|
? (dto.cargoTypeId ?? null)
|
||||||
|
: isSurcharge
|
||||||
|
? null
|
||||||
|
: (dto.cargoTypeId ?? null);
|
||||||
// Intercity never leaves Ethiopia, so it has no trade direction to store —
|
// Intercity never leaves Ethiopia, so it has no trade direction to store —
|
||||||
// its yard pair already says where it runs.
|
// its yard pair already says where it runs.
|
||||||
const tradeDirection =
|
const tradeDirection =
|
||||||
isSurcharge || appliesTo === 'INTERCITY' ? null : (dto.tradeDirection ?? null);
|
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN' || trigger === 'LASHING'
|
||||||
|
? (dto.tradeDirection ?? null)
|
||||||
|
: isSurcharge || appliesTo === 'INTERCITY'
|
||||||
|
? null
|
||||||
|
: (dto.tradeDirection ?? null);
|
||||||
|
|
||||||
const intercityKind = dto.intercityKind ?? null;
|
const intercityKind = dto.intercityKind ?? null;
|
||||||
this.assertScopeCoherent({
|
this.assertScopeCoherent({
|
||||||
@@ -261,6 +362,7 @@ export class RatesService {
|
|||||||
trigger,
|
trigger,
|
||||||
tradeDirection,
|
tradeDirection,
|
||||||
intercityKind,
|
intercityKind,
|
||||||
|
cargoKind,
|
||||||
containerTypeId,
|
containerTypeId,
|
||||||
cargoTypeId,
|
cargoTypeId,
|
||||||
});
|
});
|
||||||
@@ -282,6 +384,7 @@ export class RatesService {
|
|||||||
appliesTo,
|
appliesTo,
|
||||||
trigger,
|
trigger,
|
||||||
dto.rateUnit as Rate['rateUnit'] | undefined,
|
dto.rateUnit as Rate['rateUnit'] | undefined,
|
||||||
|
cargoKind,
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.assertNoDuplicatePattern({
|
await this.assertNoDuplicatePattern({
|
||||||
@@ -376,22 +479,42 @@ export class RatesService {
|
|||||||
if (dto.appliesTo) updates.appliesTo = appliesTo;
|
if (dto.appliesTo) updates.appliesTo = appliesTo;
|
||||||
if (dto.trigger) updates.trigger = trigger;
|
if (dto.trigger) updates.trigger = trigger;
|
||||||
|
|
||||||
const containerTypeId = isSurcharge
|
// A patch that leaves the cargo kind unsaid keeps the one the rate already
|
||||||
|
// has — read back off its container scope (container fees carry the type).
|
||||||
|
const cargoKind =
|
||||||
|
trigger !== 'CUSTOMS_CLEARANCE'
|
||||||
|
? null
|
||||||
|
: ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ??
|
||||||
|
(existing.containerTypeId ? 'CONTAINER' : 'BULK'));
|
||||||
|
|
||||||
|
const keepsContainerType =
|
||||||
|
!isSurcharge ||
|
||||||
|
trigger === 'WITH_RETURN' ||
|
||||||
|
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'CONTAINER');
|
||||||
|
const containerTypeId = !keepsContainerType
|
||||||
? null
|
? null
|
||||||
: dto.containerTypeId !== undefined
|
: dto.containerTypeId !== undefined
|
||||||
? dto.containerTypeId
|
? dto.containerTypeId
|
||||||
: existing.containerTypeId;
|
: existing.containerTypeId;
|
||||||
const cargoTypeId = isSurcharge
|
const keepsCargoType =
|
||||||
|
!isSurcharge ||
|
||||||
|
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') ||
|
||||||
|
trigger === 'LASHING';
|
||||||
|
const cargoTypeId = !keepsCargoType
|
||||||
? null
|
? null
|
||||||
: dto.cargoTypeId !== undefined
|
: dto.cargoTypeId !== undefined
|
||||||
? dto.cargoTypeId
|
? dto.cargoTypeId
|
||||||
: existing.cargoTypeId;
|
: existing.cargoTypeId;
|
||||||
const tradeDirection =
|
const tradeDirection =
|
||||||
isSurcharge || appliesTo === 'INTERCITY'
|
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN' || trigger === 'LASHING'
|
||||||
? null
|
? dto.tradeDirection !== undefined
|
||||||
: dto.tradeDirection !== undefined
|
|
||||||
? dto.tradeDirection
|
? dto.tradeDirection
|
||||||
: existing.tradeDirection;
|
: existing.tradeDirection
|
||||||
|
: isSurcharge || appliesTo === 'INTERCITY'
|
||||||
|
? null
|
||||||
|
: dto.tradeDirection !== undefined
|
||||||
|
? dto.tradeDirection
|
||||||
|
: existing.tradeDirection;
|
||||||
|
|
||||||
updates.containerTypeId = containerTypeId ?? null;
|
updates.containerTypeId = containerTypeId ?? null;
|
||||||
updates.cargoTypeId = cargoTypeId ?? null;
|
updates.cargoTypeId = cargoTypeId ?? null;
|
||||||
@@ -407,6 +530,7 @@ export class RatesService {
|
|||||||
trigger,
|
trigger,
|
||||||
tradeDirection: updates.tradeDirection,
|
tradeDirection: updates.tradeDirection,
|
||||||
intercityKind,
|
intercityKind,
|
||||||
|
cargoKind,
|
||||||
containerTypeId: updates.containerTypeId,
|
containerTypeId: updates.containerTypeId,
|
||||||
cargoTypeId: updates.cargoTypeId,
|
cargoTypeId: updates.cargoTypeId,
|
||||||
});
|
});
|
||||||
@@ -438,7 +562,7 @@ export class RatesService {
|
|||||||
// Re-validate the unit against the (possibly changed) shape; overweight is
|
// Re-validate the unit against the (possibly changed) shape; overweight is
|
||||||
// forced to PER_TON.
|
// forced to PER_TON.
|
||||||
const requestedUnit = (dto.rateUnit as Rate['rateUnit']) ?? existing.rateUnit;
|
const requestedUnit = (dto.rateUnit as Rate['rateUnit']) ?? existing.rateUnit;
|
||||||
updates.rateUnit = this.resolveRateUnit(appliesTo, trigger, requestedUnit);
|
updates.rateUnit = this.resolveRateUnit(appliesTo, trigger, requestedUnit, cargoKind);
|
||||||
|
|
||||||
// Guard the pattern uniqueness for the new identity, ignoring this row.
|
// Guard the pattern uniqueness for the new identity, ignoring this row.
|
||||||
await this.assertNoDuplicatePattern({
|
await this.assertNoDuplicatePattern({
|
||||||
|
|||||||
@@ -50,8 +50,11 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
|
|||||||
company: true,
|
company: true,
|
||||||
originYard: true,
|
originYard: true,
|
||||||
destinationYard: true,
|
destinationYard: true,
|
||||||
bookingContainers: { containerType: true },
|
// wagonTypes feed grossBookingWeightTons the REAL tare of the
|
||||||
cargoType: true,
|
// 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 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1187,6 +1187,7 @@ describe('BookingBatchService — built-train wagon capacity', () => {
|
|||||||
reserved: Booking[];
|
reserved: Booking[];
|
||||||
maxWagons?: number;
|
maxWagons?: number;
|
||||||
routeStops?: string[];
|
routeStops?: string[];
|
||||||
|
yardCountries?: Record<string, string>;
|
||||||
}) => {
|
}) => {
|
||||||
const schedule = {
|
const schedule = {
|
||||||
id: scheduleId,
|
id: scheduleId,
|
||||||
@@ -1218,10 +1219,21 @@ describe('BookingBatchService — built-train wagon capacity', () => {
|
|||||||
find: jest.fn().mockResolvedValue([]),
|
find: jest.fn().mockResolvedValue([]),
|
||||||
update: jest.fn().mockResolvedValue(undefined),
|
update: jest.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
|
const yardRepo = {
|
||||||
|
find: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(
|
||||||
|
Object.entries(opts.yardCountries ?? {}).map(([id, country]) => ({
|
||||||
|
id,
|
||||||
|
country,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
};
|
||||||
const dataSource = {
|
const dataSource = {
|
||||||
getRepository: jest.fn((entity: { name?: string }) => {
|
getRepository: jest.fn((entity: { name?: string }) => {
|
||||||
if (entity?.name === 'Wagon') return wagonRepo;
|
if (entity?.name === 'Wagon') return wagonRepo;
|
||||||
if (entity?.name === 'RouteMilestone') return milestoneRepo;
|
if (entity?.name === 'RouteMilestone') return milestoneRepo;
|
||||||
|
if (entity?.name === 'Yard') return yardRepo;
|
||||||
return genericRepo;
|
return genericRepo;
|
||||||
}),
|
}),
|
||||||
transaction: jest.fn(),
|
transaction: jest.fn(),
|
||||||
@@ -1264,11 +1276,11 @@ describe('BookingBatchService — built-train wagon capacity', () => {
|
|||||||
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false);
|
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is FULL when sub-leg bookings hold every physical wagon of a milestone route', async () => {
|
it('is NOT full when only a middle leg is sold and other edges run free (domestic route)', async () => {
|
||||||
// Regression: 50 wagons sold Negad→Mojo on a Doraleh→…→Dire Dawa corridor
|
// Leg-aware allocation (planWagonsWithStock legs) made mid-leg wagons real
|
||||||
// left the pass-through edges reading "free" in the per-edge budget, so the
|
// capacity on the edges they don't ride: a domestic corridor with cargo
|
||||||
// full train's window cycled OPEN forever and the day pool never expired.
|
// only on m1→m2 still boards bookings on the free first/last edges, so the
|
||||||
// A wagon is committed for the whole trip — leg-free edges are not capacity.
|
// window must stay open for them.
|
||||||
const { service } = buildService({
|
const { service } = buildService({
|
||||||
physicalWagons: 2,
|
physicalWagons: 2,
|
||||||
routeStops: ['yard-a', 'yard-m1', 'yard-m2', 'yard-b'],
|
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' }),
|
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);
|
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 () => {
|
it('reports over-allocation when the consist is trimmed below committed bookings', async () => {
|
||||||
const { service } = buildService({
|
const { service } = buildService({
|
||||||
physicalWagons: 1,
|
physicalWagons: 1,
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ import {
|
|||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
||||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
|
||||||
import { formatRouteLabel } from '../routes/entities/route.entity';
|
import { formatRouteLabel } from '../routes/entities/route.entity';
|
||||||
import { RouteMilestone } from '../routes/entities/route-milestone.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 { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||||
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||||
@@ -60,11 +60,14 @@ import {
|
|||||||
DEFAULT_WAGONS_PER_BOOKING,
|
DEFAULT_WAGONS_PER_BOOKING,
|
||||||
} from "./booking-batch.constants";
|
} from "./booking-batch.constants";
|
||||||
import {
|
import {
|
||||||
|
LocomotiveLimits,
|
||||||
WagonTypeDimensions,
|
WagonTypeDimensions,
|
||||||
|
bookingCargoTons,
|
||||||
bookingGrossWeightTons,
|
bookingGrossWeightTons,
|
||||||
deriveTrainCapacityFromLocomotive,
|
deriveTrainCapacityFromLocomotive,
|
||||||
sizePartialOfferWagons,
|
sizePartialOfferWagons,
|
||||||
trainHardCaps,
|
trainHardCaps,
|
||||||
|
trainSetLocomotiveLimits,
|
||||||
wagonTypeDimensionsFromEntity,
|
wagonTypeDimensionsFromEntity,
|
||||||
} from './train-capacity.util';
|
} from './train-capacity.util';
|
||||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||||
@@ -677,7 +680,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||||
candidate.id,
|
candidate.id,
|
||||||
);
|
);
|
||||||
const locomotive = schedule?.trainSet?.locomotive;
|
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
|
||||||
if (!schedule || !locomotive) continue;
|
if (!schedule || !locomotive) continue;
|
||||||
const limits = await this.capacityLimits(locomotive);
|
const limits = await this.capacityLimits(locomotive);
|
||||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||||
@@ -825,7 +828,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||||
candidate.id,
|
candidate.id,
|
||||||
);
|
);
|
||||||
const locomotive = schedule?.trainSet?.locomotive;
|
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
|
||||||
if (!schedule || !locomotive) continue;
|
if (!schedule || !locomotive) continue;
|
||||||
const limits = await this.capacityLimits(locomotive);
|
const limits = await this.capacityLimits(locomotive);
|
||||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||||
@@ -895,7 +898,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||||
candidate.id,
|
candidate.id,
|
||||||
);
|
);
|
||||||
const locomotive = schedule?.trainSet?.locomotive;
|
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
|
||||||
if (!schedule || !locomotive) continue;
|
if (!schedule || !locomotive) continue;
|
||||||
const limits = await this.capacityLimits(locomotive);
|
const limits = await this.capacityLimits(locomotive);
|
||||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||||
@@ -937,7 +940,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||||
target.scheduleId,
|
target.scheduleId,
|
||||||
);
|
);
|
||||||
const locomotive = schedule?.trainSet?.locomotive;
|
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
|
||||||
if (!schedule || !locomotive) return false;
|
if (!schedule || !locomotive) return false;
|
||||||
const wagonDims = await this.loadWagonDims();
|
const wagonDims = await this.loadWagonDims();
|
||||||
const limits = await this.capacityLimits(locomotive);
|
const limits = await this.capacityLimits(locomotive);
|
||||||
@@ -1034,7 +1037,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
const schedule =
|
const schedule =
|
||||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||||
const locomotive = schedule?.trainSet?.locomotive;
|
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
|
||||||
if (!schedule || !locomotive) {
|
if (!schedule || !locomotive) {
|
||||||
throw new ConflictException(
|
throw new ConflictException(
|
||||||
"Export train is no longer available for reservation",
|
"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
|
// The board renders ONE booking window — the schedule's own frozen window
|
||||||
// (windowOpensAt/windowClosesAt + phase deadlines returned below). Bookings
|
// (windowOpensAt/windowClosesAt + phase deadlines returned below). Bookings
|
||||||
@@ -1411,10 +1414,12 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
trainName: s.trainSet.train.trainName ?? null,
|
trainName: s.trainSet.train.trainName ?? null,
|
||||||
}
|
}
|
||||||
: 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
|
locomotive: loco
|
||||||
? {
|
? {
|
||||||
code: loco.code,
|
code: s.trainSet?.locomotive?.code ?? '',
|
||||||
name: loco.name ?? null,
|
name: s.trainSet?.locomotive?.name ?? null,
|
||||||
maxPullWeightTons: Number(loco.maxPullWeightTons),
|
maxPullWeightTons: Number(loco.maxPullWeightTons),
|
||||||
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
||||||
}
|
}
|
||||||
@@ -1464,7 +1469,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
weightTons: number;
|
weightTons: number;
|
||||||
lengthMeters: number;
|
lengthMeters: number;
|
||||||
}>,
|
}>,
|
||||||
loco: Locomotive | null,
|
loco: LocomotiveLimits | null,
|
||||||
maxWagons: number | null,
|
maxWagons: number | null,
|
||||||
): BatchBoardSchedule["capacity"] {
|
): BatchBoardSchedule["capacity"] {
|
||||||
const allocated = items.filter((i) => i.state === "ALLOCATED");
|
const allocated = items.filter((i) => i.state === "ALLOCATED");
|
||||||
@@ -1499,7 +1504,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
s: TrainSchedule,
|
s: TrainSchedule,
|
||||||
items: BatchBoardBooking[],
|
items: BatchBoardBooking[],
|
||||||
): BatchBoardSchedule {
|
): BatchBoardSchedule {
|
||||||
const loco = s.trainSet?.locomotive ?? null;
|
const loco = trainSetLocomotiveLimits(s.trainSet);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
scheduleId: s.id,
|
scheduleId: s.id,
|
||||||
@@ -1531,10 +1536,12 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
trainName: s.trainSet.train.trainName ?? null,
|
trainName: s.trainSet.train.trainName ?? null,
|
||||||
}
|
}
|
||||||
: 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
|
locomotive: loco
|
||||||
? {
|
? {
|
||||||
code: loco.code,
|
code: s.trainSet?.locomotive?.code ?? '',
|
||||||
name: loco.name ?? null,
|
name: s.trainSet?.locomotive?.name ?? null,
|
||||||
maxPullWeightTons: Number(loco.maxPullWeightTons),
|
maxPullWeightTons: Number(loco.maxPullWeightTons),
|
||||||
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
||||||
}
|
}
|
||||||
@@ -1600,7 +1607,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
const schedule =
|
const schedule =
|
||||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||||
if (!schedule || !this.isFillable(schedule)) return 0;
|
if (!schedule || !this.isFillable(schedule)) return 0;
|
||||||
const locomotive = schedule.trainSet?.locomotive;
|
const locomotive = trainSetLocomotiveLimits(schedule.trainSet);
|
||||||
if (!schedule.trainSetId || !locomotive) {
|
if (!schedule.trainSetId || !locomotive) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Schedule ${scheduleId} has no locomotive/train set — skipped.`,
|
`Schedule ${scheduleId} has no locomotive/train set — skipped.`,
|
||||||
@@ -1827,7 +1834,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
for (const id of scheduleIds) {
|
for (const id of scheduleIds) {
|
||||||
const schedule =
|
const schedule =
|
||||||
await this.trainSchedulesRepository.findByIdWithFullGraph(id);
|
await this.trainSchedulesRepository.findByIdWithFullGraph(id);
|
||||||
const locomotive = schedule?.trainSet?.locomotive;
|
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
|
||||||
if (!schedule || !schedule.trainSetId || !locomotive) {
|
if (!schedule || !schedule.trainSetId || !locomotive) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Schedule ${id} has no locomotive/train set — skipped.`,
|
`Schedule ${id} has no locomotive/train set — skipped.`,
|
||||||
@@ -2469,7 +2476,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
} | null> {
|
} | null> {
|
||||||
const schedule =
|
const schedule =
|
||||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||||
const locomotive = schedule?.trainSet?.locomotive;
|
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
|
||||||
if (!schedule || !locomotive) return null;
|
if (!schedule || !locomotive) return null;
|
||||||
const wagonDims = await this.loadWagonDims();
|
const wagonDims = await this.loadWagonDims();
|
||||||
const limits = await this.capacityLimits(locomotive);
|
const limits = await this.capacityLimits(locomotive);
|
||||||
@@ -3114,8 +3121,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
const containers = (b: Booking): number =>
|
const containers = (b: Booking): number =>
|
||||||
(b.bookingContainers ?? []).reduce((sum, c) => sum + Number(c.quantity ?? 0), 0);
|
(b.bookingContainers ?? []).reduce((sum, c) => sum + Number(c.quantity ?? 0), 0);
|
||||||
const totalContainers = containers(primary) + containers(partner);
|
const totalContainers = containers(primary) + containers(partner);
|
||||||
const cargoTons =
|
const cargoTons = bookingCargoTons(primary) + bookingCargoTons(partner);
|
||||||
Number(primary.cargoTotalWeightVgm ?? 0) + Number(partner.cargoTotalWeightVgm ?? 0);
|
|
||||||
|
|
||||||
// Consolidation shares TEU slots, never rated payload: the pair still needs
|
// Consolidation shares TEU slots, never rated payload: the pair still needs
|
||||||
// enough wagons to carry its combined cargo, so the weight axis bounds the
|
// 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 byLength = containerWagonsForLines(booking.bookingContainers ?? []);
|
||||||
|
|
||||||
const capacityTons = this.dimsFor(booking, wagonDims).capacityTons;
|
const capacityTons = this.dimsFor(booking, wagonDims).capacityTons;
|
||||||
const cargoTons = Number(booking.cargoTotalWeightVgm ?? 0);
|
const cargoTons = bookingCargoTons(booking);
|
||||||
const byWeight =
|
const byWeight =
|
||||||
cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0;
|
cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0;
|
||||||
|
|
||||||
@@ -3243,7 +3249,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
return {
|
return {
|
||||||
wagons,
|
wagons,
|
||||||
weightTons: bookingGrossWeightTons(
|
weightTons: bookingGrossWeightTons(
|
||||||
Number(booking.cargoTotalWeightVgm ?? 0),
|
bookingCargoTons(booking),
|
||||||
wagons,
|
wagons,
|
||||||
dims.tareWeightTons,
|
dims.tareWeightTons,
|
||||||
),
|
),
|
||||||
@@ -3270,7 +3276,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
* caps deliberately do not apply here (a mis-set global row once capped
|
* caps deliberately do not apply here (a mis-set global row once capped
|
||||||
* every train at 14m and no export booking could board).
|
* 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 wagonTypes = await this.loadWagonTypeDimensions();
|
||||||
const derived = deriveTrainCapacityFromLocomotive(
|
const derived = deriveTrainCapacityFromLocomotive(
|
||||||
{
|
{
|
||||||
@@ -3304,7 +3310,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
*/
|
*/
|
||||||
private async syncScheduleMaxWagons(
|
private async syncScheduleMaxWagons(
|
||||||
schedule: TrainSchedule,
|
schedule: TrainSchedule,
|
||||||
locomotive: Locomotive,
|
locomotive: LocomotiveLimits,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const physicalWagons = await this.builtTrainWagonCount(schedule);
|
const physicalWagons = await this.builtTrainWagonCount(schedule);
|
||||||
const maxWagons =
|
const maxWagons =
|
||||||
@@ -3605,14 +3611,14 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Built train: FULL when every physical wagon slot is taken — the consist is
|
* FULL is DIRECTIONAL: the schedule's trade direction is full when the
|
||||||
* the capacity, weight/length were settled at build time.
|
* border-crossing edge (which every export/import must ride) can't take one
|
||||||
* No built train: FULL on ANY capacity axis — out of wagon slots, or out of
|
* more minimal wagon on any axis — slots for built trains (the consist is
|
||||||
* pull weight / train length for even one more loaded wagon. The old
|
* the capacity, weight/length settled at build), all three axes otherwise
|
||||||
* slot-only check let a weight-bound train (PW2: weight binds at 37 wagons =
|
* (PW2: weight binds at 37 wagons = 3522.4T of 3500+90T, slots bind at 44).
|
||||||
* 3522.4T of 3500+90T, slots bind at 44) cycle its booking window forever
|
* Home-side legs may still run empty; intercity ride-alongs keep filling
|
||||||
* instead of finalizing — 7 phantom slots kept it "not full" while nothing
|
* them via the per-leg budget and never consult this flag. Domestic routes
|
||||||
* could board.
|
* (no border) are full only when every edge is closed.
|
||||||
*/
|
*/
|
||||||
async isScheduleFull(scheduleId: string): Promise<boolean> {
|
async isScheduleFull(scheduleId: string): Promise<boolean> {
|
||||||
const schedule =
|
const schedule =
|
||||||
@@ -3663,48 +3669,69 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
|
|
||||||
/** See {@link isScheduleFull} — same check for callers that already hold the full graph. */
|
/** See {@link isScheduleFull} — same check for callers that already hold the full graph. */
|
||||||
private async isTrainFull(schedule: TrainSchedule): Promise<boolean> {
|
private async isTrainFull(schedule: TrainSchedule): Promise<boolean> {
|
||||||
// Built train: the physical consist is the only capacity axis, and a wagon
|
// "Full" means full FOR THE TRAIN'S TRADE DIRECTION. Every export and
|
||||||
// is committed to its booking for the WHOLE trip — wagon allocation has no
|
// every import must cross the ET↔DJ border edge, so once that edge can't
|
||||||
// leg concept, so a wagon hauling Negad→Mojo cargo can never be re-sold for
|
// take one more minimal wagon the booking window may close — even while
|
||||||
// the Doraleh→Negad edge it merely passes through. Count commitments
|
// home-side legs still run empty. Intercity ride-alongs never consult this
|
||||||
// train-wide, not per corridor edge: the per-edge budget read "free slots"
|
// flag; they keep booking the free legs through the per-leg budget.
|
||||||
// on pass-through legs of a sold-out consist, so the window of a full train
|
// A single-country (domestic) corridor has no mandatory edge, so it is
|
||||||
// cycled OPEN forever instead of concluding DONE (and the day pool's
|
// full only when EVERY edge is closed on some axis.
|
||||||
// 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
|
|
||||||
const wagonDims = await this.loadWagonDims();
|
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);
|
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,
|
* The corridor's single border-crossing edge (last home-country stop → first
|
||||||
* regardless of which corridor leg each rides. Deduped by booking id — a
|
* far-country stop), or null when every stop is in one country. This is the
|
||||||
* booking mid-settle can momentarily be both linked and reserved.
|
* 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> {
|
private async borderLeg(stops: string[]): Promise<CorridorLeg | null> {
|
||||||
const wagonDims = await this.loadWagonDims();
|
if (stops.length < 2) return null;
|
||||||
const allocated = (schedule.scheduleBookings ?? [])
|
const yards = await this.dataSource
|
||||||
.map((sb) => sb.booking)
|
.getRepository(Yard)
|
||||||
.filter((b): b is Booking => Boolean(b));
|
.find({ where: { id: In(stops) } });
|
||||||
const reserved = await this.bookingsRepository.findReservedForSchedule(
|
const countryOf = new Map(yards.map((y) => [y.id, y.country]));
|
||||||
schedule.id,
|
const first = countryOf.get(stops[0]);
|
||||||
);
|
if (!first) return null;
|
||||||
const byId = new Map(
|
const crossIdx = stops.findIndex((id) => {
|
||||||
[...allocated, ...reserved].map((b) => [b.id, b] as const),
|
const country = countryOf.get(id);
|
||||||
);
|
return country != null && country !== first;
|
||||||
let total = 0;
|
});
|
||||||
for (const booking of byId.values()) {
|
if (crossIdx <= 0) return null;
|
||||||
total += this.wagonsFor(booking, wagonDims);
|
return { fromEdge: crossIdx - 1, toEdge: crossIdx };
|
||||||
}
|
|
||||||
return total;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { bookingCargoTons } from './train-capacity.util';
|
||||||
import type { Booking } from '../bookings/entities/booking.entity';
|
import type { Booking } from '../bookings/entities/booking.entity';
|
||||||
import type { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
import type { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||||
import {
|
import {
|
||||||
@@ -187,5 +188,5 @@ export function summarizeFleetWarnings(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function totalAssignedWeight(bookings: Booking[]): number {
|
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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
grossWagonWeightTons,
|
grossWagonWeightTons,
|
||||||
minLocomotiveLimits,
|
minLocomotiveLimits,
|
||||||
sizePartialOfferWagons,
|
sizePartialOfferWagons,
|
||||||
|
trainSetLocomotiveLimits,
|
||||||
} from './train-capacity.util';
|
} from './train-capacity.util';
|
||||||
|
|
||||||
describe('train-capacity.util', () => {
|
describe('train-capacity.util', () => {
|
||||||
@@ -205,6 +206,37 @@ describe('train-capacity.util', () => {
|
|||||||
expect(limits?.overageToleranceTons).toBe(20);
|
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', () => {
|
describe('sizePartialOfferWagons', () => {
|
||||||
it('sizes a bulk split by the WEIGHT axis when the pull limit binds, not wagon slots', () => {
|
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
|
// The 3500T-train scenario: two 1000T bookings boarded gross (each 15 PW2
|
||||||
|
|||||||
@@ -86,6 +86,27 @@ function num(value: unknown, fallback = 0): number {
|
|||||||
return Number.isFinite(n) ? n : fallback;
|
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. */
|
/** Gross weight of one loaded wagon: it hauls itself plus its cargo. */
|
||||||
export function grossWagonWeightTons(slot: Pick<ConsistSlot, 'tareWeightTons' | 'cargoTons'>): number {
|
export function grossWagonWeightTons(slot: Pick<ConsistSlot, 'tareWeightTons' | 'cargoTons'>): number {
|
||||||
return num(slot.tareWeightTons) + num(slot.cargoTons);
|
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));
|
const configured = values.filter((v) => v != null).map((v) => num(v));
|
||||||
return configured.length ? Math.min(...configured) : 0;
|
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. */
|
/** Per-booking train length from wagon count and freight-specific wagon type length. */
|
||||||
export function bookingTrainLengthMeters(
|
export function bookingTrainLengthMeters(
|
||||||
freightType: string | null | undefined,
|
freightType: string | null | undefined,
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ import {
|
|||||||
roundTons,
|
roundTons,
|
||||||
sumWagonsRequired,
|
sumWagonsRequired,
|
||||||
type TrainLimitConfig,
|
type TrainLimitConfig,
|
||||||
|
maxEdgeConsistUsage,
|
||||||
validateContainerPlacements,
|
validateContainerPlacements,
|
||||||
validateMixedTrainLimitsPerEdge,
|
validateMixedTrainLimitsPerEdge,
|
||||||
type ContainerPlacementInput,
|
type ContainerPlacementInput,
|
||||||
@@ -130,9 +131,12 @@ import {
|
|||||||
import { deriveScheduleDirection } from './derive-schedule-direction.util';
|
import { deriveScheduleDirection } from './derive-schedule-direction.util';
|
||||||
import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util';
|
import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util';
|
||||||
import {
|
import {
|
||||||
|
bookingCargoTons,
|
||||||
deriveTrainCapacityFromLocomotive,
|
deriveTrainCapacityFromLocomotive,
|
||||||
minLocomotiveLimits,
|
minLocomotiveLimits,
|
||||||
|
trainSetLocomotiveLimits,
|
||||||
wagonTypeDimensionsFromEntity,
|
wagonTypeDimensionsFromEntity,
|
||||||
|
LocomotiveLimits,
|
||||||
WagonTypeDimensions,
|
WagonTypeDimensions,
|
||||||
} from './train-capacity.util';
|
} from './train-capacity.util';
|
||||||
import {
|
import {
|
||||||
@@ -1706,25 +1710,36 @@ export class TrainSchedulingService {
|
|||||||
relations: { wagonType: true },
|
relations: { wagonType: true },
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
const planTareTons = consistWagons
|
const planTareTons = roundTons(
|
||||||
|
wagonPlan.reduce((sum, slot) => sum + Number(slot.tareWeightTons ?? 0), 0),
|
||||||
|
);
|
||||||
|
const consistTareTons = consistWagons
|
||||||
? roundTons(
|
? roundTons(
|
||||||
consistWagons.reduce(
|
consistWagons.reduce(
|
||||||
(sum, wagon) => sum + Number(wagon.wagonType?.tareWeightTons ?? 0),
|
(sum, wagon) => sum + Number(wagon.wagonType?.tareWeightTons ?? 0),
|
||||||
0,
|
0,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: roundTons(
|
: planTareTons;
|
||||||
wagonPlan.reduce((sum, slot) => sum + Number(slot.tareWeightTons ?? 0), 0),
|
// The pull limit binds on the HEAVIEST LEG, not the whole-route sum —
|
||||||
);
|
// disjoint legs (intercity Gelan→Adama + export Adama→Doraleh) are never
|
||||||
const grossWeightTons = roundTons(totalWeightTons + planTareTons);
|
// 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) {
|
if (!dto.forceAssign && weightCapWithOverage < grossWeightTons) {
|
||||||
throw new BadRequestException(
|
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(
|
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);
|
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(
|
const totalTareTons = roundTons(
|
||||||
wagonPlan.reduce((sum, w) => sum + (Number(w.tareWeightTons) || 0), 0),
|
wagonPlan.reduce((sum, w) => sum + (Number(w.tareWeightTons) || 0), 0),
|
||||||
);
|
);
|
||||||
@@ -4091,12 +4103,13 @@ export class TrainSchedulingService {
|
|||||||
const totalLengthMeters = roundTons(
|
const totalLengthMeters = roundTons(
|
||||||
wagonPlan.reduce((sum, w) => sum + w.lengthMeters, 0),
|
wagonPlan.reduce((sum, w) => sum + w.lengthMeters, 0),
|
||||||
);
|
);
|
||||||
if (grossWeightTons > trainLimits.maxWeightTons) {
|
// Weight/length limits are enforced PER EDGE by validateMixedTrainLimitsPerEdge
|
||||||
const message = `Total gross weight ${grossWeightTons}T (${totalWeightTons}T cargo + ${totalTareTons}T wagon tare) exceeds max train weight ${trainLimits.maxWeightTons}T`;
|
// above — the whole-route totals here are informational (summary) only. The
|
||||||
if (!violations.includes(message) && !warnings.includes(message)) {
|
// locomotive checks below also compare the heaviest single edge: a train is
|
||||||
pushLimit([message]);
|
// 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[] = [];
|
let assignedLocomotives: Locomotive[] = [];
|
||||||
if (targetScheduleId) {
|
if (targetScheduleId) {
|
||||||
@@ -4119,9 +4132,9 @@ export class TrainSchedulingService {
|
|||||||
if (
|
if (
|
||||||
setLimits &&
|
setLimits &&
|
||||||
(setLimits.maxPullWeightTons + (Number(setLimits.overageToleranceTons) || 0) <
|
(setLimits.maxPullWeightTons + (Number(setLimits.overageToleranceTons) || 0) <
|
||||||
grossWeightTons ||
|
maxEdgeGrossTons ||
|
||||||
setLimits.maxTrainLengthMeters + (Number(setLimits.overageToleranceMeters) || 0) <
|
setLimits.maxTrainLengthMeters + (Number(setLimits.overageToleranceMeters) || 0) <
|
||||||
totalLengthMeters)
|
maxEdgeLengthMeters)
|
||||||
) {
|
) {
|
||||||
pushLimit([
|
pushLimit([
|
||||||
'Assigned locomotives cannot support the total train weight and length',
|
'Assigned locomotives cannot support the total train weight and length',
|
||||||
@@ -4140,9 +4153,9 @@ export class TrainSchedulingService {
|
|||||||
!inServiceLocomotives.some(
|
!inServiceLocomotives.some(
|
||||||
(l) =>
|
(l) =>
|
||||||
Number(l.maxPullWeightTons) + (Number(l.overageToleranceTons) || 0) >=
|
Number(l.maxPullWeightTons) + (Number(l.overageToleranceTons) || 0) >=
|
||||||
grossWeightTons &&
|
maxEdgeGrossTons &&
|
||||||
Number(l.maxTrainLengthMeters) + (Number(l.overageToleranceMeters) || 0) >=
|
Number(l.maxTrainLengthMeters) + (Number(l.overageToleranceMeters) || 0) >=
|
||||||
totalLengthMeters,
|
maxEdgeLengthMeters,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
pushLimit(['No locomotive can support the total train weight and length']);
|
pushLimit(['No locomotive can support the total train weight and length']);
|
||||||
@@ -4201,10 +4214,7 @@ export class TrainSchedulingService {
|
|||||||
maxTrainLengthMeters?: number;
|
maxTrainLengthMeters?: number;
|
||||||
maxWagonsPerTrain?: number;
|
maxWagonsPerTrain?: number;
|
||||||
},
|
},
|
||||||
locomotive?: Pick<
|
locomotive?: LocomotiveLimits | null,
|
||||||
Locomotive,
|
|
||||||
'maxPullWeightTons' | 'maxTrainLengthMeters' | 'overageToleranceTons' | 'overageToleranceMeters'
|
|
||||||
>,
|
|
||||||
): Promise<Required<TrainLimitConfig>> {
|
): Promise<Required<TrainLimitConfig>> {
|
||||||
const row = await this.loadGlobalRulesRow();
|
const row = await this.loadGlobalRulesRow();
|
||||||
const configured = this.configService?.get<{
|
const configured = this.configService?.get<{
|
||||||
@@ -6512,7 +6522,7 @@ export class TrainSchedulingService {
|
|||||||
>,
|
>,
|
||||||
tareDims: Awaited<ReturnType<TrainSchedulingService['loadWagonTareDims']>>,
|
tareDims: Awaited<ReturnType<TrainSchedulingService['loadWagonTareDims']>>,
|
||||||
): number {
|
): number {
|
||||||
const cargo = Number(booking.cargoTotalWeightVgm ?? 0);
|
const cargo = bookingCargoTons(booking);
|
||||||
const fallback =
|
const fallback =
|
||||||
booking.freightType === 'BULK' ? tareDims.bulk : tareDims.container;
|
booking.freightType === 'BULK' ? tareDims.bulk : tareDims.container;
|
||||||
// Same first-configured-type resolution the batch engine's dimsFor uses.
|
// 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
|
// Ordered corridor stops (route milestones; falls back to the two
|
||||||
// endpoints) — lets the UI draw per-segment occupancy and label legs.
|
// endpoints) — lets the UI draw per-segment occupancy and label legs.
|
||||||
stops: this.mapScheduleStops(schedule),
|
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
|
// 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
|
// is dispatched/arrived/cancelled) rather than the live joins — the UI can badge
|
||||||
// it "historical" and skip re-pin affordances.
|
// it "historical" and skip re-pin affordances.
|
||||||
@@ -6973,7 +6995,10 @@ export class TrainSchedulingService {
|
|||||||
originStationId: schedule.originStationId,
|
originStationId: schedule.originStationId,
|
||||||
destinationStationId: schedule.destinationStationId,
|
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(
|
const validation = await this.validateBookingsForScheduling(
|
||||||
previewDto,
|
previewDto,
|
||||||
@@ -7099,7 +7124,7 @@ export class TrainSchedulingService {
|
|||||||
};
|
};
|
||||||
const limits = await this.resolveTrainLimitConfig(
|
const limits = await this.resolveTrainLimitConfig(
|
||||||
undefined,
|
undefined,
|
||||||
schedule.trainSet.locomotive,
|
trainSetLocomotiveLimits(schedule.trainSet),
|
||||||
);
|
);
|
||||||
|
|
||||||
let validation: Awaited<ReturnType<TrainSchedulingService['validateBookingsForScheduling']>>;
|
let validation: Awaited<ReturnType<TrainSchedulingService['validateBookingsForScheduling']>>;
|
||||||
@@ -7643,7 +7668,7 @@ export class TrainSchedulingService {
|
|||||||
};
|
};
|
||||||
const limits = await this.resolveTrainLimitConfig(
|
const limits = await this.resolveTrainLimitConfig(
|
||||||
undefined,
|
undefined,
|
||||||
schedule.trainSet.locomotive,
|
trainSetLocomotiveLimits(schedule.trainSet),
|
||||||
);
|
);
|
||||||
|
|
||||||
let validation: Awaited<ReturnType<TrainSchedulingService['validateBookingsForScheduling']>>;
|
let validation: Awaited<ReturnType<TrainSchedulingService['validateBookingsForScheduling']>>;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
containerWagonsForLines,
|
containerWagonsForLines,
|
||||||
expandBookingContainerUnits,
|
expandBookingContainerUnits,
|
||||||
expandContainerItems,
|
expandContainerItems,
|
||||||
|
maxEdgeConsistUsage,
|
||||||
roundTons,
|
roundTons,
|
||||||
sumWagonsRequired,
|
sumWagonsRequired,
|
||||||
validate20ftContainerRules,
|
validate20ftContainerRules,
|
||||||
@@ -279,3 +280,54 @@ describe('containerWagonsForLines — TEU-aware, ceil booking total once', () =>
|
|||||||
expect(containerWagonsForLines([])).toBe(0);
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -539,15 +539,9 @@ export function validateMixedTrainLimitsPerEdge(
|
|||||||
stops: string[],
|
stops: string[],
|
||||||
): string[] {
|
): string[] {
|
||||||
if (stops.length <= 2) return validateMixedTrainLimits(wagonPlan, wagonTypes, limits);
|
if (stops.length <= 2) return validateMixedTrainLimits(wagonPlan, wagonTypes, limits);
|
||||||
const lastIdx = stops.length - 1;
|
const spans = slotSpans(wagonPlan, stops);
|
||||||
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 violations = new Set<string>();
|
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(
|
const active = wagonPlan.filter(
|
||||||
(_, i) => spans[i].from <= edge && edge < spans[i].to,
|
(_, i) => spans[i].from <= edge && edge < spans[i].to,
|
||||||
);
|
);
|
||||||
@@ -559,6 +553,52 @@ export function validateMixedTrainLimitsPerEdge(
|
|||||||
return [...violations];
|
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(
|
export function validate20ftContainerRules(
|
||||||
units: ContainerUnitRow[],
|
units: ContainerUnitRow[],
|
||||||
placements: ContainerPlacementInput[],
|
placements: ContainerPlacementInput[],
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
import { FleetManage, FleetView } from '../../common/booking-guards';
|
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||||
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
|
import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
|
||||||
import { BuildTrainDto } from './dto/build-train.dto';
|
import { BuildTrainDto } from './dto/build-train.dto';
|
||||||
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
|
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
|
||||||
@@ -27,12 +28,12 @@ import { TrainBuilderService } from './train-builder.service';
|
|||||||
@ApiTags('train-builder')
|
@ApiTags('train-builder')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('train-builder')
|
@Controller('train-builder')
|
||||||
@FleetView()
|
@FleetView(FREIGHT_PERMS.trains.view)
|
||||||
export class TrainBuilderController {
|
export class TrainBuilderController {
|
||||||
constructor(private readonly trainBuilderService: TrainBuilderService) {}
|
constructor(private readonly trainBuilderService: TrainBuilderService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@FleetManage()
|
@FleetManage(FREIGHT_PERMS.trains.create)
|
||||||
@ApiOperation({ summary: 'Build a train: code + yard + 2+ locomotives (+ optional wagons)' })
|
@ApiOperation({ summary: 'Build a train: code + yard + 2+ locomotives (+ optional wagons)' })
|
||||||
build(@Body() dto: BuildTrainDto) {
|
build(@Body() dto: BuildTrainDto) {
|
||||||
return this.trainBuilderService.buildTrain(dto);
|
return this.trainBuilderService.buildTrain(dto);
|
||||||
@@ -60,7 +61,7 @@ export class TrainBuilderController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Put(':id/locomotives')
|
@Put(':id/locomotives')
|
||||||
@FleetManage()
|
@FleetManage(FREIGHT_PERMS.trains.update)
|
||||||
@ApiOperation({ summary: 'Replace the locomotive set (minimum 2, same yard)' })
|
@ApiOperation({ summary: 'Replace the locomotive set (minimum 2, same yard)' })
|
||||||
setLocomotives(
|
setLocomotives(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@@ -70,7 +71,7 @@ export class TrainBuilderController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/details')
|
@Patch(':id/details')
|
||||||
@FleetManage()
|
@FleetManage(FREIGHT_PERMS.trains.update)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Edit the train's name and fixed import/export run numbers",
|
summary: "Edit the train's name and fixed import/export run numbers",
|
||||||
})
|
})
|
||||||
@@ -82,7 +83,7 @@ export class TrainBuilderController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/yard')
|
@Patch(':id/yard')
|
||||||
@FleetManage()
|
@FleetManage(FREIGHT_PERMS.trains.update)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Relocate the train — its locomotives and wagons move to the new yard with it',
|
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')
|
@Post(':id/wagons')
|
||||||
@FleetManage()
|
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||||
@ApiOperation({ summary: "Append AVAILABLE wagons from the train's yard to the consist" })
|
@ApiOperation({ summary: "Append AVAILABLE wagons from the train's yard to the consist" })
|
||||||
assignWagons(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignTrainWagonsDto) {
|
assignWagons(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignTrainWagonsDto) {
|
||||||
return this.trainBuilderService.assignWagons(id, dto);
|
return this.trainBuilderService.assignWagons(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id/wagons/:wagonId')
|
@Delete(':id/wagons/:wagonId')
|
||||||
@FleetManage()
|
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||||
@ApiOperation({ summary: 'Detach one wagon from the consist' })
|
@ApiOperation({ summary: 'Detach one wagon from the consist' })
|
||||||
removeWagon(
|
removeWagon(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@@ -108,7 +109,7 @@ export class TrainBuilderController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/wagons/:wagonId/maintenance')
|
@Post(':id/wagons/:wagonId/maintenance')
|
||||||
@FleetManage()
|
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||||
@ApiOperation({ summary: 'Detach one wagon and move it to MAINTENANCE status' })
|
@ApiOperation({ summary: 'Detach one wagon and move it to MAINTENANCE status' })
|
||||||
sendWagonToMaintenance(
|
sendWagonToMaintenance(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@@ -118,14 +119,14 @@ export class TrainBuilderController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/reorder-wagons')
|
@Post(':id/reorder-wagons')
|
||||||
@FleetManage()
|
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||||
@ApiOperation({ summary: 'Persist a drag-reorder of the full consist' })
|
@ApiOperation({ summary: 'Persist a drag-reorder of the full consist' })
|
||||||
reorderWagons(@Param('id', ParseUUIDPipe) id: string, @Body() dto: ReorderTrainWagonsDto) {
|
reorderWagons(@Param('id', ParseUUIDPipe) id: string, @Body() dto: ReorderTrainWagonsDto) {
|
||||||
return this.trainBuilderService.reorderWagons(id, dto);
|
return this.trainBuilderService.reorderWagons(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/deactivate')
|
@Post(':id/deactivate')
|
||||||
@FleetManage()
|
@FleetManage(FREIGHT_PERMS.trains.update)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Deactivate the train (park it) — only allowed with no active schedule',
|
summary: 'Deactivate the train (park it) — only allowed with no active schedule',
|
||||||
})
|
})
|
||||||
@@ -134,14 +135,14 @@ export class TrainBuilderController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/activate')
|
@Post(':id/activate')
|
||||||
@FleetManage()
|
@FleetManage(FREIGHT_PERMS.trains.update)
|
||||||
@ApiOperation({ summary: 'Reactivate a deactivated train back to AVAILABLE' })
|
@ApiOperation({ summary: 'Reactivate a deactivated train back to AVAILABLE' })
|
||||||
activate(@Param('id', ParseUUIDPipe) id: string) {
|
activate(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.trainBuilderService.activate(id);
|
return this.trainBuilderService.activate(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@FleetManage()
|
@FleetManage(FREIGHT_PERMS.trains.delete)
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Disband the train (release wagons and locomotives)' })
|
@ApiOperation({ summary: 'Disband the train (release wagons and locomotives)' })
|
||||||
disband(@Param('id', ParseUUIDPipe) id: string) {
|
disband(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
|||||||
@@ -12,18 +12,19 @@ import {
|
|||||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
|
||||||
import { FleetManage, FleetView } from "../../common/booking-guards";
|
import { FleetManage, FleetView } from "../../common/booking-guards";
|
||||||
|
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||||
import { CreateTrainDto } from "./dto/create-train.dto";
|
import { CreateTrainDto } from "./dto/create-train.dto";
|
||||||
import { UpdateTrainDto } from "./dto/update-train.dto";
|
import { UpdateTrainDto } from "./dto/update-train.dto";
|
||||||
import { TrainsService } from "./trains.service";
|
import { TrainsService } from "./trains.service";
|
||||||
|
|
||||||
@ApiTags("trains")
|
@ApiTags("trains")
|
||||||
@Controller("trains")
|
@Controller("trains")
|
||||||
@FleetView()
|
@FleetView(FREIGHT_PERMS.trains.view)
|
||||||
export class TrainsController {
|
export class TrainsController {
|
||||||
constructor(private readonly trainsService: TrainsService) {}
|
constructor(private readonly trainsService: TrainsService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@FleetManage()
|
@FleetManage(FREIGHT_PERMS.trains.create)
|
||||||
@ApiOperation({ summary: "Register a new train" })
|
@ApiOperation({ summary: "Register a new train" })
|
||||||
create(@Body() dto: CreateTrainDto) {
|
create(@Body() dto: CreateTrainDto) {
|
||||||
return this.trainsService.create(dto);
|
return this.trainsService.create(dto);
|
||||||
@@ -42,14 +43,14 @@ export class TrainsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(":id")
|
@Patch(":id")
|
||||||
@FleetManage()
|
@FleetManage(FREIGHT_PERMS.trains.update)
|
||||||
@ApiOperation({ summary: "Update a train" })
|
@ApiOperation({ summary: "Update a train" })
|
||||||
update(@Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateTrainDto) {
|
update(@Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateTrainDto) {
|
||||||
return this.trainsService.update(id, dto);
|
return this.trainsService.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(":id")
|
@Delete(":id")
|
||||||
@FleetManage()
|
@FleetManage(FREIGHT_PERMS.trains.delete)
|
||||||
@ApiOperation({ summary: "Delete a train" })
|
@ApiOperation({ summary: "Delete a train" })
|
||||||
remove(@Param("id", ParseUUIDPipe) id: string) {
|
remove(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
return this.trainsService.remove(id);
|
return this.trainsService.remove(id);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
WagonTransferHistoryAll,
|
WagonTransferHistoryAll,
|
||||||
WagonTransferRequest,
|
WagonTransferRequest,
|
||||||
} from '../../common/booking-guards';
|
} from '../../common/booking-guards';
|
||||||
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
import { BulkFulfillTransferRequestsDto } from './dto/bulk-fulfill-transfer-requests.dto';
|
import { BulkFulfillTransferRequestsDto } from './dto/bulk-fulfill-transfer-requests.dto';
|
||||||
import { CreateTransferRequestDto } from './dto/create-transfer-request.dto';
|
import { CreateTransferRequestDto } from './dto/create-transfer-request.dto';
|
||||||
import { FulfillTransferRequestDto } from './dto/fulfill-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')
|
@ApiTags('wagon-transfer-requests')
|
||||||
@Controller('wagon-transfer-requests')
|
@Controller('wagon-transfer-requests')
|
||||||
@FleetView()
|
@FleetView(FREIGHT_PERMS.wagons.view)
|
||||||
export class WagonTransferRequestsController {
|
export class WagonTransferRequestsController {
|
||||||
constructor(private readonly service: WagonTransferRequestsService) {}
|
constructor(private readonly service: WagonTransferRequestsService) {}
|
||||||
|
|
||||||
@@ -110,7 +111,7 @@ export class WagonTransferRequestsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/cancel')
|
@Post(':id/cancel')
|
||||||
@FleetManage()
|
@FleetManage(FREIGHT_PERMS.wagons.transferRequest)
|
||||||
@ApiOperation({ summary: 'Withdraw a pending transfer request' })
|
@ApiOperation({ summary: 'Withdraw a pending transfer request' })
|
||||||
cancel(@Param('id', ParseUUIDPipe) id: string) {
|
cancel(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.service.cancelRequest(id);
|
return this.service.cancelRequest(id);
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import {
|
|||||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { CurrentUser } from '@edr/api-common';
|
import { CurrentUser } from '@edr/api-common';
|
||||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
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 { CreateWagonDto } from './dto/create-wagon.dto';
|
||||||
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
|
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
|
||||||
import { UpdateWagonDto } from './dto/update-wagon.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';
|
import { WagonsService } from './wagons.service';
|
||||||
|
|
||||||
@ApiTags('wagons')
|
@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')
|
@Controller('wagons')
|
||||||
@FleetView()
|
|
||||||
export class WagonsController {
|
export class WagonsController {
|
||||||
constructor(private readonly wagonsService: WagonsService) {}
|
constructor(private readonly wagonsService: WagonsService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@FleetManage()
|
@FleetManage(FREIGHT_PERMS.wagons.create)
|
||||||
@ApiOperation({ summary: 'Create a new wagon' })
|
@ApiOperation({ summary: 'Create a new wagon' })
|
||||||
create(@Body() dto: CreateWagonDto) {
|
create(@Body() dto: CreateWagonDto) {
|
||||||
return this.wagonsService.create(dto);
|
return this.wagonsService.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@StaffReference()
|
||||||
@ApiOperation({ summary: 'List all wagons' })
|
@ApiOperation({ summary: 'List all wagons' })
|
||||||
findAll(@Query() query: ListWagonsQueryDto) {
|
findAll(@Query() query: ListWagonsQueryDto) {
|
||||||
return this.wagonsService.findAll(query);
|
return this.wagonsService.findAll(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@StaffReference()
|
||||||
@ApiOperation({ summary: 'Get a wagon by ID' })
|
@ApiOperation({ summary: 'Get a wagon by ID' })
|
||||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.wagonsService.findById(id);
|
return this.wagonsService.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id/movements')
|
@Get(':id/movements')
|
||||||
|
@StaffReference()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Wagon movement ledger (loaded legs, empty repositions, manual moves), newest first",
|
summary: "Wagon movement ledger (loaded legs, empty repositions, manual moves), newest first",
|
||||||
})
|
})
|
||||||
@@ -56,42 +62,42 @@ export class WagonsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@FleetManage()
|
@FleetManage(FREIGHT_PERMS.wagons.update)
|
||||||
@ApiOperation({ summary: 'Update a wagon' })
|
@ApiOperation({ summary: 'Update a wagon' })
|
||||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWagonDto) {
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWagonDto) {
|
||||||
return this.wagonsService.update(id, dto);
|
return this.wagonsService.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@FleetManage()
|
@FleetManage(FREIGHT_PERMS.wagons.delete)
|
||||||
@ApiOperation({ summary: 'Delete a wagon' })
|
@ApiOperation({ summary: 'Delete a wagon' })
|
||||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.wagonsService.remove(id);
|
return this.wagonsService.remove(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/assign-train')
|
@Post(':id/assign-train')
|
||||||
@FleetManage()
|
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||||
@ApiOperation({ summary: 'Assign wagon to a train' })
|
@ApiOperation({ summary: 'Assign wagon to a train' })
|
||||||
assignToTrain(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignWagonToTrainDto) {
|
assignToTrain(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignWagonToTrainDto) {
|
||||||
return this.wagonsService.assignToTrain(id, dto);
|
return this.wagonsService.assignToTrain(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/unassign-train')
|
@Post(':id/unassign-train')
|
||||||
@FleetManage()
|
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||||
@ApiOperation({ summary: 'Unassign wagon from train' })
|
@ApiOperation({ summary: 'Unassign wagon from train' })
|
||||||
unassignFromTrain(@Param('id', ParseUUIDPipe) id: string) {
|
unassignFromTrain(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.wagonsService.unassignFromTrain(id);
|
return this.wagonsService.unassignFromTrain(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('bulk-transfer')
|
@Post('bulk-transfer')
|
||||||
@FleetManage()
|
@FleetManage(FREIGHT_PERMS.wagons.update)
|
||||||
@ApiOperation({ summary: 'Transfer multiple wagons to a destination yard' })
|
@ApiOperation({ summary: 'Transfer multiple wagons to a destination yard' })
|
||||||
bulkTransfer(@Body() dto: BulkTransferWagonsDto, @CurrentUser() user: TCurrentUser) {
|
bulkTransfer(@Body() dto: BulkTransferWagonsDto, @CurrentUser() user: TCurrentUser) {
|
||||||
return this.wagonsService.bulkTransfer(dto, user?.id);
|
return this.wagonsService.bulkTransfer(dto, user?.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('bulk-status')
|
@Post('bulk-status')
|
||||||
@FleetManage()
|
@FleetManage(FREIGHT_PERMS.wagons.update)
|
||||||
@ApiOperation({ summary: 'Set the status of multiple wagons' })
|
@ApiOperation({ summary: 'Set the status of multiple wagons' })
|
||||||
bulkSetStatus(@Body() dto: BulkSetWagonStatusDto) {
|
bulkSetStatus(@Body() dto: BulkSetWagonStatusDto) {
|
||||||
return this.wagonsService.bulkSetStatus(dto);
|
return this.wagonsService.bulkSetStatus(dto);
|
||||||
@@ -100,12 +106,12 @@ export class WagonsController {
|
|||||||
|
|
||||||
// Separate controller for train‑specific reorder (registered in module)
|
// Separate controller for train‑specific reorder (registered in module)
|
||||||
@Controller('trains/:trainId/reorder-wagons')
|
@Controller('trains/:trainId/reorder-wagons')
|
||||||
@FleetView()
|
@FleetView(FREIGHT_PERMS.trains.view)
|
||||||
export class TrainWagonsReorderController {
|
export class TrainWagonsReorderController {
|
||||||
constructor(private readonly wagonsService: WagonsService) {}
|
constructor(private readonly wagonsService: WagonsService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@FleetManage()
|
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||||
@ApiOperation({ summary: 'Reorder wagons of a train' })
|
@ApiOperation({ summary: 'Reorder wagons of a train' })
|
||||||
reorder(@Param('trainId', ParseUUIDPipe) trainId: string, @Body() dto: ReorderWagonsDto) {
|
reorder(@Param('trainId', ParseUUIDPipe) trainId: string, @Body() dto: ReorderWagonsDto) {
|
||||||
return this.wagonsService.reorderWagons(trainId, dto);
|
return this.wagonsService.reorderWagons(trainId, dto);
|
||||||
|
|||||||
@@ -52,4 +52,8 @@ export class BookingHandover extends BaseEntity {
|
|||||||
/** EDR last-mile: when the goods were delivered to the customer. */
|
/** EDR last-mile: when the goods were delivered to the customer. */
|
||||||
@Column({ name: 'delivered_at', type: 'timestamptz', nullable: true })
|
@Column({ name: 'delivered_at', type: 'timestamptz', nullable: true })
|
||||||
deliveredAt?: Date | null;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -287,6 +287,7 @@ export class HandoverService {
|
|||||||
handoverId: string,
|
handoverId: string,
|
||||||
userId?: string | null,
|
userId?: string | null,
|
||||||
signerName?: string | null,
|
signerName?: string | null,
|
||||||
|
signatureImageUrl?: string | null,
|
||||||
): Promise<BookingHandover> {
|
): Promise<BookingHandover> {
|
||||||
const repo = this.dataSource.getRepository(BookingHandover);
|
const repo = this.dataSource.getRepository(BookingHandover);
|
||||||
const handover = await repo.findOne({ where: { id: handoverId } });
|
const handover = await repo.findOne({ where: { id: handoverId } });
|
||||||
@@ -297,6 +298,7 @@ export class HandoverService {
|
|||||||
handover.signedAt = new Date();
|
handover.signedAt = new Date();
|
||||||
handover.signedByUserId = userId ?? null;
|
handover.signedByUserId = userId ?? null;
|
||||||
handover.signerName = signerName?.trim() || null;
|
handover.signerName = signerName?.trim() || null;
|
||||||
|
handover.signatureImageUrl = signatureImageUrl ?? null;
|
||||||
return repo.save(handover);
|
return repo.save(handover);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,6 +307,7 @@ export class HandoverService {
|
|||||||
bookingId: string,
|
bookingId: string,
|
||||||
userId?: string | null,
|
userId?: string | null,
|
||||||
signerName?: string | null,
|
signerName?: string | null,
|
||||||
|
signatureImageUrl?: string | null,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.dataSource
|
await this.dataSource
|
||||||
.getRepository(BookingHandover)
|
.getRepository(BookingHandover)
|
||||||
@@ -314,6 +317,7 @@ export class HandoverService {
|
|||||||
signedAt: new Date(),
|
signedAt: new Date(),
|
||||||
signedByUserId: userId ?? null,
|
signedByUserId: userId ?? null,
|
||||||
signerName: signerName?.trim() || null,
|
signerName: signerName?.trim() || null,
|
||||||
|
signatureImageUrl: signatureImageUrl ?? null,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2504,27 +2504,37 @@ export class WarehouseInventoryService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (result.unloadedCount > 0) {
|
if (result.unloadedCount > 0) {
|
||||||
let document = await this.interchangeDocuments.generateFromSchedule({
|
// Best-effort: the unload is already committed — a paperwork failure must
|
||||||
scheduleId,
|
// not fail the response (it did once: items unloaded, request 500'd, and
|
||||||
direction: 'EXPORT',
|
// the document only appeared after a manual retry days later). The doc
|
||||||
handoverLocation: schedule.destinationName ?? 'Djibouti Port',
|
// backfills on any retry since already-unloaded items count as unloaded.
|
||||||
handoverFrom: 'EDR',
|
try {
|
||||||
handoverTo: 'Djibouti Port Operator',
|
let document = await this.interchangeDocuments.generateFromSchedule({
|
||||||
portOperatorName: 'Doraleh Multipurpose Port',
|
scheduleId,
|
||||||
generatedBy: performedBy ?? 'EDR Operations',
|
direction: 'EXPORT',
|
||||||
remarks: 'Generated after export unloading at Djibouti Port; signed by EDR and Djibouti Port Operator.',
|
handoverLocation: schedule.destinationName ?? 'Djibouti Port',
|
||||||
});
|
handoverFrom: 'EDR',
|
||||||
if (document.status !== 'ACKNOWLEDGED') {
|
handoverTo: 'Djibouti Port Operator',
|
||||||
document = await this.interchangeDocuments.acknowledge(document.id, {
|
portOperatorName: 'Doraleh Multipurpose Port',
|
||||||
acknowledgedBy: 'Djibouti Port Operator',
|
generatedBy: performedBy ?? 'EDR Operations',
|
||||||
remarks: 'Auto acknowledged after Djibouti export unloading.',
|
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;
|
return result;
|
||||||
@@ -4087,10 +4097,11 @@ export class WarehouseInventoryService {
|
|||||||
await this.invoices.assertClearanceAllowed(item.id);
|
await this.invoices.assertClearanceAllowed(item.id);
|
||||||
|
|
||||||
const approvedAt = new Date();
|
const approvedAt = new Date();
|
||||||
|
const signatureImageUrl = signature?.signatureImageUrl ?? null;
|
||||||
const approval = {
|
const approval = {
|
||||||
approvedAt: approvedAt.toISOString(),
|
approvedAt: approvedAt.toISOString(),
|
||||||
signerDisplayName: name,
|
signerDisplayName: name,
|
||||||
signatureImageUrl: signature?.signatureImageUrl ?? null,
|
signatureImageUrl,
|
||||||
userId,
|
userId,
|
||||||
};
|
};
|
||||||
const existingNotes = this.stripCustomerDeliveryApproval(item.notes);
|
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
|
// Sign the structured handover record(s) for this booking (self-haul: before
|
||||||
// the truck leaves). Kept alongside the legacy approval note.
|
// 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 {
|
return {
|
||||||
bookingId,
|
bookingId,
|
||||||
@@ -4149,6 +4160,8 @@ export class WarehouseInventoryService {
|
|||||||
throw new BadRequestException('Please enter your full name to sign the handover');
|
throw new BadRequestException('Please enter your full name to sign the handover');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const signature = await this.signatures.getForUser(userId).catch(() => null);
|
||||||
|
|
||||||
const [h]: Array<{
|
const [h]: Array<{
|
||||||
bookingId: string;
|
bookingId: string;
|
||||||
reference: string;
|
reference: string;
|
||||||
@@ -4181,7 +4194,7 @@ export class WarehouseInventoryService {
|
|||||||
);
|
);
|
||||||
if (inv) await this.invoices.assertClearanceAllowed(inv.id);
|
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);
|
const allSigned = await this.handover.isFullySigned(h.bookingId);
|
||||||
|
|
||||||
if (inv) {
|
if (inv) {
|
||||||
|
|||||||
@@ -70,9 +70,15 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
*/
|
*/
|
||||||
export const CONTRACT_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-000000000001', 'edr_freight_app:contracts:view', 'View contracts'),
|
||||||
perm('a3000001-0001-4000-8000-000000000002', 'edr_freight_app:contracts:staff_accept', 'Accept contract intake'),
|
// Intake actions are split per freight type (bulk vs container) — fresh ids
|
||||||
perm('a3000001-0001-4000-8000-000000000003', 'edr_freight_app:contracts:request_changes', 'Request contract changes'),
|
// because the seeder upserts ON CONFLICT (key); reusing the old ids with new
|
||||||
perm('a3000001-0001-4000-8000-000000000004', 'edr_freight_app:contracts:reject', 'Reject contract'),
|
// 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-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-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'),
|
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-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-000000000003', 'edr_freight_app:cargoes:update', 'Update cargo'),
|
||||||
perm('e1f00001-0001-4000-8000-000000000004', 'edr_freight_app:cargoes:delete', 'Delete 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
|
// G. Fleet — road & telemetry
|
||||||
@@ -302,8 +312,6 @@ export const SCHEDULING_EXTRA_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
|
|
||||||
// L. Administration & settings (split from the coarse admin umbrella)
|
// L. Administration & settings (split from the coarse admin umbrella)
|
||||||
export const CONFIG_SETTINGS_PERMISSIONS: FreightPermissionSeed[] = [
|
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-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('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'),
|
perm('b4b00001-0001-4000-8000-000000000001', 'edr_freight_app:settings:dropdown:view', 'View dropdown settings'),
|
||||||
@@ -371,9 +379,18 @@ export const FREIGHT_PERMS = {
|
|||||||
},
|
},
|
||||||
contracts: {
|
contracts: {
|
||||||
view: 'edr_freight_app:contracts:view',
|
view: 'edr_freight_app:contracts:view',
|
||||||
staffAccept: 'edr_freight_app:contracts:staff_accept',
|
staffAccept: {
|
||||||
requestChanges: 'edr_freight_app:contracts:request_changes',
|
bulk: 'edr_freight_app:contracts:staff_accept:bulk',
|
||||||
reject: 'edr_freight_app:contracts:reject',
|
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',
|
approveLineStaff: 'edr_freight_app:contracts:approve_line_staff',
|
||||||
approveDirector: 'edr_freight_app:contracts:approve_director',
|
approveDirector: 'edr_freight_app:contracts:approve_director',
|
||||||
approveCeo: 'edr_freight_app:contracts:approve_ceo',
|
approveCeo: 'edr_freight_app:contracts:approve_ceo',
|
||||||
@@ -495,6 +512,10 @@ export const FREIGHT_PERMS = {
|
|||||||
update: 'edr_freight_app:cargoes:update',
|
update: 'edr_freight_app:cargoes:update',
|
||||||
delete: 'edr_freight_app:cargoes:delete',
|
delete: 'edr_freight_app:cargoes:delete',
|
||||||
},
|
},
|
||||||
|
consignments: {
|
||||||
|
view: 'edr_freight_app:consignments:view',
|
||||||
|
create: 'edr_freight_app:consignments:create',
|
||||||
|
},
|
||||||
vehicles: {
|
vehicles: {
|
||||||
view: 'edr_freight_app:vehicles:view',
|
view: 'edr_freight_app:vehicles:view',
|
||||||
create: 'edr_freight_app:vehicles:create',
|
create: 'edr_freight_app:vehicles:create',
|
||||||
@@ -594,12 +615,6 @@ export const FREIGHT_PERMS = {
|
|||||||
cancel: 'edr_freight_app:warehouse_fee_invoices:cancel',
|
cancel: 'edr_freight_app:warehouse_fee_invoices:cancel',
|
||||||
pay: 'edr_freight_app:warehouse_fee_invoices:pay',
|
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: {
|
settings: {
|
||||||
fileUpload: {
|
fileUpload: {
|
||||||
view: 'edr_freight_app:settings:file_upload:view',
|
view: 'edr_freight_app:settings:file_upload:view',
|
||||||
@@ -661,9 +676,56 @@ export const FREIGHT_PERMS = {
|
|||||||
},
|
},
|
||||||
} as const;
|
} 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 = () =>
|
const allRuleEngineViewKeys = () =>
|
||||||
RULE_ENGINE_RESOURCE_SLUGS.map((s) => FREIGHT_PERMS.ruleEngine.view(s));
|
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 = {
|
export const ROLE_PERMISSION_PRESETS = {
|
||||||
// Marketing / line staff: drives a booking from intake through line-staff
|
// Marketing / line staff: drives a booking from intake through line-staff
|
||||||
// approval and contract generation/signing — i.e. until the contract is ready
|
// 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.rejectApproval,
|
||||||
FREIGHT_PERMS.bookings.cancel,
|
FREIGHT_PERMS.bookings.cancel,
|
||||||
FREIGHT_PERMS.contracts.view,
|
FREIGHT_PERMS.contracts.view,
|
||||||
FREIGHT_PERMS.contracts.staffAccept,
|
...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept),
|
||||||
FREIGHT_PERMS.contracts.requestChanges,
|
...bothFreightTypes(FREIGHT_PERMS.contracts.requestChanges),
|
||||||
FREIGHT_PERMS.contracts.reject,
|
...bothFreightTypes(FREIGHT_PERMS.contracts.reject),
|
||||||
FREIGHT_PERMS.contracts.approveLineStaff,
|
FREIGHT_PERMS.contracts.approveLineStaff,
|
||||||
...allRuleEngineViewKeys(),
|
...allRuleEngineViewKeys(),
|
||||||
],
|
],
|
||||||
@@ -692,6 +754,7 @@ export const ROLE_PERMISSION_PRESETS = {
|
|||||||
FREIGHT_PERMS.trainScheduling.manage,
|
FREIGHT_PERMS.trainScheduling.manage,
|
||||||
FREIGHT_PERMS.fleet.view,
|
FREIGHT_PERMS.fleet.view,
|
||||||
FREIGHT_PERMS.fleet.manage,
|
FREIGHT_PERMS.fleet.manage,
|
||||||
|
...FLEET_GRANULAR_KEYS,
|
||||||
// Path A (no customs): Operations reviews the customer's self-clearance docs
|
// Path A (no customs): Operations reviews the customer's self-clearance docs
|
||||||
// — on the contract for ONE_TIME contracts, and PER BOOKING for GENERAL
|
// — on the contract for ONE_TIME contracts, and PER BOOKING for GENERAL
|
||||||
// contracts (booking-level document review → finalize → CLEARANCE_READY).
|
// contracts (booking-level document review → finalize → CLEARANCE_READY).
|
||||||
@@ -768,9 +831,9 @@ export const ROLE_PERMISSION_PRESETS = {
|
|||||||
FREIGHT_PERMS.bookings.reviewDocuments,
|
FREIGHT_PERMS.bookings.reviewDocuments,
|
||||||
FREIGHT_PERMS.bookings.finalizeClearance,
|
FREIGHT_PERMS.bookings.finalizeClearance,
|
||||||
FREIGHT_PERMS.contracts.view,
|
FREIGHT_PERMS.contracts.view,
|
||||||
FREIGHT_PERMS.contracts.staffAccept,
|
...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept),
|
||||||
FREIGHT_PERMS.contracts.requestChanges,
|
...bothFreightTypes(FREIGHT_PERMS.contracts.requestChanges),
|
||||||
FREIGHT_PERMS.contracts.reject,
|
...bothFreightTypes(FREIGHT_PERMS.contracts.reject),
|
||||||
FREIGHT_PERMS.contracts.approveLineStaff,
|
FREIGHT_PERMS.contracts.approveLineStaff,
|
||||||
FREIGHT_PERMS.contracts.generateContract,
|
FREIGHT_PERMS.contracts.generateContract,
|
||||||
FREIGHT_PERMS.contracts.signStaff,
|
FREIGHT_PERMS.contracts.signStaff,
|
||||||
|
|||||||
@@ -416,9 +416,11 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
|||||||
{ appliesTo: "OTHER", trigger: "WITH_RETURN", rateType: "RETURN_SURCHARGE", rateValue: 20, rateUnit: "PER_CONTAINER" },
|
{ appliesTo: "OTHER", trigger: "WITH_RETURN", rateType: "RETURN_SURCHARGE", rateValue: 20, rateUnit: "PER_CONTAINER" },
|
||||||
{ appliesTo: "OTHER", trigger: "SHIPPING_LINE", rateType: "DOUBLE_HANDLING", rateValue: 100, rateUnit: "PER_CONTAINER" },
|
{ appliesTo: "OTHER", trigger: "SHIPPING_LINE", rateType: "DOUBLE_HANDLING", rateValue: 100, rateUnit: "PER_CONTAINER" },
|
||||||
{ appliesTo: "OTHER", trigger: "CONSOLIDATION", rateType: "LASHING", rateValue: 50, rateUnit: "PER_CONTAINER" },
|
{ appliesTo: "OTHER", trigger: "CONSOLIDATION", rateType: "LASHING", rateValue: 50, rateUnit: "PER_CONTAINER" },
|
||||||
// Cargo-securing / lashing — flat fee, billed once per booking whose
|
// Cargo-securing / lashing — bulk-only, fires when the cargo type has
|
||||||
// cargo type has hasLashing = true.
|
// hasLashing. Sold per direction; commodity-wide catch-alls seeded here,
|
||||||
{ appliesTo: "OTHER", trigger: "LASHING", rateType: "LASHING", rateValue: 40, rateUnit: "FLAT" },
|
// commodity-specific rates are configured by the rates team.
|
||||||
|
{ appliesTo: "OTHER", trigger: "LASHING", rateType: "LASHING", tradeDirection: "IMPORT", rateValue: 40, rateUnit: "PER_TON" },
|
||||||
|
{ appliesTo: "OTHER", trigger: "LASHING", rateType: "LASHING", tradeDirection: "EXPORT", rateValue: 40, rateUnit: "PER_TON" },
|
||||||
// ── First/last-mile road haulage (per km) — drives the mile invoices ──
|
// ── First/last-mile road haulage (per km) — drives the mile invoices ──
|
||||||
{ appliesTo: "OTHER", trigger: "ALWAYS", rateType: "FIRST_MILE", rateValue: 20, rateUnit: "PER_KM" },
|
{ appliesTo: "OTHER", trigger: "ALWAYS", rateType: "FIRST_MILE", rateValue: 20, rateUnit: "PER_KM" },
|
||||||
{ appliesTo: "OTHER", trigger: "ALWAYS", rateType: "LAST_MILE", rateValue: 25, rateUnit: "PER_KM" },
|
{ appliesTo: "OTHER", trigger: "ALWAYS", rateType: "LAST_MILE", rateValue: 25, rateUnit: "PER_KM" },
|
||||||
|
|||||||
@@ -271,19 +271,19 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
label: "Routes",
|
label: "Routes",
|
||||||
href: "/dashboard/routes",
|
href: "/dashboard/routes",
|
||||||
icon: <Network />,
|
icon: <Network />,
|
||||||
permission: FREIGHT_PERMS.fleet.view,
|
permission: [FREIGHT_PERMS.routes.view, FREIGHT_PERMS.fleet.view],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Locomotives",
|
label: "Locomotives",
|
||||||
href: "/dashboard/locomotives",
|
href: "/dashboard/locomotives",
|
||||||
icon: <Train />,
|
icon: <Train />,
|
||||||
permission: FREIGHT_PERMS.fleet.view,
|
permission: [FREIGHT_PERMS.locomotives.view, FREIGHT_PERMS.fleet.view],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Train Builder",
|
label: "Train Builder",
|
||||||
href: "/dashboard/train-builder",
|
href: "/dashboard/train-builder",
|
||||||
icon: <Hammer />,
|
icon: <Hammer />,
|
||||||
permission: FREIGHT_PERMS.fleet.view,
|
permission: [FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view],
|
||||||
},
|
},
|
||||||
|
|
||||||
// {
|
// {
|
||||||
@@ -295,7 +295,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
label: "Wagons",
|
label: "Wagons",
|
||||||
href: "/dashboard/wagons",
|
href: "/dashboard/wagons",
|
||||||
icon: <Truck />,
|
icon: <Truck />,
|
||||||
permission: FREIGHT_PERMS.fleet.view,
|
permission: [FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Vehicles",
|
label: "Vehicles",
|
||||||
@@ -548,11 +548,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
icon: <Boxes />,
|
icon: <Boxes />,
|
||||||
children: [
|
children: [
|
||||||
...getCategorySidebarChildren("configuration"),
|
...getCategorySidebarChildren("configuration"),
|
||||||
{
|
|
||||||
label: "Contract validity",
|
|
||||||
href: "/dashboard/configuration/contract-validity-periods",
|
|
||||||
permission: FREIGHT_PERMS.config.contractValidity.view,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: "Train scheduling rules",
|
label: "Train scheduling rules",
|
||||||
href: "/dashboard/configuration/train-scheduling-rules",
|
href: "/dashboard/configuration/train-scheduling-rules",
|
||||||
@@ -586,6 +581,16 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
const ET_CLEARANCE_HREF = "/dashboard/contracts/clearance";
|
const ET_CLEARANCE_HREF = "/dashboard/contracts/clearance";
|
||||||
const DJ_CLEARANCE_HREF = "/dashboard/gl-djibouti/clearance";
|
const DJ_CLEARANCE_HREF = "/dashboard/gl-djibouti/clearance";
|
||||||
|
|
||||||
|
// Routes a GL officer may reach beyond their clearance hub. Path B booking is
|
||||||
|
// part of their job (create/rebook under a cleared contract, then view that
|
||||||
|
// booking's clearance), but those routes live outside the clearance prefix —
|
||||||
|
// without this allowlist the single-prefix lock bounces them out of their own
|
||||||
|
// workflow. Matched against location.pathname (no query string).
|
||||||
|
const GL_WORKFLOW_PATH_PATTERNS: RegExp[] = [
|
||||||
|
/^\/dashboard\/contracts\/[^/]+\/create-booking(\/|$)/,
|
||||||
|
/^\/dashboard\/bookings\/[^/]+\/clearance(\/|$)/,
|
||||||
|
];
|
||||||
|
|
||||||
const isEtClearanceItem = (item: SidebarItem): boolean =>
|
const isEtClearanceItem = (item: SidebarItem): boolean =>
|
||||||
item.href === ET_CLEARANCE_HREF;
|
item.href === ET_CLEARANCE_HREF;
|
||||||
const isDjClearanceItem = (item: SidebarItem): boolean =>
|
const isDjClearanceItem = (item: SidebarItem): boolean =>
|
||||||
@@ -627,7 +632,9 @@ const filterSidebarByPermission = (
|
|||||||
const filterItems = (items: SidebarItem[]): SidebarItem[] =>
|
const filterItems = (items: SidebarItem[]): SidebarItem[] =>
|
||||||
items
|
items
|
||||||
.map((item) =>
|
.map((item) =>
|
||||||
item.children ? { ...item, children: filterItems(item.children) } : item,
|
item.children
|
||||||
|
? { ...item, children: filterItems(item.children) }
|
||||||
|
: item,
|
||||||
)
|
)
|
||||||
.filter((item) => {
|
.filter((item) => {
|
||||||
if (etGl || djGl) {
|
if (etGl || djGl) {
|
||||||
@@ -719,7 +726,11 @@ const DashboardShell = () => {
|
|||||||
document.title = activeLabel ? `${activeLabel} | ${APP_TITLE}` : APP_TITLE;
|
document.title = activeLabel ? `${activeLabel} | ${APP_TITLE}` : APP_TITLE;
|
||||||
}, [location.pathname, sidebarSections]);
|
}, [location.pathname, sidebarSections]);
|
||||||
|
|
||||||
if (glClearanceHome && !location.pathname.startsWith(glClearanceHome)) {
|
if (
|
||||||
|
glClearanceHome &&
|
||||||
|
!location.pathname.startsWith(glClearanceHome) &&
|
||||||
|
!GL_WORKFLOW_PATH_PATTERNS.some((re) => re.test(location.pathname))
|
||||||
|
) {
|
||||||
return <Navigate to={glClearanceHome} replace />;
|
return <Navigate to={glClearanceHome} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -791,8 +802,22 @@ const App = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="support" element={<SupportInboxPage />} />
|
<Route path="support" element={<SupportInboxPage />} />
|
||||||
<Route path="customers" element={<CustomersPage />} />
|
<Route
|
||||||
<Route path="customers/:id" element={<CustomerDetailPage />} />
|
path="customers"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.customers.view}>
|
||||||
|
<CustomersPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="customers/:id"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.customers.view}>
|
||||||
|
<CustomerDetailPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="invoices"
|
path="invoices"
|
||||||
element={
|
element={
|
||||||
@@ -1102,7 +1127,7 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="routes"
|
path="routes"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
<RequirePermission permission={[FREIGHT_PERMS.routes.view, FREIGHT_PERMS.fleet.view]}>
|
||||||
<RoutesPage />
|
<RoutesPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -1110,7 +1135,7 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="locomotives"
|
path="locomotives"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
<RequirePermission permission={[FREIGHT_PERMS.locomotives.view, FREIGHT_PERMS.fleet.view]}>
|
||||||
<FleetResourcePage />
|
<FleetResourcePage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -1118,7 +1143,7 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="trains"
|
path="trains"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
||||||
<FleetResourcePage />
|
<FleetResourcePage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -1126,7 +1151,7 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="trains/:id"
|
path="trains/:id"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
||||||
<TrainDetailPage />
|
<TrainDetailPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -1134,7 +1159,7 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="train-builder"
|
path="train-builder"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
||||||
<TrainBuilderListPage />
|
<TrainBuilderListPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -1142,7 +1167,7 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="train-builder/:id"
|
path="train-builder/:id"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
||||||
<TrainBuilderDetailPage />
|
<TrainBuilderDetailPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -1150,7 +1175,7 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="wagons"
|
path="wagons"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
<RequirePermission permission={[FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view]}>
|
||||||
<FleetResourcePage />
|
<FleetResourcePage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -1158,7 +1183,7 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="containers"
|
path="containers"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
<RequirePermission permission={[FREIGHT_PERMS.containers.view, FREIGHT_PERMS.fleet.view]}>
|
||||||
<FleetResourcePage />
|
<FleetResourcePage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -1166,7 +1191,7 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="cargoes"
|
path="cargoes"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
<RequirePermission permission={[FREIGHT_PERMS.cargoes.view, FREIGHT_PERMS.fleet.view]}>
|
||||||
<FleetResourcePage />
|
<FleetResourcePage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -1252,7 +1277,7 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="routes"
|
path="routes"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
<RequirePermission permission={[FREIGHT_PERMS.routes.view, FREIGHT_PERMS.fleet.view]}>
|
||||||
<RoutesPage />
|
<RoutesPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -1340,7 +1365,7 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="locomotives"
|
path="locomotives"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
<RequirePermission permission={[FREIGHT_PERMS.locomotives.view, FREIGHT_PERMS.fleet.view]}>
|
||||||
<FleetResourcePage />
|
<FleetResourcePage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -1348,7 +1373,7 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="trains"
|
path="trains"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
||||||
<FleetResourcePage />
|
<FleetResourcePage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -1356,7 +1381,7 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="trains/:id"
|
path="trains/:id"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
||||||
<TrainDetailPage />
|
<TrainDetailPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -1364,7 +1389,7 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="train-builder"
|
path="train-builder"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
||||||
<TrainBuilderListPage />
|
<TrainBuilderListPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -1372,7 +1397,7 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="train-builder/:id"
|
path="train-builder/:id"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
||||||
<TrainBuilderDetailPage />
|
<TrainBuilderDetailPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -1380,7 +1405,7 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="wagons"
|
path="wagons"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
<RequirePermission permission={[FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view]}>
|
||||||
<FleetResourcePage />
|
<FleetResourcePage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -1388,7 +1413,7 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="containers"
|
path="containers"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
<RequirePermission permission={[FREIGHT_PERMS.containers.view, FREIGHT_PERMS.fleet.view]}>
|
||||||
<FleetResourcePage />
|
<FleetResourcePage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -1396,7 +1421,7 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="cargoes"
|
path="cargoes"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
<RequirePermission permission={[FREIGHT_PERMS.cargoes.view, FREIGHT_PERMS.fleet.view]}>
|
||||||
<FleetResourcePage />
|
<FleetResourcePage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -1463,14 +1488,14 @@ const App = () => {
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
{/* <Route
|
||||||
path="configuration/contract-validity-periods"
|
path="configuration/contract-validity-periods"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.admin}>
|
<RequirePermission permission={FREIGHT_PERMS.admin}>
|
||||||
<ContractValidityPeriodsPage />
|
<ContractValidityPeriodsPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/> */}
|
||||||
<Route path="configuration/cargo-types" element={<CargoTypesPage />} />
|
<Route path="configuration/cargo-types" element={<CargoTypesPage />} />
|
||||||
<Route
|
<Route
|
||||||
path="configuration/cargo-types/:id"
|
path="configuration/cargo-types/:id"
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { contractsService } from "@/services/contracts.service";
|
import { contractsService } from "@/services/contracts.service";
|
||||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||||
@@ -48,8 +50,19 @@ export function ContractActionsToolbar({
|
|||||||
onReviewClearance,
|
onReviewClearance,
|
||||||
}: ContractActionsToolbarProps) {
|
}: ContractActionsToolbarProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { user } = useAuth();
|
||||||
const { status } = contract;
|
const { status } = contract;
|
||||||
|
|
||||||
|
// Intake permissions are split per freight type: an accept:bulk holder must
|
||||||
|
// not see the accept button on a container contract (API enforces the same).
|
||||||
|
const arm = contract.freightType === "BULK" ? "bulk" : "container";
|
||||||
|
const mayAccept = hasPermission(user, FREIGHT_PERMS.contracts.staffAccept[arm]);
|
||||||
|
const mayRequestChanges = hasPermission(
|
||||||
|
user,
|
||||||
|
FREIGHT_PERMS.contracts.requestChanges[arm],
|
||||||
|
);
|
||||||
|
const mayReject = hasPermission(user, FREIGHT_PERMS.contracts.reject[arm]);
|
||||||
|
|
||||||
const [editorOpen, setEditorOpen] = useState(false);
|
const [editorOpen, setEditorOpen] = useState(false);
|
||||||
const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept");
|
const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept");
|
||||||
const [previewOpen, setPreviewOpen] = useState(false);
|
const [previewOpen, setPreviewOpen] = useState(false);
|
||||||
@@ -97,7 +110,8 @@ export function ContractActionsToolbar({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const canAccept = status === "SUBMITTED";
|
const canAccept =
|
||||||
|
status === "SUBMITTED" && (mayAccept || mayRequestChanges || mayReject);
|
||||||
// The document stays editable for the whole approval chain, but only by the
|
// The document stays editable for the whole approval chain, but only by the
|
||||||
// approver whose turn it is. The server resolves that against the caller's
|
// approver whose turn it is. The server resolves that against the caller's
|
||||||
// position type; the client cannot derive it.
|
// position type; the client cannot derive it.
|
||||||
@@ -126,35 +140,41 @@ export function ContractActionsToolbar({
|
|||||||
|
|
||||||
{canAccept && (
|
{canAccept && (
|
||||||
<>
|
<>
|
||||||
<Button
|
{mayAccept && (
|
||||||
fullWidth
|
<Button
|
||||||
color="edr-green"
|
fullWidth
|
||||||
leftSection={<Check size={16} />}
|
color="edr-green"
|
||||||
onClick={() => {
|
leftSection={<Check size={16} />}
|
||||||
setEditorMode("accept");
|
onClick={() => {
|
||||||
setEditorOpen(true);
|
setEditorMode("accept");
|
||||||
}}
|
setEditorOpen(true);
|
||||||
>
|
}}
|
||||||
Accept for approval
|
>
|
||||||
</Button>
|
Accept for approval
|
||||||
<Button
|
</Button>
|
||||||
fullWidth
|
)}
|
||||||
variant="light"
|
{mayRequestChanges && (
|
||||||
color="orange"
|
<Button
|
||||||
leftSection={<MessageSquareWarning size={16} />}
|
fullWidth
|
||||||
onClick={() => setChangesOpen(true)}
|
variant="light"
|
||||||
>
|
color="orange"
|
||||||
Request changes
|
leftSection={<MessageSquareWarning size={16} />}
|
||||||
</Button>
|
onClick={() => setChangesOpen(true)}
|
||||||
<Button
|
>
|
||||||
fullWidth
|
Request changes
|
||||||
variant="light"
|
</Button>
|
||||||
color="red"
|
)}
|
||||||
leftSection={<XCircle size={16} />}
|
{mayReject && (
|
||||||
onClick={() => setRejectOpen(true)}
|
<Button
|
||||||
>
|
fullWidth
|
||||||
Reject contract
|
variant="light"
|
||||||
</Button>
|
color="red"
|
||||||
|
leftSection={<XCircle size={16} />}
|
||||||
|
onClick={() => setRejectOpen(true)}
|
||||||
|
>
|
||||||
|
Reject contract
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import type { Freight } from "@edr/types";
|
|||||||
import { formatContractApprovalProgress } from "@/features/contracts/contract-approval-progress";
|
import { formatContractApprovalProgress } from "@/features/contracts/contract-approval-progress";
|
||||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||||
import type { useContractMutations } from "@/hooks/contracts/useContracts";
|
import type { useContractMutations } from "@/hooks/contracts/useContracts";
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
import { canApproveContractStep } from "@/lib/permissions";
|
||||||
|
|
||||||
type Mutations = ReturnType<typeof useContractMutations>;
|
type Mutations = ReturnType<typeof useContractMutations>;
|
||||||
|
|
||||||
@@ -29,6 +31,7 @@ export function ContractApprovalStepsCard({
|
|||||||
contract,
|
contract,
|
||||||
mutations,
|
mutations,
|
||||||
}: ContractApprovalStepsCardProps) {
|
}: ContractApprovalStepsCardProps) {
|
||||||
|
const { user } = useAuth();
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
const [pendingStep, setPendingStep] =
|
const [pendingStep, setPendingStep] =
|
||||||
useState<Freight.IContractApprovalStep | null>(null);
|
useState<Freight.IContractApprovalStep | null>(null);
|
||||||
@@ -166,6 +169,10 @@ export function ContractApprovalStepsCard({
|
|||||||
key={step.id}
|
key={step.id}
|
||||||
step={step}
|
step={step}
|
||||||
isNext={actionable && nextPending?.id === step.id}
|
isNext={actionable && nextPending?.id === step.id}
|
||||||
|
// Buttons show only to the step's actual approver (matching
|
||||||
|
// position type): a chief step never offers Approve/Reject to a
|
||||||
|
// marketing officer. Everyone still sees the "next" highlight.
|
||||||
|
canAct={canApproveContractStep(user, step.requiredRole)}
|
||||||
isPending={
|
isPending={
|
||||||
mutations.approveStep.isPending ||
|
mutations.approveStep.isPending ||
|
||||||
mutations.rejectStep.isPending
|
mutations.rejectStep.isPending
|
||||||
@@ -306,12 +313,14 @@ export function ContractApprovalStepsCard({
|
|||||||
function StepRow({
|
function StepRow({
|
||||||
step,
|
step,
|
||||||
isNext,
|
isNext,
|
||||||
|
canAct,
|
||||||
isPending,
|
isPending,
|
||||||
onApprove,
|
onApprove,
|
||||||
onReject,
|
onReject,
|
||||||
}: {
|
}: {
|
||||||
step: Freight.IContractApprovalStep;
|
step: Freight.IContractApprovalStep;
|
||||||
isNext: boolean;
|
isNext: boolean;
|
||||||
|
canAct: boolean;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
onApprove: () => void;
|
onApprove: () => void;
|
||||||
onReject: () => void;
|
onReject: () => void;
|
||||||
@@ -372,8 +381,11 @@ function StepRow({
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
|
{/* One element type per row: action buttons on the active step (they
|
||||||
{isNext && step.status === "PENDING" && (
|
already imply "pending & actionable"), a status badge otherwise.
|
||||||
|
Mixing compact buttons + a badge here made them read as misaligned. */}
|
||||||
|
<Group gap="xs" wrap="nowrap" align="center" style={{ flexShrink: 0 }}>
|
||||||
|
{isNext && canAct && step.status === "PENDING" ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
size="compact-sm"
|
size="compact-sm"
|
||||||
@@ -395,16 +407,17 @@ function StepRow({
|
|||||||
Reject
|
Reject
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
|
) : (
|
||||||
|
<Badge
|
||||||
|
variant="light"
|
||||||
|
color={statusColor}
|
||||||
|
size="sm"
|
||||||
|
radius="sm"
|
||||||
|
tt="uppercase"
|
||||||
|
>
|
||||||
|
{step.status}
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<Badge
|
|
||||||
variant="light"
|
|
||||||
color={statusColor}
|
|
||||||
size="sm"
|
|
||||||
radius="sm"
|
|
||||||
tt="uppercase"
|
|
||||||
>
|
|
||||||
{step.status}
|
|
||||||
</Badge>
|
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -269,6 +269,8 @@ export default function GlCreateBookingForm() {
|
|||||||
const [scheduledDate, setScheduledDate] = useState("");
|
const [scheduledDate, setScheduledDate] = useState("");
|
||||||
const [contractRouteId, setContractRouteId] = useState<string | null>(null);
|
const [contractRouteId, setContractRouteId] = useState<string | null>(null);
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
|
// What the containers carry — captured per booking (moved off the contract).
|
||||||
|
const [cargoDescription, setCargoDescription] = useState("");
|
||||||
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
|
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
|
||||||
const [bulk, setBulk] = useState<BulkDraft>({
|
const [bulk, setBulk] = useState<BulkDraft>({
|
||||||
cargoWeightTons: "",
|
cargoWeightTons: "",
|
||||||
@@ -459,6 +461,10 @@ export default function GlCreateBookingForm() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
setPrefilled(true);
|
setPrefilled(true);
|
||||||
|
// Rebook carries the expired booking's cargo description forward.
|
||||||
|
if (copyFromBooking.cargoFreeText) {
|
||||||
|
setCargoDescription(copyFromBooking.cargoFreeText);
|
||||||
|
}
|
||||||
setContainerLines(
|
setContainerLines(
|
||||||
lines.map((c) => {
|
lines.map((c) => {
|
||||||
const qty = Math.max(1, c.quantity);
|
const qty = Math.max(1, c.quantity);
|
||||||
@@ -705,14 +711,23 @@ export default function GlCreateBookingForm() {
|
|||||||
|
|
||||||
const lineErrors = useMemo<LineErrors[]>(() => {
|
const lineErrors = useMemo<LineErrors[]>(() => {
|
||||||
if (!isContainer || !contract) return [];
|
if (!isContainer || !contract) return [];
|
||||||
return containerLines.map((line) => {
|
// A line can be 0 (the contract covers both sizes; a booking may only need
|
||||||
|
// one) but the booking as a whole needs at least one container — anchor
|
||||||
|
// that error on the first line's quantity so it renders in the field.
|
||||||
|
const totalQty = containerLines.reduce(
|
||||||
|
(sum, l) => sum + Math.max(0, Number(l.quantity) || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
return containerLines.map((line, idx) => {
|
||||||
const errs: LineErrors = {};
|
const errs: LineErrors = {};
|
||||||
const qty = Number(line.quantity || 0);
|
const qty = Number(line.quantity || 0);
|
||||||
if (line.quantity.trim() === "") {
|
if (line.quantity.trim() === "") {
|
||||||
errs.quantity = "Quantity is required.";
|
errs.quantity = "Quantity is required.";
|
||||||
} else if (Number.isNaN(qty) || qty < 1) {
|
} else if (Number.isNaN(qty) || qty < 0) {
|
||||||
errs.quantity = "At least 1.";
|
errs.quantity = "Enter 0 or more.";
|
||||||
} else if (line.units.length < qty) {
|
} else if (idx === 0 && totalQty < 1) {
|
||||||
|
errs.quantity = "Book at least one container (either size).";
|
||||||
|
} else if (qty >= 1 && line.units.length < qty) {
|
||||||
errs.units = `Enter details for all ${qty} container(s).`;
|
errs.units = `Enter details for all ${qty} container(s).`;
|
||||||
}
|
}
|
||||||
if (contract.isHazardous) {
|
if (contract.isHazardous) {
|
||||||
@@ -789,6 +804,11 @@ export default function GlCreateBookingForm() {
|
|||||||
const routeError =
|
const routeError =
|
||||||
multiRoute && !contractRouteId ? "Select a route." : undefined;
|
multiRoute && !contractRouteId ? "Select a route." : undefined;
|
||||||
|
|
||||||
|
const cargoDescriptionError =
|
||||||
|
isContainer && !cargoDescription.trim()
|
||||||
|
? "Describe the cargo carried in the containers."
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const cargoValid = isContainer
|
const cargoValid = isContainer
|
||||||
? lineErrors.every(
|
? lineErrors.every(
|
||||||
(e) =>
|
(e) =>
|
||||||
@@ -800,7 +820,8 @@ export default function GlCreateBookingForm() {
|
|||||||
) &&
|
) &&
|
||||||
unitErrors.every((line) =>
|
unitErrors.every((line) =>
|
||||||
line.every((e) => !e.containerNumber && !e.vgmTons),
|
line.every((e) => !e.containerNumber && !e.vgmTons),
|
||||||
)
|
) &&
|
||||||
|
!cargoDescriptionError
|
||||||
: !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer;
|
: !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer;
|
||||||
|
|
||||||
const formValid = cargoValid && !hasOdd20ft && !dateError && !routeError;
|
const formValid = cargoValid && !hasOdd20ft && !dateError && !routeError;
|
||||||
@@ -827,6 +848,8 @@ export default function GlCreateBookingForm() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (isContainer) {
|
if (isContainer) {
|
||||||
|
// What the containers carry — captured per booking, not on the contract.
|
||||||
|
if (cargoDescription.trim()) payload.cargoFreeText = cargoDescription.trim();
|
||||||
payload.containers = containerLines
|
payload.containers = containerLines
|
||||||
.filter((l) => Number(l.quantity) >= 1)
|
.filter((l) => Number(l.quantity) >= 1)
|
||||||
.map((l) => ({
|
.map((l) => ({
|
||||||
@@ -1245,6 +1268,19 @@ export default function GlCreateBookingForm() {
|
|||||||
)}
|
)}
|
||||||
{remainderNotice}
|
{remainderNotice}
|
||||||
<ContractCapacityNotice contractId={contract.id} isContainer />
|
<ContractCapacityNotice contractId={contract.id} isContainer />
|
||||||
|
<Textarea
|
||||||
|
label="Cargo description *"
|
||||||
|
description="What do the containers carry on this shipment?"
|
||||||
|
placeholder="e.g. Electronics, garments, machinery spare parts…"
|
||||||
|
value={cargoDescription}
|
||||||
|
onChange={(e) => setCargoDescription(e.currentTarget.value)}
|
||||||
|
error={showErrors ? cargoDescriptionError : undefined}
|
||||||
|
radius={10}
|
||||||
|
autosize
|
||||||
|
minRows={2}
|
||||||
|
maxRows={4}
|
||||||
|
styles={fieldStyles}
|
||||||
|
/>
|
||||||
{containerLines.length === 0 ? (
|
{containerLines.length === 0 ? (
|
||||||
<Text fz="sm" c="dimmed">
|
<Text fz="sm" c="dimmed">
|
||||||
This contract has no container sizes in scope.
|
This contract has no container sizes in scope.
|
||||||
@@ -1264,7 +1300,7 @@ export default function GlCreateBookingForm() {
|
|||||||
type="number"
|
type="number"
|
||||||
onKeyDown={blockNegative}
|
onKeyDown={blockNegative}
|
||||||
label="Quantity *"
|
label="Quantity *"
|
||||||
min={1}
|
min={0}
|
||||||
value={line.quantity}
|
value={line.quantity}
|
||||||
error={
|
error={
|
||||||
showErrors
|
showErrors
|
||||||
|
|||||||
@@ -65,8 +65,12 @@ export function computeGlShipmentTotal(
|
|||||||
(i) =>
|
(i) =>
|
||||||
i.containerSize === line.containerSize &&
|
i.containerSize === line.containerSize &&
|
||||||
i.unit === "per_container" &&
|
i.unit === "per_container" &&
|
||||||
!i.conditionalOn,
|
!i.conditionalOn &&
|
||||||
) ?? rateFor((i) => i.containerSize === line.containerSize);
|
!i.isClearance,
|
||||||
|
) ??
|
||||||
|
rateFor(
|
||||||
|
(i) => i.containerSize === line.containerSize && !i.isClearance,
|
||||||
|
);
|
||||||
if (rate) {
|
if (rate) {
|
||||||
lines.push({
|
lines.push({
|
||||||
label: rate.label,
|
label: rate.label,
|
||||||
@@ -123,7 +127,12 @@ export function computeGlShipmentTotal(
|
|||||||
} else {
|
} else {
|
||||||
const qty = q.bulkQuantity;
|
const qty = q.bulkQuantity;
|
||||||
const rate =
|
const rate =
|
||||||
rateFor((i) => i.unit === "per_ton" || i.unit === "per_item") ?? items[0];
|
rateFor(
|
||||||
|
(i) =>
|
||||||
|
(i.unit === "per_ton" || i.unit === "per_item") &&
|
||||||
|
!i.isClearance &&
|
||||||
|
!i.conditionalOn,
|
||||||
|
) ?? items[0];
|
||||||
if (rate && qty > 0) {
|
if (rate && qty > 0) {
|
||||||
lines.push({
|
lines.push({
|
||||||
label: rate.label,
|
label: rate.label,
|
||||||
@@ -159,6 +168,53 @@ export function computeGlShipmentTotal(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lashing / cargo securing — bulk-only, applies whenever the contract shows
|
||||||
|
// it (the commodity needs lashing). Per-ton scales by tonnage; per-wagon
|
||||||
|
// depends on the wagon capacity the train stocks — shown at real pricing.
|
||||||
|
const lashing = items.find((i) => i.conditionalOn === "has_lashing");
|
||||||
|
if (lashing && lashing.unit === "per_ton") {
|
||||||
|
const tons = q.bulkQuantity;
|
||||||
|
if (tons > 0) {
|
||||||
|
lines.push({
|
||||||
|
label: lashing.label,
|
||||||
|
unitPrice: lashing.unitPrice,
|
||||||
|
unit: lashing.unit,
|
||||||
|
quantity: tons,
|
||||||
|
amount: lashing.unitPrice * tons,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Customs clearance service fee — billed on the booking invoice with the
|
||||||
|
// freight. Container fees estimate per size (per box, or per wagon: two 20ft
|
||||||
|
// share one); bulk per-ton scales by tonnage. Bulk per-wagon fees depend on
|
||||||
|
// the wagon capacity the train stocks — shown at real pricing, not estimated.
|
||||||
|
for (const cl of items.filter((i) => i.isClearance)) {
|
||||||
|
let qty = 0;
|
||||||
|
if (q.isContainer) {
|
||||||
|
const boxes = q.containers
|
||||||
|
.filter((c) => c.containerSize === cl.containerSize)
|
||||||
|
.reduce((s, c) => s + Number(c.quantity || 0), 0);
|
||||||
|
qty =
|
||||||
|
cl.unit === "per_wagon"
|
||||||
|
? Math.ceil(boxes * (cl.containerSize === "40ft" ? 1 : 0.5))
|
||||||
|
: boxes;
|
||||||
|
} else if (cl.unit === "per_ton") {
|
||||||
|
qty = q.bulkQuantity;
|
||||||
|
} else if (cl.unit === "flat") {
|
||||||
|
qty = 1;
|
||||||
|
}
|
||||||
|
if (qty > 0) {
|
||||||
|
lines.push({
|
||||||
|
label: cl.label,
|
||||||
|
unitPrice: cl.unitPrice,
|
||||||
|
unit: cl.unit,
|
||||||
|
quantity: qty,
|
||||||
|
amount: cl.unitPrice * qty,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const total = lines.reduce((s, l) => s + l.amount, 0);
|
const total = lines.reduce((s, l) => s + l.amount, 0);
|
||||||
return { currency, lines, total };
|
return { currency, lines, total };
|
||||||
}
|
}
|
||||||
@@ -167,6 +223,7 @@ export function computeGlShipmentTotal(
|
|||||||
export function formatRateUnit(unit: Freight.ContractRateUnit | string): string {
|
export function formatRateUnit(unit: Freight.ContractRateUnit | string): string {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
per_container: "container",
|
per_container: "container",
|
||||||
|
per_wagon: "wagon",
|
||||||
per_ton: "ton",
|
per_ton: "ton",
|
||||||
per_item: "item",
|
per_item: "item",
|
||||||
per_km: "km",
|
per_km: "km",
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import {
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useFileViewer } from "@edr/ui-common";
|
import { useFileViewer } from "@edr/ui-common";
|
||||||
|
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||||
import { fetchViewableFile } from "@/services/files.service";
|
import { fetchViewableFile } from "@/services/files.service";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import type { Company, CompanyChangeRequest } from "@/types/customer";
|
import type { Company, CompanyChangeRequest } from "@/types/customer";
|
||||||
@@ -128,6 +130,8 @@ function DiffRow({
|
|||||||
* (with note) actions, plus a short history of past decisions.
|
* (with note) actions, plus a short history of past decisions.
|
||||||
*/
|
*/
|
||||||
export function ChangeRequestReview({ company }: { company: Company }) {
|
export function ChangeRequestReview({ company }: { company: Company }) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const canReview = hasPermission(user, FREIGHT_PERMS.customers.verify);
|
||||||
const query = useQuery(
|
const query = useQuery(
|
||||||
api.customers.changeRequests.queryOptions({ input: { id: company.id } }),
|
api.customers.changeRequests.queryOptions({ input: { id: company.id } }),
|
||||||
);
|
);
|
||||||
@@ -323,25 +327,30 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
|||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Group justify="flex-end" gap="sm">
|
{/* Reviewing the diff is `customers:view`; deciding on it is
|
||||||
<Button
|
`customers:verify`. Without it the request stays readable but
|
||||||
variant="light"
|
un-actionable. */}
|
||||||
color="red"
|
{canReview && (
|
||||||
onClick={() => {
|
<Group justify="flex-end" gap="sm">
|
||||||
setRejectId(pending.id);
|
<Button
|
||||||
setNote("");
|
variant="light"
|
||||||
}}
|
color="red"
|
||||||
>
|
onClick={() => {
|
||||||
Reject
|
setRejectId(pending.id);
|
||||||
</Button>
|
setNote("");
|
||||||
<Button
|
}}
|
||||||
color="edr-green"
|
>
|
||||||
loading={approve.isPending}
|
Reject
|
||||||
onClick={() => approve.mutate({ id: pending.id })}
|
</Button>
|
||||||
>
|
<Button
|
||||||
Approve changes
|
color="edr-green"
|
||||||
</Button>
|
loading={approve.isPending}
|
||||||
</Group>
|
onClick={() => approve.mutate({ id: pending.id })}
|
||||||
|
>
|
||||||
|
Approve changes
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -286,6 +288,19 @@ export function InvoiceStatusBadge({
|
|||||||
* regardless (setCompanyProfileStatus). Suspend/blacklist/reinstate stay live so
|
* regardless (setCompanyProfileStatus). Suspend/blacklist/reinstate stay live so
|
||||||
* an already-active profile is still managable.
|
* an already-active profile is still managable.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Which permission each status write needs. Mirrors `STATUS_PERM` in the API's
|
||||||
|
* `companies.controller.ts` — approving is a different authority from
|
||||||
|
* suspending, and both go through the same endpoint. Keep the two in step.
|
||||||
|
*/
|
||||||
|
const STATUS_PERM: Record<ProfileStatus, string> = {
|
||||||
|
active: FREIGHT_PERMS.customers.verify,
|
||||||
|
pending: FREIGHT_PERMS.customers.verify,
|
||||||
|
rejected: FREIGHT_PERMS.customers.verify,
|
||||||
|
suspended: FREIGHT_PERMS.customers.deactivate,
|
||||||
|
blacklisted: FREIGHT_PERMS.customers.deactivate,
|
||||||
|
};
|
||||||
|
|
||||||
export function ProfileApprovalActions({
|
export function ProfileApprovalActions({
|
||||||
profileId,
|
profileId,
|
||||||
status,
|
status,
|
||||||
@@ -295,6 +310,10 @@ export function ProfileApprovalActions({
|
|||||||
status: ProfileStatus;
|
status: ProfileStatus;
|
||||||
locked?: boolean;
|
locked?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
/** The API rejects these anyway — hide rather than offer a button that 403s. */
|
||||||
|
const canSet = (next: ProfileStatus) =>
|
||||||
|
hasPermission(user, STATUS_PERM[next]);
|
||||||
const { mutate, isPending } = useMutation(
|
const { mutate, isPending } = useMutation(
|
||||||
api.customers.setProfileStatus.mutationOptions(),
|
api.customers.setProfileStatus.mutationOptions(),
|
||||||
);
|
);
|
||||||
@@ -414,35 +433,41 @@ export function ProfileApprovalActions({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (status === "pending") {
|
if (status === "pending") {
|
||||||
|
if (!canSet("active") && !canSet("rejected")) return null;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{decisionModal}
|
{decisionModal}
|
||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={6} wrap="nowrap">
|
||||||
<Button
|
{canSet("active") && (
|
||||||
size="xs"
|
<Button
|
||||||
variant="light"
|
size="xs"
|
||||||
color="edr-green"
|
variant="light"
|
||||||
radius="md"
|
color="edr-green"
|
||||||
loading={isPending}
|
radius="md"
|
||||||
onClick={() => act("active")}
|
loading={isPending}
|
||||||
>
|
onClick={() => act("active")}
|
||||||
Approve
|
>
|
||||||
</Button>
|
Approve
|
||||||
<Button
|
</Button>
|
||||||
size="xs"
|
)}
|
||||||
variant="light"
|
{canSet("rejected") && (
|
||||||
color="red"
|
<Button
|
||||||
radius="md"
|
size="xs"
|
||||||
onClick={() => openDecision("reject")}
|
variant="light"
|
||||||
>
|
color="red"
|
||||||
Reject
|
radius="md"
|
||||||
</Button>
|
onClick={() => openDecision("reject")}
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === "rejected") {
|
if (status === "rejected") {
|
||||||
|
if (!canSet("active")) return null;
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
@@ -458,6 +483,7 @@ export function ProfileApprovalActions({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (status === "active") {
|
if (status === "active") {
|
||||||
|
if (!canSet("suspended")) return null;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{decisionModal}
|
{decisionModal}
|
||||||
@@ -476,34 +502,40 @@ export function ProfileApprovalActions({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (status === "suspended") {
|
if (status === "suspended") {
|
||||||
|
if (!canSet("active") && !canSet("blacklisted")) return null;
|
||||||
return (
|
return (
|
||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={6} wrap="nowrap">
|
||||||
{decisionModal}
|
{decisionModal}
|
||||||
<Button
|
{canSet("active") && (
|
||||||
size="xs"
|
<Button
|
||||||
variant="light"
|
size="xs"
|
||||||
color="edr-green"
|
variant="light"
|
||||||
radius="md"
|
color="edr-green"
|
||||||
loading={isPending}
|
radius="md"
|
||||||
onClick={() => openDecision("reactivate")}
|
loading={isPending}
|
||||||
>
|
onClick={() => openDecision("reactivate")}
|
||||||
Reactivate
|
>
|
||||||
</Button>
|
Reactivate
|
||||||
<Button
|
</Button>
|
||||||
size="xs"
|
)}
|
||||||
variant="light"
|
{canSet("blacklisted") && (
|
||||||
color="red"
|
<Button
|
||||||
radius="md"
|
size="xs"
|
||||||
loading={isPending}
|
variant="light"
|
||||||
onClick={() => act("blacklisted")}
|
color="red"
|
||||||
>
|
radius="md"
|
||||||
Blacklist
|
loading={isPending}
|
||||||
</Button>
|
onClick={() => act("blacklisted")}
|
||||||
|
>
|
||||||
|
Blacklist
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === "blacklisted") {
|
if (status === "blacklisted") {
|
||||||
|
if (!canSet("pending")) return null;
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
|
|||||||
@@ -19,8 +19,9 @@ export interface FleetCardGridProps {
|
|||||||
pageCount: number;
|
pageCount: number;
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
onPaginationChange: OnChangeFn<PaginationState>;
|
onPaginationChange: OnChangeFn<PaginationState>;
|
||||||
onEdit: (record: FleetRecord) => void;
|
/** Omit to hide the action (caller lacks the update/delete permission). */
|
||||||
onRemove: (record: FleetRecord) => void;
|
onEdit?: (record: FleetRecord) => void;
|
||||||
|
onRemove?: (record: FleetRecord) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FleetCardGrid = ({
|
const FleetCardGrid = ({
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import type { FleetRecord } from "@/services/fleet/fleet.service";
|
|||||||
export interface FleetRecordActionsProps {
|
export interface FleetRecordActionsProps {
|
||||||
record: FleetRecord;
|
record: FleetRecord;
|
||||||
config: FleetResourceConfig;
|
config: FleetResourceConfig;
|
||||||
onEdit: (record: FleetRecord) => void;
|
/** Omit to hide the action (caller lacks the update/delete permission). */
|
||||||
onRemove: (record: FleetRecord) => void;
|
onEdit?: (record: FleetRecord) => void;
|
||||||
|
onRemove?: (record: FleetRecord) => void;
|
||||||
onAssignDriver?: (record: FleetRecord) => void;
|
onAssignDriver?: (record: FleetRecord) => void;
|
||||||
onHistory?: (record: FleetRecord) => void;
|
onHistory?: (record: FleetRecord) => void;
|
||||||
onViewDetail?: (record: FleetRecord) => void;
|
onViewDetail?: (record: FleetRecord) => void;
|
||||||
@@ -42,6 +43,17 @@ const FleetRecordActions = ({
|
|||||||
navigate(config.detailPath.replace(":id", String(record.id)));
|
navigate(config.detailPath.replace(":id", String(record.id)));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
!onEdit &&
|
||||||
|
!onRemove &&
|
||||||
|
!showDetail &&
|
||||||
|
!showViewDetail &&
|
||||||
|
!showHistory &&
|
||||||
|
!(isVehicle && onAssignDriver)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (layout === "compact") {
|
if (layout === "compact") {
|
||||||
return (
|
return (
|
||||||
<Menu position="bottom-end" withinPortal shadow="md">
|
<Menu position="bottom-end" withinPortal shadow="md">
|
||||||
@@ -61,12 +73,14 @@ const FleetRecordActions = ({
|
|||||||
Assign Driver
|
Assign Driver
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
<MenuItem
|
{onEdit ? (
|
||||||
onClick={() => onEdit(record)}
|
<MenuItem
|
||||||
leftSection={<Edit2 size={14} strokeWidth={2} />}
|
onClick={() => onEdit(record)}
|
||||||
>
|
leftSection={<Edit2 size={14} strokeWidth={2} />}
|
||||||
Edit
|
>
|
||||||
</MenuItem>
|
Edit
|
||||||
|
</MenuItem>
|
||||||
|
) : null}
|
||||||
{showViewDetail ? (
|
{showViewDetail ? (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={() => onViewDetail?.(record)}
|
onClick={() => onViewDetail?.(record)}
|
||||||
@@ -91,13 +105,15 @@ const FleetRecordActions = ({
|
|||||||
View details
|
View details
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
<MenuItem
|
{onRemove ? (
|
||||||
color="red"
|
<MenuItem
|
||||||
onClick={() => onRemove(record)}
|
color="red"
|
||||||
leftSection={<Trash2 size={14} strokeWidth={2} />}
|
onClick={() => onRemove(record)}
|
||||||
>
|
leftSection={<Trash2 size={14} strokeWidth={2} />}
|
||||||
{removeLabel}
|
>
|
||||||
</MenuItem>
|
{removeLabel}
|
||||||
|
</MenuItem>
|
||||||
|
) : null}
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
@@ -121,12 +137,14 @@ const FleetRecordActions = ({
|
|||||||
Assign Driver
|
Assign Driver
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
<MenuItem
|
{onEdit ? (
|
||||||
onClick={() => onEdit(record)}
|
<MenuItem
|
||||||
leftSection={<Edit2 size={14} strokeWidth={2} />}
|
onClick={() => onEdit(record)}
|
||||||
>
|
leftSection={<Edit2 size={14} strokeWidth={2} />}
|
||||||
Edit
|
>
|
||||||
</MenuItem>
|
Edit
|
||||||
|
</MenuItem>
|
||||||
|
) : null}
|
||||||
{showViewDetail ? (
|
{showViewDetail ? (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={() => onViewDetail?.(record)}
|
onClick={() => onViewDetail?.(record)}
|
||||||
@@ -143,13 +161,15 @@ const FleetRecordActions = ({
|
|||||||
View details
|
View details
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
<MenuItem
|
{onRemove ? (
|
||||||
color="red"
|
<MenuItem
|
||||||
onClick={() => onRemove(record)}
|
color="red"
|
||||||
leftSection={<Trash2 size={14} strokeWidth={2} />}
|
onClick={() => onRemove(record)}
|
||||||
>
|
leftSection={<Trash2 size={14} strokeWidth={2} />}
|
||||||
{removeLabel}
|
>
|
||||||
</MenuItem>
|
{removeLabel}
|
||||||
|
</MenuItem>
|
||||||
|
) : null}
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -209,6 +209,13 @@ const RuleEngineFormDialog = ({
|
|||||||
next.containerTypeId = "";
|
next.containerTypeId = "";
|
||||||
next.cargoTypeId = "";
|
next.cargoTypeId = "";
|
||||||
}
|
}
|
||||||
|
// Cargo kind (customs / lashing) decides both the container-type scope
|
||||||
|
// and the legal units (container → per box/wagon, bulk → per ton/wagon).
|
||||||
|
if (name === "cargoKind") {
|
||||||
|
next.containerTypeId = "";
|
||||||
|
next.cargoTypeId = "";
|
||||||
|
next.rateUnit = "";
|
||||||
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -333,6 +340,10 @@ const RuleEngineFormDialog = ({
|
|||||||
value={resolveSelectValue(field, values)}
|
value={resolveSelectValue(field, values)}
|
||||||
onChange={(v) => setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)}
|
onChange={(v) => setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)}
|
||||||
disabled={selectOptionsLoading}
|
disabled={selectOptionsLoading}
|
||||||
|
// Native required blocks submit while a mandatory select is empty —
|
||||||
|
// without it the form posts and the API 400s (e.g. a container
|
||||||
|
// customs/lashing rate with no container type picked).
|
||||||
|
required={field.required}
|
||||||
data={options
|
data={options
|
||||||
.filter((opt) => opt.value !== "")
|
.filter((opt) => opt.value !== "")
|
||||||
.map((opt) => ({
|
.map((opt) => ({
|
||||||
|
|||||||
@@ -287,6 +287,8 @@ export type SegmentStripBooking = {
|
|||||||
destinationYardId?: string | null;
|
destinationYardId?: string | null;
|
||||||
tradeDirection?: string | null;
|
tradeDirection?: string | null;
|
||||||
wagonsRequired?: number | null;
|
wagonsRequired?: number | null;
|
||||||
|
/** GROSS tons (cargo + tare of the booking's wagons), as the API sends it. */
|
||||||
|
weightTons?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -300,10 +302,13 @@ export function SegmentOccupancyStrip({
|
|||||||
stops,
|
stops,
|
||||||
bookings,
|
bookings,
|
||||||
maxWagons,
|
maxWagons,
|
||||||
|
maxGrossTons,
|
||||||
}: {
|
}: {
|
||||||
stops: Array<{ yardId: string; label: string }>;
|
stops: Array<{ yardId: string; label: string }>;
|
||||||
bookings: SegmentStripBooking[];
|
bookings: SegmentStripBooking[];
|
||||||
maxWagons?: number | null;
|
maxWagons?: number | null;
|
||||||
|
/** Loco pull ceiling incl. tolerance — per-leg gross is measured against it. */
|
||||||
|
maxGrossTons?: number | null;
|
||||||
}) {
|
}) {
|
||||||
if (stops.length < 2) return null;
|
if (stops.length < 2) return null;
|
||||||
const lastIdx = stops.length - 1;
|
const lastIdx = stops.length - 1;
|
||||||
@@ -312,6 +317,7 @@ export function SegmentOccupancyStrip({
|
|||||||
const segments = stops.slice(0, -1).map((stop, edge) => {
|
const segments = stops.slice(0, -1).map((stop, edge) => {
|
||||||
let cargo = 0;
|
let cargo = 0;
|
||||||
let intercity = 0;
|
let intercity = 0;
|
||||||
|
let grossTons = 0;
|
||||||
for (const b of bookings) {
|
for (const b of bookings) {
|
||||||
const from = (b.originYardId ? indexOf.get(b.originYardId) : undefined) ?? 0;
|
const from = (b.originYardId ? indexOf.get(b.originYardId) : undefined) ?? 0;
|
||||||
const to =
|
const to =
|
||||||
@@ -322,8 +328,15 @@ export function SegmentOccupancyStrip({
|
|||||||
const wagons = Number(b.wagonsRequired) || 1;
|
const wagons = Number(b.wagonsRequired) || 1;
|
||||||
if (b.tradeDirection === "DOMESTIC") intercity += wagons;
|
if (b.tradeDirection === "DOMESTIC") intercity += wagons;
|
||||||
else cargo += wagons;
|
else cargo += wagons;
|
||||||
|
grossTons += Number(b.weightTons) || 0;
|
||||||
}
|
}
|
||||||
return { from: stop, to: stops[edge + 1], cargo, intercity };
|
return {
|
||||||
|
from: stop,
|
||||||
|
to: stops[edge + 1],
|
||||||
|
cargo,
|
||||||
|
intercity,
|
||||||
|
grossTons: Math.round(grossTons * 10) / 10,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const cap = Number(maxWagons) || null;
|
const cap = Number(maxWagons) || null;
|
||||||
@@ -393,6 +406,22 @@ export function SegmentOccupancyStrip({
|
|||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</Text>
|
</Text>
|
||||||
|
{seg.grossTons > 0 ? (
|
||||||
|
<Text
|
||||||
|
size="xs"
|
||||||
|
ta="center"
|
||||||
|
fw={600}
|
||||||
|
c={
|
||||||
|
maxGrossTons != null && seg.grossTons > maxGrossTons
|
||||||
|
? "red.7"
|
||||||
|
: "dimmed"
|
||||||
|
}
|
||||||
|
style={{ whiteSpace: "nowrap" }}
|
||||||
|
>
|
||||||
|
{seg.grossTons}
|
||||||
|
{maxGrossTons != null ? ` / ${maxGrossTons}` : ""} T gross
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
{i === segments.length - 1 ? (
|
{i === segments.length - 1 ? (
|
||||||
<Stack gap={2} align="center" justify="flex-end" style={{ minWidth: 0 }}>
|
<Stack gap={2} align="center" justify="flex-end" style={{ minWidth: 0 }}>
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
label: `Last-mile · ${truckPrefill.truckPlateNumber}`,
|
label: `Last-mile · ${truckPrefill.truckPlateNumber}`,
|
||||||
trailerPlate: truckPrefill.trailerPlateNumber ?? '',
|
trailerPlate: truckPrefill.trailerPlateNumber ?? '',
|
||||||
driverName: truckPrefill.driverName ?? '',
|
driverName: truckPrefill.driverName ?? '',
|
||||||
|
driverLicense: truckPrefill.driverLicense ?? '',
|
||||||
driverPhone: truckPrefill.driverPhone ?? '',
|
driverPhone: truckPrefill.driverPhone ?? '',
|
||||||
truckType: truckPrefill.truckType ?? '',
|
truckType: truckPrefill.truckType ?? '',
|
||||||
containerNumbers: splitContainerNumbers(truckPrefill.containerNumber),
|
containerNumbers: splitContainerNumbers(truckPrefill.containerNumber),
|
||||||
@@ -218,6 +219,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
label: `Customer · ${t.plateNumber} — ${t.driverName}`,
|
label: `Customer · ${t.plateNumber} — ${t.driverName}`,
|
||||||
trailerPlate: '',
|
trailerPlate: '',
|
||||||
driverName: t.driverName,
|
driverName: t.driverName,
|
||||||
|
driverLicense: '',
|
||||||
driverPhone: '',
|
driverPhone: '',
|
||||||
truckType: t.truckType,
|
truckType: t.truckType,
|
||||||
containerNumbers: (t.containers ?? []).map((c) => c.containerNumber).filter(Boolean),
|
containerNumbers: (t.containers ?? []).map((c) => c.containerNumber).filter(Boolean),
|
||||||
@@ -231,6 +233,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
label: `Last-mile · ${t.truckPlateNumber ?? ''}${t.driverName ? ` — ${t.driverName}` : ''}`,
|
label: `Last-mile · ${t.truckPlateNumber ?? ''}${t.driverName ? ` — ${t.driverName}` : ''}`,
|
||||||
trailerPlate: t.trailerPlateNumber ?? '',
|
trailerPlate: t.trailerPlateNumber ?? '',
|
||||||
driverName: t.driverName ?? '',
|
driverName: t.driverName ?? '',
|
||||||
|
driverLicense: t.driverLicense ?? '',
|
||||||
driverPhone: t.driverPhone ?? '',
|
driverPhone: t.driverPhone ?? '',
|
||||||
truckType: t.truckType ?? '',
|
truckType: t.truckType ?? '',
|
||||||
containerNumbers: splitContainerNumbers(t.containerNumber),
|
containerNumbers: splitContainerNumbers(t.containerNumber),
|
||||||
@@ -281,6 +284,11 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
// way. A walk-in truck (typed plate, no assignment) stays editable at arrival.
|
// way. A walk-in truck (typed plate, no assignment) stays editable at arrival.
|
||||||
const isTruckIdentityLocked = isEntranceLocked || Boolean(selectedOption);
|
const isTruckIdentityLocked = isEntranceLocked || Boolean(selectedOption);
|
||||||
const isDriverNameLocked = isEntranceLocked || Boolean(selectedOption?.driverName);
|
const isDriverNameLocked = isEntranceLocked || Boolean(selectedOption?.driverName);
|
||||||
|
// The freight order's truck details are the customer's / fleet's record — the
|
||||||
|
// gate may FILL blanks (walk-in license, phone) but never edit shown values.
|
||||||
|
const isTrailerLocked = isEntranceLocked || Boolean(selectedOption?.trailerPlate);
|
||||||
|
const isDriverLicenseLocked = isEntranceLocked || Boolean(selectedOption?.driverLicense);
|
||||||
|
const isDriverPhoneLocked = isEntranceLocked || Boolean(selectedOption?.driverPhone);
|
||||||
const referenceLocked = Boolean(item?.releaseOrderReference) || savedBlocks.length > 0;
|
const referenceLocked = Boolean(item?.releaseOrderReference) || savedBlocks.length > 0;
|
||||||
|
|
||||||
/** Load a truck into the form: its saved block if any, else its assignment. */
|
/** Load a truck into the form: its saved block if any, else its assignment. */
|
||||||
@@ -293,7 +301,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
setTruckPlateNumber(plate);
|
setTruckPlateNumber(plate);
|
||||||
setTrailerPlateNumber(block?.trailerPlateNumber || option?.trailerPlate || '');
|
setTrailerPlateNumber(block?.trailerPlateNumber || option?.trailerPlate || '');
|
||||||
setDriverName(block?.driverName || option?.driverName || '');
|
setDriverName(block?.driverName || option?.driverName || '');
|
||||||
setDriverLicense(block?.driverLicense || '');
|
setDriverLicense(block?.driverLicense || option?.driverLicense || '');
|
||||||
setDriverPhone(block?.driverPhone || option?.driverPhone || '');
|
setDriverPhone(block?.driverPhone || option?.driverPhone || '');
|
||||||
setTruckType(block?.truckType || option?.truckType || '');
|
setTruckType(block?.truckType || option?.truckType || '');
|
||||||
const loaded = block
|
const loaded = block
|
||||||
@@ -611,15 +619,15 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
label="Trailer plate number"
|
label="Trailer plate number"
|
||||||
value={trailerPlateNumber}
|
value={trailerPlateNumber}
|
||||||
onChange={(e) => setTrailerPlateNumber(e.currentTarget.value)}
|
onChange={(e) => setTrailerPlateNumber(e.currentTarget.value)}
|
||||||
readOnly={isEntranceLocked}
|
readOnly={isTrailerLocked}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<TextInput label="Driver name" required value={driverName} onChange={(e) => setDriverName(e.currentTarget.value)} readOnly={isDriverNameLocked} />
|
<TextInput label="Driver name" required value={driverName} onChange={(e) => setDriverName(e.currentTarget.value)} readOnly={isDriverNameLocked} />
|
||||||
<TextInput label="Driver license" value={driverLicense} onChange={(e) => setDriverLicense(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
<TextInput label="Driver license" value={driverLicense} onChange={(e) => setDriverLicense(e.currentTarget.value)} readOnly={isDriverLicenseLocked} />
|
||||||
</Group>
|
</Group>
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<TextInput label="Driver phone" value={driverPhone} onChange={(e) => setDriverPhone(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
<TextInput label="Driver phone" value={driverPhone} onChange={(e) => setDriverPhone(e.currentTarget.value)} readOnly={isDriverPhoneLocked} />
|
||||||
<TextInput label="Truck type" value={truckType} onChange={(e) => setTruckType(e.currentTarget.value)} readOnly={isTruckIdentityLocked} />
|
<TextInput label="Truck type" value={truckType} onChange={(e) => setTruckType(e.currentTarget.value)} readOnly={isTruckIdentityLocked} />
|
||||||
</Group>
|
</Group>
|
||||||
<Group grow align="flex-start">
|
<Group grow align="flex-start">
|
||||||
|
|||||||
@@ -199,6 +199,7 @@ export const QUERY_KEYS = {
|
|||||||
|
|
||||||
MAINTENANCE: {
|
MAINTENANCE: {
|
||||||
ROOT: ["maintenance"] as const,
|
ROOT: ["maintenance"] as const,
|
||||||
|
dueBoard: () => ["maintenance", "due-board"] as const,
|
||||||
schedules: (vehicleId?: string) =>
|
schedules: (vehicleId?: string) =>
|
||||||
["maintenance", "schedules", vehicleId ?? "all"] as const,
|
["maintenance", "schedules", vehicleId ?? "all"] as const,
|
||||||
upcoming: (vehicleId?: string) =>
|
upcoming: (vehicleId?: string) =>
|
||||||
@@ -207,6 +208,8 @@ export const QUERY_KEYS = {
|
|||||||
["maintenance", "history", vehicleId ?? "all"] as const,
|
["maintenance", "history", vehicleId ?? "all"] as const,
|
||||||
stats: (vehicleId?: string) =>
|
stats: (vehicleId?: string) =>
|
||||||
["maintenance", "stats", vehicleId ?? "all"] as const,
|
["maintenance", "stats", vehicleId ?? "all"] as const,
|
||||||
|
intervals: (vehicleId?: string) =>
|
||||||
|
["maintenance", "intervals", vehicleId ?? "all"] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
FINANCIAL_REPORTS: {
|
FINANCIAL_REPORTS: {
|
||||||
|
|||||||
@@ -286,7 +286,6 @@ export const BOOKING_LIST_TABS = [
|
|||||||
key: "clearance",
|
key: "clearance",
|
||||||
label: "Clearance",
|
label: "Clearance",
|
||||||
statuses: [
|
statuses: [
|
||||||
"AWAITING_CLEARANCE_PAYMENT",
|
|
||||||
"AWAITING_DOCUMENTS",
|
"AWAITING_DOCUMENTS",
|
||||||
"DOCUMENTS_UNDER_REVIEW",
|
"DOCUMENTS_UNDER_REVIEW",
|
||||||
"CLEARANCE_READY",
|
"CLEARANCE_READY",
|
||||||
|
|||||||
@@ -51,10 +51,6 @@ export const CONTRACT_STATUS_STYLES: Record<string, StatusStyle> = {
|
|||||||
label: "Active",
|
label: "Active",
|
||||||
color: "bg-[color:var(--freight-brand-muted)] text-[color:var(--freight-brand)] border-[color:var(--freight-brand-border)]",
|
color: "bg-[color:var(--freight-brand-muted)] text-[color:var(--freight-brand)] border-[color:var(--freight-brand-border)]",
|
||||||
},
|
},
|
||||||
AWAITING_CLEARANCE_PAYMENT: {
|
|
||||||
label: "Clearance Fee Due",
|
|
||||||
color: "bg-orange-50 text-orange-700 border-orange-200",
|
|
||||||
},
|
|
||||||
AWAITING_CLEARANCE_DOCUMENTS: {
|
AWAITING_CLEARANCE_DOCUMENTS: {
|
||||||
label: "Awaiting Documents",
|
label: "Awaiting Documents",
|
||||||
color: "bg-amber-50 text-amber-700 border-amber-200",
|
color: "bg-amber-50 text-amber-700 border-amber-200",
|
||||||
@@ -122,7 +118,6 @@ export const CONTRACT_STATUS_COLOR: Record<string, string> = {
|
|||||||
SIGNED_CUSTOMER: "cyan",
|
SIGNED_CUSTOMER: "cyan",
|
||||||
FULLY_EXECUTED: "indigo",
|
FULLY_EXECUTED: "indigo",
|
||||||
CONTRACT_ACTIVE: "edr-green",
|
CONTRACT_ACTIVE: "edr-green",
|
||||||
AWAITING_CLEARANCE_PAYMENT: "orange",
|
|
||||||
AWAITING_CLEARANCE_DOCUMENTS: "yellow",
|
AWAITING_CLEARANCE_DOCUMENTS: "yellow",
|
||||||
CLEARANCE_UNDER_REVIEW: "yellow",
|
CLEARANCE_UNDER_REVIEW: "yellow",
|
||||||
CLEARANCE_READY_FOR_BOOKING: "edr-green",
|
CLEARANCE_READY_FOR_BOOKING: "edr-green",
|
||||||
@@ -212,13 +207,6 @@ export const CONTRACT_STATUS_META: Record<string, StatusMeta> = {
|
|||||||
color: "text-[color:var(--freight-brand)]",
|
color: "text-[color:var(--freight-brand)]",
|
||||||
stage: 3,
|
stage: 3,
|
||||||
},
|
},
|
||||||
AWAITING_CLEARANCE_PAYMENT: {
|
|
||||||
title: "Clearance Fee Due",
|
|
||||||
description:
|
|
||||||
"Customer must pay the prepaid clearance service fee before uploading documents.",
|
|
||||||
color: "text-orange-600",
|
|
||||||
stage: 3,
|
|
||||||
},
|
|
||||||
AWAITING_CLEARANCE_DOCUMENTS: {
|
AWAITING_CLEARANCE_DOCUMENTS: {
|
||||||
title: "Awaiting Documents",
|
title: "Awaiting Documents",
|
||||||
description: "Customer is uploading pre-booking clearance documents.",
|
description: "Customer is uploading pre-booking clearance documents.",
|
||||||
|
|||||||
@@ -67,10 +67,3 @@ export function useDisputeInterchangeDocument() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCancelInterchangeDocument() {
|
|
||||||
const onSuccess = useInterchangeInvalidation();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (id: string) => interchangeDocumentsService.cancel(id),
|
|
||||||
onSuccess,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -88,8 +88,8 @@ export const resolveModuleConfig = (config: TenantConfig): ModuleConfig => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const defaultConfig: TenantConfig = {
|
const defaultConfig: TenantConfig = {
|
||||||
appName: "Smart Office",
|
appName: "EDR Freight",
|
||||||
organizationName: "Smart Office",
|
organizationName: "Ethio-Djibouti Railways",
|
||||||
canUseAttachmentFromDMS: false,
|
canUseAttachmentFromDMS: false,
|
||||||
logo: "/assets/TriaTradinglogo.png",
|
logo: "/assets/TriaTradinglogo.png",
|
||||||
primaryColor: "#1b354d",
|
primaryColor: "#1b354d",
|
||||||
@@ -116,8 +116,8 @@ const defaultConfig: TenantConfig = {
|
|||||||
|
|
||||||
const tenantConfigs: Record<string, TenantConfig> = {
|
const tenantConfigs: Record<string, TenantConfig> = {
|
||||||
localhost: {
|
localhost: {
|
||||||
appName: "Smart Office",
|
appName: "EDR Freight",
|
||||||
organizationName: "Addis Ababa City Administration",
|
organizationName: "Ethio-Djibouti Railways",
|
||||||
logo: "",
|
logo: "",
|
||||||
primaryColor: "#0EA371",
|
primaryColor: "#0EA371",
|
||||||
moduleConfig: {
|
moduleConfig: {
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { AuthUser } from "@/auth/types";
|
||||||
|
import { canApproveContractStep } from "./permissions";
|
||||||
|
|
||||||
|
const withPositionType = (typeKey: string): AuthUser => ({
|
||||||
|
employee: [{ positions: [{ positionType: { key: typeKey } }] }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const withRole = (roleKey: string): AuthUser => ({ roles: [{ key: roleKey }] });
|
||||||
|
|
||||||
|
const withPermission = (permKey: string): AuthUser => ({
|
||||||
|
permissionKeys: [permKey],
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("canApproveContractStep", () => {
|
||||||
|
it("shows to the matching position type only", () => {
|
||||||
|
const chief = withPositionType("-marketing-chief");
|
||||||
|
expect(canApproveContractStep(chief, "-marketing-chief")).toBe(true);
|
||||||
|
// a marketing officer must NOT see the chief step's buttons
|
||||||
|
expect(canApproveContractStep(chief, "-marketing-director-")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets super/org admins action any step", () => {
|
||||||
|
expect(canApproveContractStep(withRole("super_admin"), "anything")).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
canApproveContractStep(withRole("organization_admin"), "-marketing-chief"),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves legacy chain roles via their position-type aliases", () => {
|
||||||
|
const director = withPositionType("operation-director");
|
||||||
|
expect(canApproveContractStep(director, "DIRECTOR")).toBe(true);
|
||||||
|
expect(canApproveContractStep(director, "CEO")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honours the role's own legacy approve permission", () => {
|
||||||
|
const staff = withPermission(
|
||||||
|
"edr_freight_app:contracts:approve_director",
|
||||||
|
);
|
||||||
|
expect(canApproveContractStep(staff, "DIRECTOR")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT show to holders of an unrelated approve permission", () => {
|
||||||
|
// the dropped blanket fallback: a line-staff approver is not a chief
|
||||||
|
const lineStaff = withPermission(
|
||||||
|
"edr_freight_app:contracts:approve_line_staff",
|
||||||
|
);
|
||||||
|
expect(canApproveContractStep(lineStaff, "-marketing-chief")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false without a user or role", () => {
|
||||||
|
expect(canApproveContractStep(null, "-marketing-chief")).toBe(false);
|
||||||
|
expect(canApproveContractStep(withPositionType("x"), null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user