mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha
This commit is contained in:
19
.gitignore
vendored
19
.gitignore
vendored
@@ -29,3 +29,22 @@ coverage/
|
||||
\#*\#
|
||||
.\#*
|
||||
docker-compose.override.yml
|
||||
|
||||
# cypress e2e artifacts
|
||||
e2e/**/cypress/videos/
|
||||
e2e/**/cypress/screenshots/
|
||||
e2e/**/cypress/downloads/
|
||||
|
||||
# e2e launcher state (ports of the running stack)
|
||||
e2e/freight/.e2e-ports.json
|
||||
|
||||
# local run scripts (contain personal DB credentials — never commit)
|
||||
run-passenger-local.sh
|
||||
run-passenger-web.sh
|
||||
|
||||
# generated test output
|
||||
e2e-ui-report/
|
||||
test-results/
|
||||
playwright-report/
|
||||
blob-report/
|
||||
RUNNING_LOCALLY.md
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
ensurePostgresSchemas,
|
||||
APPLICATION_SEARCH_PATH,
|
||||
} from "./config/ensure-postgres-schemas";
|
||||
import { IamModule, DataSeeder } from "@tria-plc/iamapi-common";
|
||||
import { IamModule } from "@tria-plc/iamapi-common";
|
||||
import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module";
|
||||
|
||||
import appConfig from "./config/app.config";
|
||||
@@ -77,8 +77,8 @@ import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-l
|
||||
import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder";
|
||||
//New Trains, Wagons, Container and Cargo management modules
|
||||
import { TrainsModule } from "./modules/trains/trains.module";
|
||||
import { VerifaydaModule } from './modules/verifayda/verifayda.module';
|
||||
import { FleetHistoryModule } from './modules/fleet-history/fleet-history.module';
|
||||
import { VerifaydaModule } from "./modules/verifayda/verifayda.module";
|
||||
import { FleetHistoryModule } from "./modules/fleet-history/fleet-history.module";
|
||||
import { WagonsModule } from "./modules/wagons/wagons.module";
|
||||
import { ContainersModule } from "./modules/container-management/containers.module";
|
||||
import { CargoesModule } from "./modules/cargoes/cargoes.module";
|
||||
@@ -104,7 +104,13 @@ import { LoggerMiddleware } from "./logger.middleware";
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig, faydaConfig],
|
||||
load: [
|
||||
appConfig,
|
||||
databaseConfig,
|
||||
telebirrConfig,
|
||||
rabbitmqConfig,
|
||||
faydaConfig,
|
||||
],
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
EventEmitterModule.forRoot(),
|
||||
@@ -223,7 +229,7 @@ import { LoggerMiddleware } from "./logger.middleware";
|
||||
})
|
||||
export class AppModule implements OnApplicationBootstrap {
|
||||
constructor(
|
||||
private readonly seeder: DataSeeder,
|
||||
// private readonly seeder: DataSeeder,
|
||||
private readonly edrOrgSeeder: EdrOrgSeeder,
|
||||
private readonly freightPositionsSeeder: FreightPositionsSeeder,
|
||||
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
|
||||
@@ -258,7 +264,7 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
// freightPositionsSeeder → seeds Position + PositionPermission rows
|
||||
// (depends on edrOrgSeeder, must run after)
|
||||
await this.freightPermissionKeyMigrationSeeder.run();
|
||||
await this.seeder.run();
|
||||
// await this.seeder.run();
|
||||
await this.edrOrgSeeder.run();
|
||||
await this.freightPositionsSeeder.run();
|
||||
|
||||
|
||||
@@ -14,6 +14,13 @@ export const BookingStaff = (permission: string | string[]) =>
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Read-only reference data (yard dropdowns, search filters): any signed-in
|
||||
* staff. Menu/page visibility stays permission-gated in the frontend — this
|
||||
* only lets forms populate their lookups.
|
||||
*/
|
||||
export const StaffReference = () => applyDecorators(UseGuards(JwtGuard));
|
||||
|
||||
export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view);
|
||||
|
||||
export const TrainSchedulingView = () =>
|
||||
@@ -22,9 +29,22 @@ export const TrainSchedulingView = () =>
|
||||
export const TrainSchedulingManage = () =>
|
||||
BookingStaff(FREIGHT_PERMS.trainScheduling.manage);
|
||||
|
||||
export const FleetView = () => BookingStaff(FREIGHT_PERMS.fleet.view);
|
||||
/**
|
||||
* Fleet guards take an optional granular per-resource key (locomotives:create,
|
||||
* wagons:delete, …). The legacy coarse fleet:view / fleet:manage keys remain
|
||||
* valid as a one-of fallback so existing role grants keep working.
|
||||
*/
|
||||
export const FleetView = (granular?: string) =>
|
||||
BookingStaff(
|
||||
granular ? [granular, FREIGHT_PERMS.fleet.view] : FREIGHT_PERMS.fleet.view,
|
||||
);
|
||||
|
||||
export const FleetManage = () => BookingStaff(FREIGHT_PERMS.fleet.manage);
|
||||
export const FleetManage = (granular?: string) =>
|
||||
BookingStaff(
|
||||
granular
|
||||
? [granular, FREIGHT_PERMS.fleet.manage]
|
||||
: FREIGHT_PERMS.fleet.manage,
|
||||
);
|
||||
|
||||
/** Requester creates a wagon-transfer request (count-only, no wagon picks). */
|
||||
export const WagonTransferRequest = () =>
|
||||
|
||||
@@ -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(
|
||||
user: TCurrentUser | MeLikeUser | null | undefined,
|
||||
requiredRole: string,
|
||||
|
||||
61
apps/edr-freight-api/src/common/mile-financials.util.ts
Normal file
61
apps/edr-freight-api/src/common/mile-financials.util.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
type MileRecord = {
|
||||
bookingId?: string | null;
|
||||
advancedPayment?: number | string | null;
|
||||
booking?: {
|
||||
cargoTotalWeightVgm?: number | string | null;
|
||||
bookingContainers?: Array<{
|
||||
units?: Array<{ vgmTons?: number | string | null }> | null;
|
||||
}> | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Display enrichment for first/last-mile lists (Assign Vehicle modal etc.):
|
||||
* - Advance payment: mile records are created with advanced_payment 0 — the
|
||||
* real advance is the FIRST_MILE/LAST_MILE line the customer already paid
|
||||
* on the booking invoice.
|
||||
* - Cargo tons: container bookings often carry tonnage on the per-unit VGMs
|
||||
* while cargo_total_weight_vgm stays 0 — fall back to the summed units.
|
||||
* Fills both in-memory on the loaded records; nothing is persisted.
|
||||
*/
|
||||
export async function attachMileFinancials(
|
||||
dataSource: DataSource,
|
||||
records: MileRecord[],
|
||||
chargeType: 'FIRST_MILE' | 'LAST_MILE',
|
||||
): Promise<void> {
|
||||
for (const r of records) {
|
||||
const b = r.booking;
|
||||
if (!b || Number(b.cargoTotalWeightVgm) > 0) continue;
|
||||
const unitTons = (b.bookingContainers ?? []).reduce(
|
||||
(sum, bc) =>
|
||||
sum + (bc.units ?? []).reduce((s, u) => s + (Number(u.vgmTons) || 0), 0),
|
||||
0,
|
||||
);
|
||||
if (unitTons > 0) b.cargoTotalWeightVgm = Number(unitTons.toFixed(3));
|
||||
}
|
||||
|
||||
const needAdvance = records.filter(
|
||||
(r) => r.bookingId && !(Number(r.advancedPayment) > 0),
|
||||
);
|
||||
if (!needAdvance.length) return;
|
||||
|
||||
const rows: Array<{ bookingId: string; amount: string }> = await dataSource.query(
|
||||
`SELECT i.source_id AS "bookingId", SUM(il.amount) AS amount
|
||||
FROM freight.invoice_lines il
|
||||
JOIN freight.invoices i ON i.id = il.invoice_id AND i.deleted_at IS NULL
|
||||
WHERE i.source = 'booking'
|
||||
AND i.status = 'PAID'
|
||||
AND i.source_id = ANY($1::text[])
|
||||
AND il.charge_type = $2
|
||||
AND il.deleted_at IS NULL
|
||||
GROUP BY i.source_id`,
|
||||
[needAdvance.map((r) => r.bookingId), chargeType],
|
||||
);
|
||||
const byBooking = new Map(rows.map((r) => [r.bookingId, Number(r.amount)]));
|
||||
for (const r of needAdvance) {
|
||||
const paid = byBooking.get(r.bookingId as string);
|
||||
if (paid) r.advancedPayment = paid;
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,7 @@ const TRIGGER_ROUTE_LABELS: Partial<Record<Rate['trigger'], string>> = {
|
||||
REEFER: 'Reefer (refrigerated) surcharge',
|
||||
WITH_RETURN: 'Empty-container return service',
|
||||
SHIPPING_LINE: 'Shipping line handling',
|
||||
CONSOLIDATION: 'Container consolidation (extra document)',
|
||||
CONSOLIDATION: 'Penalty (container consolidation)',
|
||||
LASHING: 'Cargo lashing and securing',
|
||||
CANCELLATION: 'Booking cancellation fee',
|
||||
DEMURRAGE: 'Demurrage / wagon detention',
|
||||
|
||||
@@ -33,6 +33,13 @@ async function bootstrap() {
|
||||
"delegator-position-id",
|
||||
"current-project-id",
|
||||
"current-position-id",
|
||||
// x-prefixed variants sent by the user-management / record-management
|
||||
// frontend modules (same values, different naming convention)
|
||||
"x-organization-unit-id",
|
||||
"x-delegator-id",
|
||||
"x-delegator-position-id",
|
||||
"x-current-project-id",
|
||||
"x-current-position-id",
|
||||
],
|
||||
exposedHeaders: ["Content-Disposition"],
|
||||
maxAge: 86400, // cache preflight for 24h to cut chatter in dev
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Drop the `active_profile_type` "active mode" column. A booking/contract now
|
||||
* resolves its company_profile from the trade direction at creation time (with
|
||||
* a forwarder passing an explicit companyProfileId), so no per-user active mode
|
||||
* is stored. `onboarding_step` / `onboarding_completed` are unaffected.
|
||||
*/
|
||||
export class DropActiveProfileTypeFromExternalProfiles2450000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'DropActiveProfileTypeFromExternalProfiles2450000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.external_profiles
|
||||
DROP COLUMN IF EXISTS active_profile_type;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.external_profiles
|
||||
ADD COLUMN IF NOT EXISTS active_profile_type varchar(32);
|
||||
`);
|
||||
// Rebuild the mode the same way the original column was backfilled:
|
||||
// importer first, then exporter, then whichever profile the company has.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.external_profiles ep
|
||||
SET active_profile_type = cp.type
|
||||
FROM (
|
||||
SELECT DISTINCT ON (company_id) company_id, type
|
||||
FROM freight.company_profiles
|
||||
ORDER BY company_id,
|
||||
CASE type
|
||||
WHEN 'importer' THEN 0
|
||||
WHEN 'exporter' THEN 1
|
||||
ELSE 2
|
||||
END
|
||||
) cp
|
||||
WHERE ep.company_id = cp.company_id
|
||||
AND ep.active_profile_type IS NULL;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddCacBankPaymentMethod2460000000000 implements MigrationInterface {
|
||||
name = "AddCacBankPaymentMethod2460000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// The entity + frontend already list 'cac-bank' as a valid method, but the
|
||||
// DB enum was never extended. Filtering payments by 'cac-bank' cast the
|
||||
// literal to the enum and errored (invalid input value for enum). EDRFREIGHT-301.
|
||||
await queryRunner.query(`ALTER TYPE freight.payments_method_enum ADD VALUE IF NOT EXISTS 'cac-bank';`);
|
||||
}
|
||||
|
||||
public async down(_queryRunner: QueryRunner): Promise<void> {
|
||||
// PostgreSQL does not support removing enum values directly.
|
||||
// To roll back, recreate the type without the added value and update the column.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Acquisitions describe WHAT was acquired (vehicle, parts, equipment…) — the
|
||||
* vehicle link is optional and only for acquisitions that ARE a fleet vehicle.
|
||||
*/
|
||||
export class AddAcquisitionItemName2470000000000 implements MigrationInterface {
|
||||
name = 'AddAcquisitionItemName2470000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.asset_acquisitions
|
||||
ADD COLUMN IF NOT EXISTS item_name varchar(200)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.asset_acquisitions
|
||||
DROP COLUMN IF EXISTS item_name
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
RESET_LINK_TTL_MS,
|
||||
} from "./forgot-password.service";
|
||||
import { maskOtpTarget } from "./mask-target.util";
|
||||
import { isDomesticPhone } from "../otp/otp.service";
|
||||
|
||||
/** The account a staff-triggered reset would land on. */
|
||||
export interface CustomerResetTarget {
|
||||
@@ -19,6 +20,12 @@ export interface CustomerResetTarget {
|
||||
name: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
/**
|
||||
* Whether the SMS gateway (domestic-only) can reach `phone`. `null` when
|
||||
* there is no phone. The backoffice uses this to disable the SMS channel for
|
||||
* foreign numbers instead of sending a link that will never arrive.
|
||||
*/
|
||||
phoneIsDomestic: boolean | null;
|
||||
}
|
||||
|
||||
export interface SentResetLink {
|
||||
@@ -58,6 +65,9 @@ export class CustomerResetService {
|
||||
name: `${profile.firstName} ${profile.lastName}`.trim(),
|
||||
email: user.email ?? null,
|
||||
phone: user.phoneNumber ?? null,
|
||||
phoneIsDomestic: user.phoneNumber
|
||||
? isDomesticPhone(user.phoneNumber)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -80,6 +90,17 @@ export class CustomerResetService {
|
||||
const target = this.forgotPasswordService.targetFor(user, channel);
|
||||
if (!target) return null;
|
||||
|
||||
// A foreign number is unreachable by the domestic-only SMS gateway — treat
|
||||
// it like a missing phone rather than reporting "link sent" for a message
|
||||
// that will never arrive. The backoffice disables the channel up front via
|
||||
// `phoneIsDomestic`; this guards direct API calls.
|
||||
if (channel === "phone" && target.phone && !isDomesticPhone(target.phone)) {
|
||||
this.logger.warn(
|
||||
`Staff reset via SMS refused for user ${userId} — non-domestic phone`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Mint first, send second: a failed send leaves an unused ticket that simply
|
||||
// expires, whereas sending a link before the ticket exists would hand the
|
||||
// customer a URL that is dead on arrival.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import {
|
||||
collectPermissionKeys,
|
||||
@@ -9,7 +11,35 @@ import { PERMISSIONS_CATALOG } from '../../seed/freight-permissions.registry';
|
||||
|
||||
@Injectable()
|
||||
export class FreightMeService {
|
||||
getEnrichedProfile(user: TCurrentUser) {
|
||||
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
|
||||
|
||||
/**
|
||||
* The JWT session snapshot has no position TYPE, but the backoffice needs it
|
||||
* (GL sub-positions are identified by type key). Resolved live from IAM.
|
||||
*/
|
||||
private async lookupPositionType(
|
||||
positionId: string | undefined,
|
||||
): Promise<{ key: string; name: unknown } | null> {
|
||||
if (!positionId) return null;
|
||||
try {
|
||||
const rows: { key: string; name: unknown }[] = await this.dataSource.query(
|
||||
`SELECT pt.key, pt.name
|
||||
FROM iam.positions p
|
||||
JOIN iam.position_types pt ON pt.id = p.position_type_id
|
||||
WHERE p.id = $1`,
|
||||
[positionId],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
} catch {
|
||||
return null; // iam schema unreachable — degrade to the old payload shape
|
||||
}
|
||||
}
|
||||
|
||||
async getEnrichedProfile(user: TCurrentUser) {
|
||||
const positionType = await this.lookupPositionType(
|
||||
user.employee?.position?.id,
|
||||
);
|
||||
|
||||
const employee = user.employee
|
||||
? [
|
||||
{
|
||||
@@ -27,6 +57,7 @@ export class FreightMeService {
|
||||
isDelegate: user.employee.position.isDelegate,
|
||||
parentPositionId: user.employee.position.parentPositionId,
|
||||
permissions: user.employee.position.permissions ?? [],
|
||||
positionType,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 {
|
||||
@ApiProperty()
|
||||
@@ -29,7 +30,8 @@ export class CreateOrganizationUserDto {
|
||||
phoneNumber?: string;
|
||||
|
||||
@ApiProperty({ type: CreateOrganizationUserNameDto })
|
||||
@IsObject()
|
||||
@ValidateNested()
|
||||
@Type(() => CreateOrganizationUserNameDto)
|
||||
name!: CreateOrganizationUserNameDto;
|
||||
|
||||
@ApiProperty({ required: false, default: false })
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
InvoiceLineInput,
|
||||
} from "../billing/billing.service";
|
||||
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 { BookingBatchService } from "../train-scheduling/booking-batch.service";
|
||||
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
|
||||
* per-shipment clearance fee) when the booking is
|
||||
* Expire the booking's currently-open freight (PREPAID) invoice when the booking is
|
||||
* cancelled or rejected — the counterpart to the pay-window-expiry path
|
||||
* (which also calls {@link BillingService.expirePayable}). Stops a terminated
|
||||
* booking from leaving a payable invoice open. No-op when the booking has no
|
||||
@@ -133,15 +131,6 @@ export class BookingInvoiceService {
|
||||
bookingId: string,
|
||||
manager?: EntityManager,
|
||||
): 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(
|
||||
Freight.InvoiceSource.Booking,
|
||||
bookingId,
|
||||
|
||||
@@ -56,6 +56,7 @@ describe('BookingPricingService — domestic corridor', () => {
|
||||
ratesService as never,
|
||||
exchangeService as never,
|
||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||
{} as never,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -163,11 +164,12 @@ describe('BookingPricingService — domestic corridor', () => {
|
||||
computeBaseRailLinesWithRates: (
|
||||
b: Booking,
|
||||
input: { containers: [] },
|
||||
) => Promise<{ lineItems: Array<{ amount: number }> }>;
|
||||
) => Promise<{ lineItems: Array<{ amount: number }>; blocked: string[] }>;
|
||||
}
|
||||
).computeBaseRailLinesWithRates(booking, { containers: [] });
|
||||
|
||||
expect(result.lineItems).toHaveLength(0);
|
||||
expect(result.blocked).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not price containers off a rate configured for a different leg', async () => {
|
||||
@@ -197,4 +199,277 @@ describe('BookingPricingService — domestic corridor', () => {
|
||||
|
||||
expect(result.lineItems).toHaveLength(0);
|
||||
});
|
||||
|
||||
// A mixed booking where only one container size has a configured rate must
|
||||
// hard-block, not silently carry the unconfigured size for free.
|
||||
it('blocks the unconfigured container size and prices the configured one', async () => {
|
||||
const fortyOnly: Rate = {
|
||||
...intercityContainerUsd,
|
||||
id: 'rate-ct-40-only',
|
||||
containerTypeId: 'ct-40',
|
||||
} as Rate;
|
||||
ratesService.findLiveRates.mockResolvedValue([fortyOnly]);
|
||||
|
||||
const booking = {
|
||||
id: 'b-5',
|
||||
freightType: 'CONTAINER',
|
||||
tradeDirection: 'DOMESTIC',
|
||||
paymentCurrency: 'USD',
|
||||
originYardId: MOJO,
|
||||
destinationYardId: DIRE,
|
||||
bookingContainers: [],
|
||||
} as unknown as Booking;
|
||||
|
||||
const result = await (
|
||||
service as unknown as {
|
||||
computeBaseRailLinesWithRates: (
|
||||
b: Booking,
|
||||
input: {
|
||||
containers: Array<{ containerTypeId: string; quantity: number }>;
|
||||
},
|
||||
) => Promise<{ lineItems: Array<{ code: string }>; blocked: string[] }>;
|
||||
}
|
||||
).computeBaseRailLinesWithRates(booking, {
|
||||
containers: [
|
||||
{ containerTypeId: 'ct-40', quantity: 2 },
|
||||
{ containerTypeId: 'ct-20', quantity: 3 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.lineItems).toHaveLength(1);
|
||||
expect(result.blocked).toHaveLength(1);
|
||||
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 { CargoTypesService } from '../rule-engine/services/cargo-types.service';
|
||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||
import { RatesService } from '../rule-engine/services/rates.service';
|
||||
import { Rate } from '../rule-engine/entities/rate.entity';
|
||||
@@ -10,7 +11,10 @@ import {
|
||||
BookingEvaluationInput,
|
||||
RuleEngineService,
|
||||
} 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 { wagonRemainder } from './consolidation.service';
|
||||
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
|
||||
@@ -76,6 +80,7 @@ export class BookingPricingService {
|
||||
private readonly ratesService: RatesService,
|
||||
private readonly exchangeService: ExchangeService,
|
||||
private readonly containerValidationService: ContainerValidationService,
|
||||
private readonly cargoTypesService: CargoTypesService,
|
||||
) {}
|
||||
|
||||
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
|
||||
@@ -137,8 +142,12 @@ export class BookingPricingService {
|
||||
const lineItems: PriceLineItemDto[] = [];
|
||||
let total = 0;
|
||||
|
||||
const { lineItems: baseLines, usedRates: baseRates, warnings: baseWarnings } =
|
||||
await this.computeBaseRailLinesWithRates(booking, evalInput, frozenRates);
|
||||
const {
|
||||
lineItems: baseLines,
|
||||
usedRates: baseRates,
|
||||
warnings: baseWarnings,
|
||||
blocked: baseBlocked,
|
||||
} = await this.computeBaseRailLinesWithRates(booking, evalInput, frozenRates);
|
||||
for (const line of baseLines) {
|
||||
lineItems.push(line);
|
||||
total += line.amount;
|
||||
@@ -161,8 +170,16 @@ export class BookingPricingService {
|
||||
const usdAmount = mod.calculatedAmount;
|
||||
|
||||
const rate = rateById.get(mod.rateId);
|
||||
const unit = rate?.rateUnit ?? 'FLAT';
|
||||
const unitUsd = rate ? Number(rate.rateValue) : usdAmount;
|
||||
// Derived/route-matched charges (import overweight, empty-container
|
||||
// return) carry their own unit price + billing unit — bill and display
|
||||
// those, not whatever the referenced rate row says.
|
||||
const isDerived = mod.unitPriceUsd != null;
|
||||
const unit = mod.billingUnit ?? rate?.rateUnit ?? 'FLAT';
|
||||
const unitUsd = isDerived
|
||||
? Number(mod.unitPriceUsd)
|
||||
: rate
|
||||
? Number(rate.rateValue)
|
||||
: usdAmount;
|
||||
// Per-unit count: FLAT and PER_INVOICE are billed once (qty 1); an
|
||||
// explicit trigger (e.g. overweight tons) wins when present; otherwise
|
||||
// derive from total ÷ unit price (the live unit price — a count, not a
|
||||
@@ -178,11 +195,11 @@ export class BookingPricingService {
|
||||
|
||||
// H15: bill the frozen contract surcharge rate (already in the booking
|
||||
// currency) when this code has a snapshot; else keep the live amount.
|
||||
const frozen = this.frozenRateByCode(
|
||||
frozenRates,
|
||||
mod.surchargeCode,
|
||||
paymentCurrency,
|
||||
);
|
||||
// Derived charges skip the snapshot — import overweight prices off the
|
||||
// route's container freight, never a frozen OVERWEIGHT_PER_TON value.
|
||||
const frozen = isDerived
|
||||
? null
|
||||
: this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency);
|
||||
const unitAmount = frozen
|
||||
? Number(frozen.unitPrice)
|
||||
: isEtbBooking
|
||||
@@ -211,6 +228,23 @@ export class BookingPricingService {
|
||||
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
|
||||
// to the booking's container lines (same order) for code + weights. maxAllowed
|
||||
// is derived from the line total minus the excess the engine computed.
|
||||
@@ -248,7 +282,7 @@ export class BookingPricingService {
|
||||
appliedModifiers: ruleResult.appliedModifiers,
|
||||
priorityScore: ruleResult.priorityScore,
|
||||
warnings: [...ruleResult.warnings, ...baseWarnings],
|
||||
hardBlocked: ruleResult.hardBlocked,
|
||||
hardBlocked: [...ruleResult.hardBlocked, ...baseBlocked, ...clearanceBlocked],
|
||||
overweightLines,
|
||||
};
|
||||
}
|
||||
@@ -309,6 +343,8 @@ export class BookingPricingService {
|
||||
hazardousQuantity: Number(bc.hazardousQuantity ?? 0),
|
||||
reeferQuantity: Number(bc.reeferQuantity ?? 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),
|
||||
quantity: qty,
|
||||
@@ -326,6 +362,13 @@ export class BookingPricingService {
|
||||
),
|
||||
)
|
||||
: 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
|
||||
// a container type leaves a wagon partially filled. Aggregate by type first —
|
||||
@@ -365,6 +408,8 @@ export class BookingPricingService {
|
||||
isGovernment: booking.isGovernment,
|
||||
allowConsolidation,
|
||||
shippingLineId: booking.shippingLineId,
|
||||
originYardId: booking.originYardId,
|
||||
destinationYardId: booking.destinationYardId,
|
||||
totalWagons,
|
||||
// Bulk tonnage scales PER_TON surcharges (e.g. the bulk reefer surcharge).
|
||||
// Container freight carries 0 here — its surcharges scale by container count.
|
||||
@@ -372,6 +417,7 @@ export class BookingPricingService {
|
||||
booking.freightType === 'BULK'
|
||||
? Number(booking.cargoTotalWeightVgm ?? 0)
|
||||
: 0,
|
||||
bulkWagons,
|
||||
containers,
|
||||
};
|
||||
}
|
||||
@@ -454,7 +500,12 @@ export class BookingPricingService {
|
||||
booking: Booking,
|
||||
evalInput: BookingEvaluationInput,
|
||||
frozenRates: Map<string, ContractRateSnapshot> | null = null,
|
||||
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[]; warnings: string[] }> {
|
||||
): Promise<{
|
||||
lineItems: PriceLineItemDto[];
|
||||
usedRates: Rate[];
|
||||
warnings: string[];
|
||||
blocked: string[];
|
||||
}> {
|
||||
const liveRates = await this.ratesService.findLiveRates();
|
||||
const paymentCurrency = booking.paymentCurrency;
|
||||
const isEtbBooking = paymentCurrency === 'ETB';
|
||||
@@ -477,6 +528,7 @@ export class BookingPricingService {
|
||||
const lines: PriceLineItemDto[] = [];
|
||||
const usedRatesMap = new Map<string, Rate>();
|
||||
const warnings: string[] = [];
|
||||
const blocked: string[] = [];
|
||||
const wagonCount = await this.resolveWagonCount(booking);
|
||||
|
||||
for (const container of evalInput.containers) {
|
||||
@@ -500,11 +552,14 @@ export class BookingPricingService {
|
||||
const label = await this.containerTypeLabel(container.containerTypeId);
|
||||
if (!rate && !frozen) {
|
||||
// Never price this line off another container type's (or another
|
||||
// route's) rate — an unpriced line with a warning is recoverable; a
|
||||
// silently mischarged one is not.
|
||||
warnings.push(
|
||||
// route's) rate, and never let an unpriced line through: a booking
|
||||
// that ships a container type nobody configured a rate for would be
|
||||
// carried for free. Hard-block instead — the customer drops the line
|
||||
// or EDR configures the rate.
|
||||
blocked.push(
|
||||
`No ${rateType} rate is configured for ${label} on this route — ` +
|
||||
'the line was not priced.',
|
||||
`the booking cannot be priced. Remove the ${label} line or ask EDR ` +
|
||||
'to configure its rate for this origin → destination.',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -587,10 +642,18 @@ export class BookingPricingService {
|
||||
quantity: this.effectiveUnitQuantity(fallback.rateUnit, quantity, wagonCount),
|
||||
currency: paymentCurrency,
|
||||
});
|
||||
} else if (isBulk) {
|
||||
// Same rule as container lines: bulk freight with no rate on this leg
|
||||
// must not proceed unpriced.
|
||||
blocked.push(
|
||||
`No ${rateType} rate is configured for this route — the booking ` +
|
||||
'cannot be priced. Ask EDR to configure the rate for this ' +
|
||||
'origin → destination.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { lineItems: lines, usedRates: [...usedRatesMap.values()], warnings };
|
||||
return { lineItems: lines, usedRates: [...usedRatesMap.values()], warnings, blocked };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -863,6 +926,191 @@ export class BookingPricingService {
|
||||
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 {
|
||||
return JSON.stringify(
|
||||
[...items]
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Logger,
|
||||
Optional,
|
||||
} from "@nestjs/common";
|
||||
import { OnEvent } from "@nestjs/event-emitter";
|
||||
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||
@@ -349,6 +350,22 @@ export class BookingTransitionService {
|
||||
return fresh;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import EDR last-mile: every handover signed + every truck departed ⇒ the
|
||||
* warehouses module delivered the goods and asks the booking to complete.
|
||||
* Best-effort — a booking already COMPLETED (or not yet in transit) just logs.
|
||||
*/
|
||||
@OnEvent('import.handover.completed')
|
||||
async onImportHandoverCompleted(payload: { bookingId: string }): Promise<void> {
|
||||
try {
|
||||
await this.complete(payload.bookingId);
|
||||
} catch (err) {
|
||||
this.logger.log(
|
||||
`Booking ${payload.bookingId} not auto-completed on handover sign: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async complete(bookingId: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ["IN_TRANSIT", "ARRIVED"]);
|
||||
@@ -588,11 +605,6 @@ export class BookingTransitionService {
|
||||
files: Express.Multer.File[],
|
||||
): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
if (booking.status === "AWAITING_CLEARANCE_PAYMENT") {
|
||||
throw new ConflictException(
|
||||
"The customs clearance service fee for this shipment has not been paid yet — pay it from the portal to unlock document upload.",
|
||||
);
|
||||
}
|
||||
assertBookingStatus(booking, [
|
||||
"AWAITING_DOCUMENTS",
|
||||
"DOCUMENTS_UNDER_REVIEW",
|
||||
|
||||
@@ -252,8 +252,11 @@ export class BookingsController {
|
||||
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")
|
||||
@BookingView()
|
||||
@BookingStaff([FREIGHT_PERMS.customers.view, FREIGHT_PERMS.bookings.view])
|
||||
@ApiOperation({
|
||||
summary: "List bookings for a company (customer-view shape, backoffice)",
|
||||
})
|
||||
@@ -436,18 +439,26 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Get(':id/customer-truck-assignment/freight-order')
|
||||
@ApiOperation({ summary: 'Download duplicate freight order copies for customer truck assignment' })
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Download freight order copies. The 2 gate copies always print; ?copies=1,2,8 adds waybill-style copies (catalog indexes 1-8).',
|
||||
})
|
||||
async customerTruckFreightOrder(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Res() res: Response,
|
||||
@Query('copies') copies?: string,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
}
|
||||
const extraCopyIndexes = (copies ?? '')
|
||||
.split(',')
|
||||
.map((n) => Number(n.trim()))
|
||||
.filter((n) => Number.isInteger(n) && n >= 1 && n <= 8);
|
||||
const { filename, buffer } =
|
||||
await this.bookingsService.customerTruckFreightOrderCopies(id);
|
||||
await this.bookingsService.customerTruckFreightOrderCopies(id, extraCopyIndexes);
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.send(buffer);
|
||||
@@ -480,6 +491,20 @@ export class BookingsController {
|
||||
return this.customerTruckService.addTruck(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/customer-trucks/bulk')
|
||||
@ApiOperation({ summary: 'Bulk add customer trucks from array payload (Excel parsed)' })
|
||||
async bulkAddCustomerTrucks(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() payload: { trucks: AddCustomerTruckDto[] },
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
}
|
||||
return this.customerTruckService.addBulkTrucks(id, payload.trucks);
|
||||
}
|
||||
|
||||
@Patch(':id/customer-trucks/:assignmentId')
|
||||
@ApiOperation({ summary: 'Edit a not-yet-arrived customer truck (plate/driver/type + containers)' })
|
||||
async updateCustomerTruck(
|
||||
|
||||
@@ -12,7 +12,6 @@ import { Freight, SchedulingStatus } from '@edr/types';
|
||||
import { insertWithGeneratedReference } from '@edr/api-common';
|
||||
// import { CustomersService } from '../customers/customers.service';
|
||||
import { CompaniesService } from '../companies/companies.service';
|
||||
import { ProfileType } from '../companies/entities/company-profile.entity';
|
||||
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
|
||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||
@@ -143,8 +142,21 @@ export class BookingsService {
|
||||
return this.findById(bookingId);
|
||||
}
|
||||
|
||||
/** Selectable freight-order copies (rail-waybill style). Indexes 1-8. */
|
||||
static readonly FREIGHT_ORDER_EXTRA_COPIES = [
|
||||
'Original 1 (for Issuing Carrier)',
|
||||
'Original 2 (for Consignee)',
|
||||
'Original 3 (for Shipper)',
|
||||
'Copy 4 (Delivery Receipt)',
|
||||
'Copy 5 (Extra Copy)',
|
||||
'Copy 6 (Extra Copy)',
|
||||
'Copy 7 (Extra Copy)',
|
||||
'Copy 8 (for Agent)',
|
||||
] as const;
|
||||
|
||||
async customerTruckFreightOrderCopies(
|
||||
bookingId: string,
|
||||
extraCopyIndexes: number[] = [],
|
||||
): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const booking = await this.findById(bookingId);
|
||||
if (!booking.customerTruckAssignedAt) {
|
||||
@@ -172,7 +184,12 @@ export class BookingsService {
|
||||
[bookingId],
|
||||
);
|
||||
|
||||
const html = this.buildCustomerTruckFreightOrderHtml(booking, trucks);
|
||||
// The 2 gate copies are ALWAYS printed; the waybill-style copies are
|
||||
// whatever the customer ticked (indexes into the fixed catalog).
|
||||
const extraCopies = [...new Set(extraCopyIndexes)]
|
||||
.map((i) => BookingsService.FREIGHT_ORDER_EXTRA_COPIES[i - 1])
|
||||
.filter(Boolean);
|
||||
const html = this.buildCustomerTruckFreightOrderHtml(booking, trucks, extraCopies);
|
||||
// Chromium when available; otherwise the styled tabular fallback (never the
|
||||
// generic text dump — the freight order is an outward-facing gate document).
|
||||
const buffer = await this.pdfRender.htmlToPdfBuffer(html, {
|
||||
@@ -269,6 +286,7 @@ export class BookingsService {
|
||||
arrivedAt: string | null;
|
||||
containers: string | null;
|
||||
}>,
|
||||
extraCopies: string[] = [],
|
||||
): string {
|
||||
const esc = (v: unknown) => this.escapeHtml(String(v ?? '-'));
|
||||
const assignedAt = booking.customerTruckAssignedAt
|
||||
@@ -387,6 +405,7 @@ export class BookingsService {
|
||||
<body>
|
||||
${copy('Copy 1: Port Operations Copy')}
|
||||
${copy('Copy 2: Gate Security & Carrier Copy')}
|
||||
${extraCopies.map((label) => copy(label)).join('')}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
@@ -423,6 +442,8 @@ export class BookingsService {
|
||||
isReefer?: boolean;
|
||||
isGovernment?: boolean;
|
||||
shippingLineId?: string | null;
|
||||
originYardId?: string | null;
|
||||
destinationYardId?: string | null;
|
||||
bulkTons?: number;
|
||||
containers: CreateBookingContainerDto[];
|
||||
}): Promise<BookingEvaluationInput> {
|
||||
@@ -468,6 +489,8 @@ export class BookingsService {
|
||||
isGovernment: dto.isGovernment ?? false,
|
||||
allowConsolidation,
|
||||
shippingLineId: dto.shippingLineId,
|
||||
originYardId: dto.originYardId ?? null,
|
||||
destinationYardId: dto.destinationYardId ?? null,
|
||||
totalWagons,
|
||||
bulkTons: dto.freightType === 'BULK' ? Number(dto.bulkTons ?? 0) : 0,
|
||||
containers,
|
||||
@@ -635,12 +658,9 @@ export class BookingsService {
|
||||
);
|
||||
}
|
||||
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
|
||||
// A customer can only book once their company has been approved.
|
||||
if (company.status !== CompanyStatus.Active) {
|
||||
throw new ForbiddenException(
|
||||
"Your company is awaiting approval — you can't create bookings yet.",
|
||||
);
|
||||
}
|
||||
// A customer can only book once their company has been approved; the
|
||||
// helper names the real status (suspended/blacklisted) when it isn't.
|
||||
this.companiesService.assertCompanyActiveFor(company, 'bookings');
|
||||
companyId = company.id;
|
||||
}
|
||||
|
||||
@@ -746,21 +766,13 @@ export class BookingsService {
|
||||
);
|
||||
companyProfileId = profile.id;
|
||||
} else if (companyId) {
|
||||
let fallbackType: ProfileType | null = null;
|
||||
if (userId) {
|
||||
try {
|
||||
const { profile } =
|
||||
await this.companiesService.getCompanyInfoByUserId(userId);
|
||||
fallbackType = profile.activeProfileType ?? null;
|
||||
} catch {
|
||||
// No profile (e.g. staff creating on behalf) — fall back to mapping.
|
||||
}
|
||||
}
|
||||
// No explicit profile pin: resolve from the booking's trade direction
|
||||
// (import→importer, export→exporter; otherwise the first profile). A
|
||||
// forwarder booking sends dto.companyProfileId and takes the branch above.
|
||||
companyProfileId =
|
||||
await this.companiesService.resolveCompanyProfileIdForBooking(
|
||||
companyId,
|
||||
tradeDirection,
|
||||
fallbackType,
|
||||
);
|
||||
|
||||
// A customer booking under their own account may only do so once the
|
||||
@@ -800,6 +812,8 @@ export class BookingsService {
|
||||
isReefer: dto.isReefer,
|
||||
isGovernment,
|
||||
shippingLineId: dto.shippingLineId,
|
||||
originYardId: dto.originYardId,
|
||||
destinationYardId: dto.destinationYardId,
|
||||
bulkTons: dto.cargoTotalWeightVgm,
|
||||
containers,
|
||||
});
|
||||
@@ -1010,6 +1024,8 @@ export class BookingsService {
|
||||
isHazardous: dto.isHazardous ?? existing.isHazardous,
|
||||
isReefer: dto.isReefer ?? existing.isReefer,
|
||||
shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined,
|
||||
originYardId: dto.originYardId ?? existing.originYardId,
|
||||
destinationYardId: dto.destinationYardId ?? existing.destinationYardId,
|
||||
bulkTons: dto.cargoTotalWeightVgm ?? Number(existing.cargoTotalWeightVgm ?? 0),
|
||||
containers,
|
||||
});
|
||||
@@ -1068,9 +1084,6 @@ export class BookingsService {
|
||||
await this.companiesService.resolveCompanyProfileIdForBooking(
|
||||
existing.companyId,
|
||||
tradeDirection,
|
||||
existing.companyProfileId
|
||||
? undefined
|
||||
: (existing.companyProfile?.type as ProfileType | undefined),
|
||||
);
|
||||
}
|
||||
if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate);
|
||||
@@ -1228,9 +1241,9 @@ export class BookingsService {
|
||||
|
||||
/**
|
||||
* Batched version of the findById flag: marks each page item whose booking
|
||||
* has a generated-but-unsigned SELF_HAUL handover, so list rows (portal
|
||||
* dashboard) can show "Approve delivery" for exactly the generated→signed
|
||||
* window. One query for the whole page.
|
||||
* has a generated-but-unsigned handover (self-haul or EDR last-mile), so list
|
||||
* rows (portal dashboard) can show "Approve delivery" for exactly the
|
||||
* generated→signed window. One query for the whole page.
|
||||
*/
|
||||
private async attachHandoverFlags(bookings: Booking[]): Promise<void> {
|
||||
const ids = bookings.map((b) => b.id);
|
||||
@@ -1239,8 +1252,7 @@ export class BookingsService {
|
||||
`SELECT DISTINCT booking_id AS "bookingId"
|
||||
FROM freight.booking_handovers
|
||||
WHERE booking_id = ANY($1::uuid[])
|
||||
AND signed_at IS NULL AND deleted_at IS NULL
|
||||
AND mile_type = 'SELF_HAUL'`,
|
||||
AND signed_at IS NULL AND deleted_at IS NULL`,
|
||||
[ids],
|
||||
);
|
||||
const pending = new Set(rows.map((r) => r.bookingId));
|
||||
@@ -1392,15 +1404,6 @@ export class BookingsService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active company_profile id a customer's bookings should be
|
||||
* scoped to (importer/exporter mode). Null when not onboarded — callers fall
|
||||
* back to company-level scoping.
|
||||
*/
|
||||
async resolveActiveCompanyProfileId(userId: string): Promise<string | null> {
|
||||
return this.companiesService.resolveActiveCompanyProfileId(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize a customer's access to a single booking. Staff are scoped at the
|
||||
* controller (they pass `isStaff`); for a customer, the booking must belong
|
||||
@@ -1583,14 +1586,12 @@ export class BookingsService {
|
||||
schedule?.status ?? null;
|
||||
}
|
||||
|
||||
// A generated-but-unsigned SELF_HAUL handover means the customer must approve
|
||||
// delivery from the portal (booking-based, one per booking). EDR last-mile
|
||||
// handovers are per delivering truck and signed by the receiver at the door,
|
||||
// so they never surface the portal "Approve delivery" action.
|
||||
// A generated-but-unsigned handover means the customer must approve delivery
|
||||
// from the portal. Self-haul: booking-based, one per booking. EDR last-mile:
|
||||
// per delivering truck (generated on truck exit), signed one by one.
|
||||
const [pendingHandover] = await this.dataSource.query(
|
||||
`SELECT 1 FROM freight.booking_handovers
|
||||
WHERE booking_id = $1 AND signed_at IS NULL AND deleted_at IS NULL
|
||||
AND mile_type = 'SELF_HAUL'
|
||||
LIMIT 1`,
|
||||
[id],
|
||||
);
|
||||
|
||||
@@ -576,4 +576,35 @@ export class CustomerTruckService {
|
||||
}
|
||||
|
||||
/** Contract container sizes (e.g. "20ft" / "40ft") for the given container numbers. */
|
||||
|
||||
async addBulkTrucks(
|
||||
bookingId: string,
|
||||
dtos: AddCustomerTruckDto[],
|
||||
): Promise<{
|
||||
success: number;
|
||||
failed: number;
|
||||
errors: Array<{ row: number; truck: string; reason: string }>;
|
||||
}> {
|
||||
const errors: Array<{ row: number; truck: string; reason: string }> = [];
|
||||
let successCount = 0;
|
||||
|
||||
for (let i = 0; i < dtos.length; i++) {
|
||||
try {
|
||||
await this.addTruck(bookingId, dtos[i]);
|
||||
successCount++;
|
||||
} catch (err: any) {
|
||||
errors.push({
|
||||
row: i + 2, // Row 1 is header
|
||||
truck: dtos[i].truckPlateNumber,
|
||||
reason: err.message || 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: successCount,
|
||||
failed: errors.length,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { IsString, IsNotEmpty, IsIn, IsArray, ArrayMaxSize, ArrayUnique, Matches, IsOptional } from 'class-validator';
|
||||
import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto';
|
||||
|
||||
export class BulkCustomerTruckRow {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
truckPlateNumber!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
driverName!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsIn(CUSTOMER_TRUCK_TYPES)
|
||||
truckType!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(2)
|
||||
@ArrayUnique()
|
||||
@Matches(/^[A-Z]{4}\d{7}$/, {
|
||||
each: true,
|
||||
message: 'each container must be ISO format (e.g. ABCD1234567)',
|
||||
})
|
||||
containerNumbers?: (string | null)[];
|
||||
}
|
||||
|
||||
export class BulkCustomerTrucksDto {
|
||||
@IsArray()
|
||||
@ArrayMaxSize(100)
|
||||
trucks!: BulkCustomerTruckRow[];
|
||||
}
|
||||
|
||||
export interface BulkTruckUploadResult {
|
||||
success: number;
|
||||
failed: number;
|
||||
errors: Array<{
|
||||
row: number;
|
||||
truck: string;
|
||||
reason: string;
|
||||
}>;
|
||||
created: Array<{
|
||||
truckPlateNumber: string;
|
||||
driverName: string;
|
||||
containers: number;
|
||||
}>;
|
||||
}
|
||||
@@ -45,7 +45,6 @@ export const BOOKING_STATUSES = [
|
||||
'CONTRACT_ACTIVE',
|
||||
'CONTRACT_CLOSED',
|
||||
// Post counter-sign document-clearance gate (GL workflow).
|
||||
'AWAITING_CLEARANCE_PAYMENT', // clearance fee invoiced, unpaid — docs locked
|
||||
'AWAITING_DOCUMENTS',
|
||||
'DOCUMENTS_UNDER_REVIEW',
|
||||
'CLEARANCE_READY',
|
||||
@@ -521,10 +520,6 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'clearance_current_phase', type: 'varchar', length: 40, nullable: true })
|
||||
clearanceCurrentPhase?: string | null;
|
||||
|
||||
/** When the prepaid customs clearance service fee settled (GENERAL + customs). */
|
||||
@Column({ name: 'clearance_fee_paid_at', type: 'timestamptz', nullable: true })
|
||||
clearanceFeePaidAt?: Date | null;
|
||||
|
||||
@Column({ name: 'duty_required', type: 'boolean', nullable: true })
|
||||
dutyRequired?: boolean | null;
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { CreateCargoDto } from './dto/create-cargo.dto';
|
||||
import { UpdateCargoDto } from './dto/update-cargo.dto';
|
||||
import { LoadCargoDto } from './dto/load-cargo.dto';
|
||||
@@ -19,12 +20,12 @@ import { CargoesService } from './cargoes.service';
|
||||
|
||||
@ApiTags('cargoes')
|
||||
@Controller('cargoes')
|
||||
@FleetView()
|
||||
@FleetView(FREIGHT_PERMS.cargoes.view)
|
||||
export class CargoesController {
|
||||
constructor(private readonly cargoesService: CargoesService) {}
|
||||
|
||||
@Post()
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.cargoes.create)
|
||||
@ApiOperation({ summary: 'Create a new cargo' })
|
||||
create(@Body() dto: CreateCargoDto) {
|
||||
return this.cargoesService.create(dto);
|
||||
@@ -43,35 +44,35 @@ export class CargoesController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.cargoes.update)
|
||||
@ApiOperation({ summary: 'Update a cargo' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCargoDto) {
|
||||
return this.cargoesService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.cargoes.delete)
|
||||
@ApiOperation({ summary: 'Delete a cargo' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.cargoesService.remove(id);
|
||||
}
|
||||
|
||||
@Post(':id/load')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.cargoes.update)
|
||||
@ApiOperation({ summary: 'Load cargo into a container' })
|
||||
load(@Param('id', ParseUUIDPipe) id: string, @Body() dto: LoadCargoDto) {
|
||||
return this.cargoesService.loadCargo(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/unload')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.cargoes.update)
|
||||
@ApiOperation({ summary: 'Unload cargo from container' })
|
||||
unload(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.cargoesService.unloadCargo(id);
|
||||
}
|
||||
|
||||
@Post(':id/deliver')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.cargoes.update)
|
||||
@ApiOperation({ summary: 'Mark cargo as delivered' })
|
||||
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto?: DeliverCargoDto) {
|
||||
return this.cargoesService.deliverCargo(id, dto);
|
||||
|
||||
@@ -11,13 +11,22 @@ import {
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseInterceptors,
|
||||
UseGuards,
|
||||
UploadedFiles,
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { AnyFilesInterceptor } from "@nestjs/platform-express";
|
||||
import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger";
|
||||
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 { CompaniesService } from "./companies.service";
|
||||
import { CreateCompanyDto } from "./dto/create-company.dto";
|
||||
@@ -26,7 +35,6 @@ import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
|
||||
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
|
||||
import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto";
|
||||
import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto";
|
||||
import { SetActiveModeDto } from "./dto/set-active-mode.dto";
|
||||
import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto";
|
||||
import { StartOnboardingDto } from "./dto/start-onboarding.dto";
|
||||
import { DashboardQueryDto } from "./dto/dashboard-query.dto";
|
||||
@@ -60,6 +68,23 @@ interface CurrentIamUser {
|
||||
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")
|
||||
@Controller("companies")
|
||||
export class CompaniesController {
|
||||
@@ -353,21 +378,6 @@ export class CompaniesController {
|
||||
return this.companiesService.removePoaDelegationLetter(user.id, fileId);
|
||||
}
|
||||
|
||||
@Patch("active-mode")
|
||||
@ApiOperation({
|
||||
summary: "Switch the current user's active operational mode (importer/exporter)",
|
||||
})
|
||||
async setActiveMode(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Body() dto: SetActiveModeDto,
|
||||
): Promise<CompanyInfoResponseDto> {
|
||||
const { profile, company } = await this.companiesService.setActiveMode(
|
||||
user.id,
|
||||
dto.type,
|
||||
);
|
||||
return new CompanyInfoResponseDto(profile, company);
|
||||
}
|
||||
|
||||
@Patch("onboarding-step")
|
||||
@ApiOperation({ summary: "Persist the user's current onboarding wizard step" })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@@ -426,7 +436,7 @@ export class CompaniesController {
|
||||
|
||||
// Used by backoffice
|
||||
@Post()
|
||||
@FreightAdmin()
|
||||
@BookingStaff(FREIGHT_PERMS.customers.create)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter)",
|
||||
@@ -437,12 +447,14 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Get("stats")
|
||||
@BookingStaff(FREIGHT_PERMS.customers.view)
|
||||
@ApiOperation({ summary: "Company counts by status (KPI strip)" })
|
||||
async getStats(): Promise<CompanyStatsResponseDto> {
|
||||
return this.companiesService.getCompanyStats();
|
||||
}
|
||||
|
||||
@Get()
|
||||
@BookingStaff(FREIGHT_PERMS.customers.view)
|
||||
@ApiOperation({ summary: "List companies (paginated, filterable)" })
|
||||
async findAll(
|
||||
@Query() query: ListCompaniesQueryDto,
|
||||
@@ -452,6 +464,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@BookingStaff(FREIGHT_PERMS.customers.view)
|
||||
@ApiOperation({ summary: "Get company by ID" })
|
||||
async findById(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@@ -462,30 +475,77 @@ export class CompaniesController {
|
||||
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")
|
||||
@FreightAdmin()
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.customers.update,
|
||||
FREIGHT_PERMS.customers.verify,
|
||||
FREIGHT_PERMS.customers.deactivate,
|
||||
])
|
||||
@ApiOperation({ summary: "Update a company" })
|
||||
async update(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateCompanyDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
): 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);
|
||||
return new ResponseCompanyDto(company);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@FreightAdmin()
|
||||
@BookingStaff(FREIGHT_PERMS.customers.deactivate)
|
||||
@ApiOperation({ summary: "Soft-delete a company" })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async remove(@Param("id", ParseUUIDPipe) id: string): Promise<void> {
|
||||
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")
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiOperation({ summary: "List documents uploaded for a company" })
|
||||
async listDocuments(
|
||||
@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");
|
||||
return Promise.all(
|
||||
files.map(async (f) => ({
|
||||
@@ -506,7 +566,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Post("documents/:fileId/request-change")
|
||||
@FreightAdmin()
|
||||
@BookingStaff(FREIGHT_PERMS.customers.verify)
|
||||
@ApiOperation({
|
||||
summary: "Ask the customer to correct one uploaded document",
|
||||
description:
|
||||
@@ -548,14 +608,23 @@ export class CompaniesController {
|
||||
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")
|
||||
@FreightAdmin()
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.customers.verify,
|
||||
FREIGHT_PERMS.customers.deactivate,
|
||||
])
|
||||
@ApiOperation({ summary: "Update a company profile's approval status" })
|
||||
async updateCompanyProfileStatus(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Param("profileId", ParseUUIDPipe) profileId: string,
|
||||
@Body() dto: UpdateCompanyProfileStatusDto,
|
||||
): Promise<ResponseCompanyProfileDto> {
|
||||
assertFreightPermission(user, STATUS_PERM[dto.status]);
|
||||
const profile = await this.companiesService.setCompanyProfileStatus(
|
||||
profileId,
|
||||
dto.status,
|
||||
@@ -566,7 +635,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Get(":companyId/change-requests")
|
||||
@FreightAdmin()
|
||||
@BookingStaff(FREIGHT_PERMS.customers.view)
|
||||
@ApiOperation({ summary: "List a company's profile change requests" })
|
||||
async listChangeRequests(
|
||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||
@@ -576,7 +645,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Post("change-requests/:id/approve")
|
||||
@FreightAdmin()
|
||||
@BookingStaff(FREIGHT_PERMS.customers.verify)
|
||||
@ApiOperation({
|
||||
summary: "Approve a pending profile change request (applies the changes)",
|
||||
})
|
||||
@@ -592,7 +661,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Post("change-requests/:id/reject")
|
||||
@FreightAdmin()
|
||||
@BookingStaff(FREIGHT_PERMS.customers.verify)
|
||||
@ApiOperation({
|
||||
summary: "Reject a pending profile change request with a note",
|
||||
})
|
||||
@@ -610,7 +679,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Post(":companyId/profiles")
|
||||
@FreightAdmin()
|
||||
@BookingStaff(FREIGHT_PERMS.customers.update)
|
||||
@ApiOperation({ summary: "Add a profile (employee) to a company" })
|
||||
async createProfile(
|
||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||
@@ -624,6 +693,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Get(":companyId/profiles")
|
||||
@BookingStaff(FREIGHT_PERMS.customers.view)
|
||||
@ApiOperation({ summary: "List profiles for a company" })
|
||||
async listProfiles(
|
||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||
@@ -634,6 +704,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Get("profile/user/:userId")
|
||||
@BookingStaff(FREIGHT_PERMS.customers.view)
|
||||
@ApiOperation({ summary: "Get profile by IAM user ID" })
|
||||
async findProfileByUser(
|
||||
@Param("userId", ParseUUIDPipe) userId: string,
|
||||
|
||||
@@ -41,6 +41,18 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
AND ccr.deleted_at IS NULL
|
||||
)`;
|
||||
|
||||
/**
|
||||
* The `sortBy = 'review'` queue ordering: whatever marketing must act on
|
||||
* floats to the top. Tier 0 — submitted applications awaiting first approval
|
||||
* (drafts excluded: nothing to review yet). Tier 1 — approved customers with
|
||||
* a pending change request. Tier 2 — everyone else, drafts included.
|
||||
*/
|
||||
private static readonly REVIEW_TIER_SQL = `(CASE
|
||||
WHEN company.status = 'pending' AND NOT ${CompaniesRepository.DRAFT_SQL} THEN 0
|
||||
WHEN ${CompaniesRepository.PENDING_CHANGE_REQUEST_SQL} THEN 1
|
||||
ELSE 2
|
||||
END)`;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Company)
|
||||
repo: Repository<Company>,
|
||||
@@ -80,8 +92,8 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
status,
|
||||
onboardingCompleted,
|
||||
hasPendingChangeRequest,
|
||||
sortBy = 'name',
|
||||
sortOrder = 'ASC',
|
||||
sortBy = 'review',
|
||||
sortOrder = 'DESC',
|
||||
} = query;
|
||||
|
||||
const qb = this.repository
|
||||
@@ -137,8 +149,18 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
}
|
||||
|
||||
// sortBy is whitelisted by @IsIn on the DTO, so it is safe to interpolate.
|
||||
if (sortBy === 'review') {
|
||||
// Queue ordering: actionable tiers first, newest first within each. The
|
||||
// tier is selected under an alias because skip/take pagination with
|
||||
// joins re-derives the ORDER BY in a subquery — a raw expression there
|
||||
// breaks, a selected alias survives.
|
||||
qb.addSelect(CompaniesRepository.REVIEW_TIER_SQL, 'review_tier')
|
||||
.orderBy('review_tier', 'ASC')
|
||||
.addOrderBy('company.createdAt', 'DESC');
|
||||
} else {
|
||||
qb.orderBy(`company.${sortBy}`, sortOrder);
|
||||
}
|
||||
const [items, total] = await qb
|
||||
.orderBy(`company.${sortBy}`, sortOrder)
|
||||
// Names are not unique and createdAt can tie on bulk imports; the id
|
||||
// tiebreaker keeps paging stable instead of dropping/repeating rows.
|
||||
.addOrderBy('company.id', 'ASC')
|
||||
|
||||
@@ -201,18 +201,6 @@ export class CompaniesService {
|
||||
attributes: dto.attributes ?? null,
|
||||
});
|
||||
|
||||
// Default active mode from the chosen role(s): importer wins when both are
|
||||
// picked, otherwise the first allowed type chosen.
|
||||
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
|
||||
const chosenTypes = (dto.companyProfiles ?? [])
|
||||
.map((p) => p.type)
|
||||
.filter((t) => allowedTypes.includes(t));
|
||||
const activeProfileType =
|
||||
chosenTypes.find((t) => t === ProfileType.importer) ??
|
||||
chosenTypes[0] ??
|
||||
allowedTypes[0] ??
|
||||
null;
|
||||
|
||||
const profile = await this.profilesRepo.create({
|
||||
userId: identity.userId,
|
||||
companyId: company.id,
|
||||
@@ -220,7 +208,6 @@ export class CompaniesService {
|
||||
lastName: identity.lastName,
|
||||
jobTitle: dto.jobTitle ?? null,
|
||||
isPrimaryContact: dto.isPrimaryContact ?? true,
|
||||
activeProfileType,
|
||||
onboardingStep: "company",
|
||||
});
|
||||
|
||||
@@ -293,11 +280,6 @@ export class CompaniesService {
|
||||
|
||||
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
|
||||
const chosenTypes = roles.filter((t) => allowedTypes.includes(t));
|
||||
const activeProfileType =
|
||||
chosenTypes.find((t) => t === ProfileType.importer) ??
|
||||
chosenTypes[0] ??
|
||||
allowedTypes[0] ??
|
||||
null;
|
||||
|
||||
const company = await this.companiesRepo.create({
|
||||
name: identity.firstName
|
||||
@@ -316,7 +298,6 @@ export class CompaniesService {
|
||||
firstName: identity.firstName,
|
||||
lastName: identity.lastName,
|
||||
isPrimaryContact: true,
|
||||
activeProfileType,
|
||||
onboardingStep: "company",
|
||||
onboardingCompleted: false,
|
||||
});
|
||||
@@ -1090,6 +1071,23 @@ export class CompaniesService {
|
||||
if (!existing)
|
||||
throw new NotFoundException(`Company profile ${profileId} not found`);
|
||||
|
||||
// Suspension and reactivation must carry a staff explanation — the customer
|
||||
// sees it, so "why" can never be left blank. Reactivation is the
|
||||
// active-write that leaves Suspended; a first approval stays note-free.
|
||||
const reactivating =
|
||||
status === ProfileStatus.Active &&
|
||||
existing.status === ProfileStatus.Suspended;
|
||||
if (
|
||||
(status === ProfileStatus.Suspended || reactivating) &&
|
||||
!note?.trim()
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
status === ProfileStatus.Suspended
|
||||
? "A message explaining the suspension is required — the customer will see it."
|
||||
: "A message explaining the reactivation is required — the customer will see it.",
|
||||
);
|
||||
}
|
||||
|
||||
// A self-registered company is only reviewable once its owner submits the
|
||||
// onboarding wizard (markOnboardingComplete) — until then its profiles are
|
||||
// half-filled drafts and approving one would mint a reference against an
|
||||
@@ -1176,9 +1174,13 @@ export class CompaniesService {
|
||||
);
|
||||
}
|
||||
|
||||
// Track the review outcome. Rejection keeps the note so the customer knows
|
||||
// why; approval clears it. Any decision stamps the reviewer + time.
|
||||
if (status === ProfileStatus.Rejected) {
|
||||
// Track the review outcome. Rejection and suspension keep the note so the
|
||||
// customer knows why; approval/reactivation clears it. Any decision stamps
|
||||
// the reviewer + time.
|
||||
if (
|
||||
status === ProfileStatus.Rejected ||
|
||||
status === ProfileStatus.Suspended
|
||||
) {
|
||||
patch.reviewNote = note ?? null;
|
||||
} else if (status === ProfileStatus.Active) {
|
||||
patch.reviewNote = null;
|
||||
@@ -1192,23 +1194,50 @@ export class CompaniesService {
|
||||
if (!updated)
|
||||
throw new NotFoundException(`Company profile ${existing.id} not found`);
|
||||
|
||||
// Approving any profile promotes a pending company to active, so the
|
||||
// customer can start working as soon as their first profile is cleared.
|
||||
if (status === ProfileStatus.Active) {
|
||||
// Every reviewed transition that changes what the customer can do is told
|
||||
// to them, carrying the staff message so they know why. Approval has no
|
||||
// message (the note is cleared); the others require one.
|
||||
const change =
|
||||
status === ProfileStatus.Suspended
|
||||
? "suspended"
|
||||
: status === ProfileStatus.Rejected
|
||||
? "rejected"
|
||||
: status === ProfileStatus.Active
|
||||
? existing.status === ProfileStatus.Suspended
|
||||
? "reactivated"
|
||||
: "approved"
|
||||
: null;
|
||||
if (change) {
|
||||
const company = await this.companiesRepo.findById(updated.companyId);
|
||||
if (company && company.status === CompanyStatus.Pending) {
|
||||
await this.companiesRepo.update(updated.companyId, {
|
||||
status: CompanyStatus.Active,
|
||||
});
|
||||
if (company) {
|
||||
this.companyNotifier.profileStatusChanged(
|
||||
company,
|
||||
updated.type,
|
||||
change,
|
||||
note ?? "",
|
||||
);
|
||||
// The first approved role promotes a pending company to active — a
|
||||
// bigger event (the account itself goes live), so tell them that too.
|
||||
if (
|
||||
status === ProfileStatus.Active &&
|
||||
company.status === CompanyStatus.Pending
|
||||
) {
|
||||
await this.companiesRepo.update(updated.companyId, {
|
||||
status: CompanyStatus.Active,
|
||||
});
|
||||
this.companyNotifier.companyApproved(company);
|
||||
}
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer reapplies for a rejected operational role (after fixing whatever the
|
||||
* reviewer flagged, e.g. re-uploading a license): flip it back to Pending and
|
||||
* clear the rejection note so it re-enters the approval queue.
|
||||
* Customer reapplies for a rejected or suspended operational role (after
|
||||
* fixing whatever the reviewer flagged, e.g. re-uploading a license): flip it
|
||||
* back to Pending and clear the review note so it re-enters the approval
|
||||
* queue. Suspension is a staff lockout, so resubmitting is an appeal — the
|
||||
* backoffice still has to approve before the role goes live again.
|
||||
*/
|
||||
async reapplyCompanyProfile(
|
||||
userId: string,
|
||||
@@ -1223,9 +1252,12 @@ export class CompaniesService {
|
||||
if (!target || target.companyId !== companyId) {
|
||||
throw new NotFoundException(`Company profile ${profileId} not found`);
|
||||
}
|
||||
if (target.status !== ProfileStatus.Rejected) {
|
||||
if (
|
||||
target.status !== ProfileStatus.Rejected &&
|
||||
target.status !== ProfileStatus.Suspended
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"Only a rejected role can be resubmitted for approval",
|
||||
"Only a rejected or suspended role can be resubmitted for approval",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1349,10 +1381,9 @@ export class CompaniesService {
|
||||
|
||||
/**
|
||||
* Create a single operational profile for the current user's company. The new
|
||||
* role starts Pending, so it deliberately does NOT become the active mode:
|
||||
* switching onto an unapproved profile would strip the user of `canBook` and
|
||||
* block them from creating contracts under the role they already had approved.
|
||||
* Callers switch explicitly via {@link setActiveMode} once the role is Active.
|
||||
* role starts Pending and carries no reference until a backoffice reviewer
|
||||
* approves it; a booking/contract resolves its profile from the trade
|
||||
* direction at creation time, so no "active mode" is stored.
|
||||
*/
|
||||
async createCompanyProfileForUser(
|
||||
userId: string,
|
||||
@@ -1387,40 +1418,6 @@ export class CompaniesService {
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the user's active operational mode. The target profile must already
|
||||
* exist — clients create it first via createCompanyProfileForUser.
|
||||
*/
|
||||
async setActiveMode(
|
||||
userId: string,
|
||||
type: ProfileType,
|
||||
): Promise<{ profile: ExternalProfile; company: Company }> {
|
||||
const profile = await this.profilesRepo.findByUserId(userId);
|
||||
if (!profile)
|
||||
throw new NotFoundException(`Profile for user ${userId} not found`);
|
||||
|
||||
const companyId = profile.company?.id ?? profile.companyId;
|
||||
const company = await this.findCompanyById(companyId);
|
||||
|
||||
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
|
||||
if (!allowedTypes.includes(type)) {
|
||||
throw new BadRequestException(
|
||||
`Profile type "${type}" is not allowed for company type "${company.type}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await this.companyProfilesRepo.findByType(companyId, type);
|
||||
if (!existing) {
|
||||
throw new ConflictException(
|
||||
`No ${type} profile exists yet — create it before switching`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.profilesRepo.update(profile.id, { activeProfileType: type });
|
||||
|
||||
return this.getCompanyInfoByUserId(userId);
|
||||
}
|
||||
|
||||
async setOnboardingStep(userId: string, step: string): Promise<void> {
|
||||
const profile = await this.profilesRepo.findByUserId(userId);
|
||||
if (!profile)
|
||||
@@ -1611,21 +1608,68 @@ export class CompaniesService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Block a customer from booking under a profile that isn't approved yet.
|
||||
* Called from the booking-create path for self-service bookings; staff- and
|
||||
* government-initiated bookings bypass this. No-op when the profile can't be
|
||||
* found (defensive — resolution is best-effort upstream).
|
||||
* Block a self-service action when the company account isn't active, naming
|
||||
* the actual status — a suspended customer told "awaiting approval" has no
|
||||
* idea what happened or who to call.
|
||||
*/
|
||||
assertCompanyActiveFor(company: Company, action: string): void {
|
||||
if (company.status === CompanyStatus.Active) return;
|
||||
switch (company.status) {
|
||||
case CompanyStatus.Suspended:
|
||||
throw new ForbiddenException(
|
||||
`Your company account is suspended — you can't create ${action} right now. ` +
|
||||
`Please contact EDR support for details.`,
|
||||
);
|
||||
case CompanyStatus.Blacklisted:
|
||||
throw new ForbiddenException(
|
||||
`Your company account is blacklisted — you can't create ${action}. ` +
|
||||
`Please contact EDR support.`,
|
||||
);
|
||||
default:
|
||||
throw new ForbiddenException(
|
||||
`Your company is awaiting approval — you can't create ${action} yet.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Block a customer from booking under a profile that isn't approved yet — or
|
||||
* that a reviewer has since suspended. Called from the booking/contract
|
||||
* create path for self-service actions; staff- and government-initiated ones
|
||||
* bypass this. No-op when the profile can't be found (defensive — resolution
|
||||
* is best-effort upstream). The message names the profile's real status:
|
||||
* suspension in particular is per-role, so the customer must learn which
|
||||
* operation is blocked (their other roles still work).
|
||||
*/
|
||||
async assertCompanyProfileApprovedForBooking(
|
||||
companyProfileId: string,
|
||||
): Promise<void> {
|
||||
const profile = await this.companyProfilesRepo.findById(companyProfileId);
|
||||
if (!profile) return;
|
||||
if (profile.status !== ProfileStatus.Active) {
|
||||
const role = profile.type.replace(/_/g, " ");
|
||||
throw new ForbiddenException(
|
||||
`Your ${role} profile is awaiting approval. You'll be able to create bookings once it has been approved.`,
|
||||
);
|
||||
if (profile.status === ProfileStatus.Active) return;
|
||||
|
||||
const role = profile.type.replace(/_/g, " ");
|
||||
switch (profile.status) {
|
||||
case ProfileStatus.Suspended:
|
||||
throw new ForbiddenException(
|
||||
`Your ${role} role is suspended${
|
||||
profile.reviewNote ? ` — ${profile.reviewNote}` : ""
|
||||
}. Your other roles are unaffected. Please contact EDR support to resolve this.`,
|
||||
);
|
||||
case ProfileStatus.Blacklisted:
|
||||
throw new ForbiddenException(
|
||||
`Your ${role} role is blacklisted. Please contact EDR support.`,
|
||||
);
|
||||
case ProfileStatus.Rejected:
|
||||
throw new ForbiddenException(
|
||||
`Your ${role} role was rejected${
|
||||
profile.reviewNote ? ` — ${profile.reviewNote}` : ""
|
||||
}. Amend and resubmit it from your settings page.`,
|
||||
);
|
||||
default:
|
||||
throw new ForbiddenException(
|
||||
`Your ${role} profile is awaiting approval. You'll be able to proceed once it has been approved.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2244,15 +2288,14 @@ export class CompaniesService {
|
||||
/**
|
||||
* Resolve which company_profile a new booking belongs to, from the company
|
||||
* and the booking's trade direction. IMPORT → importer profile, EXPORT →
|
||||
* exporter profile; for DOMESTIC or a forwarder/single-profile company (or
|
||||
* when the natural profile doesn't exist) it falls back to the user's active
|
||||
* profile, then the company's first profile. Returns null when the company
|
||||
* has no profiles at all.
|
||||
* exporter profile; for DOMESTIC (or when the natural profile doesn't exist,
|
||||
* e.g. a freight forwarder) it falls back to the company's first profile.
|
||||
* Callers that need a specific role (a forwarder) pass an explicit
|
||||
* companyProfileId instead. Returns null when the company has no profiles.
|
||||
*/
|
||||
async resolveCompanyProfileIdForBooking(
|
||||
companyId: string,
|
||||
tradeDirection: string,
|
||||
fallbackType?: ProfileType | null,
|
||||
): Promise<string | null> {
|
||||
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
|
||||
if (profiles.length === 0) return null;
|
||||
@@ -2264,30 +2307,12 @@ export class CompaniesService {
|
||||
? ProfileType.exporter
|
||||
: null;
|
||||
|
||||
const byType = (type?: ProfileType | null) =>
|
||||
type ? profiles.find((p) => p.type === type) : undefined;
|
||||
|
||||
const match = byType(naturalType) ?? byType(fallbackType) ?? profiles[0];
|
||||
const match =
|
||||
(naturalType && profiles.find((p) => p.type === naturalType)) ??
|
||||
profiles[0];
|
||||
return match?.id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the company_profile a customer's data should be scoped to, from
|
||||
* their persisted active mode. Returns null when nothing can be resolved
|
||||
* (not onboarded yet) so callers can fall back to company-level scoping.
|
||||
*/
|
||||
async resolveActiveCompanyProfileId(userId: string): Promise<string | null> {
|
||||
try {
|
||||
const { profile, company } = await this.getCompanyInfoByUserId(userId);
|
||||
const type = profile.activeProfileType;
|
||||
if (!type) return null;
|
||||
const match = company.companyProfiles?.find((p) => p.type === type);
|
||||
return match?.id ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchETradeData(tin: string) {
|
||||
const { businessInfo, companyInfo } =
|
||||
await this.etradeService.resolveCompanyData(tin);
|
||||
|
||||
@@ -59,23 +59,106 @@ export class CompanyNotifierService {
|
||||
}
|
||||
}
|
||||
|
||||
/** SMS + email + in-app account-status item to the company contact. */
|
||||
private notifyAccount(
|
||||
company: Company,
|
||||
title: string,
|
||||
body: string,
|
||||
link = "/settings",
|
||||
): void {
|
||||
void this.notifyContact(company, `${title}. ${body}`);
|
||||
void this.inbox.notify({
|
||||
recipients: { companyId: company.id },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.ACCOUNT_STATUS,
|
||||
title,
|
||||
body,
|
||||
link,
|
||||
data: { companyId: company.id, status: company.status },
|
||||
priority: NotificationPriority.HIGH,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the customer their account was suspended or blacklisted. Called only on
|
||||
* a real transition into one of those statuses; other status writes are silent.
|
||||
* Tell the customer their account changed status. Fires on the transitions
|
||||
* that change what they can do: suspended/blacklisted (locked out) and
|
||||
* reactivated (back to Active from a lockout). Silent otherwise.
|
||||
*/
|
||||
statusChanged(company: Company, previous: CompanyStatus): void {
|
||||
const status = company.status;
|
||||
if (status === previous) return;
|
||||
|
||||
if (status === CompanyStatus.Active && PUNITIVE_STATUSES.includes(previous)) {
|
||||
this.logger.log(`ACCOUNT_REACTIVATED — ${company.id}`);
|
||||
this.notifyAccount(
|
||||
company,
|
||||
"Account reactivated",
|
||||
"Your company account has been reactivated. " +
|
||||
"You can submit new contracts and bookings again.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!PUNITIVE_STATUSES.includes(status)) return;
|
||||
|
||||
const label = status === CompanyStatus.Suspended ? "suspended" : "blacklisted";
|
||||
const title = `Account ${label}`;
|
||||
const body =
|
||||
`Your company account has been ${label}. ` +
|
||||
`You will not be able to submit new contracts or bookings. ` +
|
||||
`Please contact EDR support for assistance.`;
|
||||
|
||||
this.logger.log(`ACCOUNT_${label.toUpperCase()} — ${company.id}`);
|
||||
this.notifyAccount(
|
||||
company,
|
||||
`Account ${label}`,
|
||||
`Your company account has been ${label}. ` +
|
||||
`You will not be able to submit new contracts or bookings. ` +
|
||||
`Please contact EDR support for assistance.`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the customer their company account was approved and is now live — the
|
||||
* first operational role clearing review promotes a pending company to Active.
|
||||
*/
|
||||
companyApproved(company: Company): void {
|
||||
this.logger.log(`ACCOUNT_APPROVED — ${company.id}`);
|
||||
this.notifyAccount(
|
||||
company,
|
||||
"Account approved",
|
||||
"Your company account has been approved and is now active. " +
|
||||
"You can start submitting bookings and contracts.",
|
||||
"/dashboard",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the customer one of their operational roles changed review status —
|
||||
* approved, rejected, suspended, or reactivated — quoting the staff message
|
||||
* when one was given (rejection/suspension/reactivation require one; approval
|
||||
* carries none).
|
||||
*/
|
||||
profileStatusChanged(
|
||||
company: Company,
|
||||
profileType: string,
|
||||
change: "approved" | "rejected" | "suspended" | "reactivated",
|
||||
staffMessage: string,
|
||||
): void {
|
||||
const title = `${profileType} role ${change}`;
|
||||
const consequence: Record<typeof change, string> = {
|
||||
approved: "You can now operate under this role.",
|
||||
rejected:
|
||||
"You will not be able to operate under this role. Amend the required " +
|
||||
"documents and resubmit it for approval from your settings page.",
|
||||
suspended:
|
||||
"You will not be able to operate under this role until it is " +
|
||||
"reactivated; your other roles are unaffected.",
|
||||
reactivated: "You can operate under this role again.",
|
||||
};
|
||||
const message = staffMessage.trim();
|
||||
const body =
|
||||
`Your company's ${profileType} role has been ${change}. ` +
|
||||
`${consequence[change]}` +
|
||||
(message ? ` Message from EDR staff: ${message}` : "");
|
||||
|
||||
this.logger.log(
|
||||
`PROFILE_${change.toUpperCase()} — ${company.id} / ${profileType}`,
|
||||
);
|
||||
void this.notifyContact(company, `${title}. ${body}`);
|
||||
void this.inbox.notify({
|
||||
recipients: { companyId: company.id },
|
||||
@@ -84,7 +167,7 @@ export class CompanyNotifierService {
|
||||
title,
|
||||
body,
|
||||
link: "/settings",
|
||||
data: { companyId: company.id, status },
|
||||
data: { companyId: company.id, profileType, change, staffMessage: message },
|
||||
priority: NotificationPriority.HIGH,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export class CompanyInfoResponseDto {
|
||||
company: Company,
|
||||
changeRequest?: CompanyChangeRequest | null,
|
||||
) {
|
||||
this.profile = new ResponseExternalProfileDto(profile, company);
|
||||
this.profile = new ResponseExternalProfileDto(profile);
|
||||
this.company = new ResponseCompanyDto(company);
|
||||
|
||||
const open =
|
||||
|
||||
@@ -60,15 +60,19 @@ export class ListCompaniesQueryDto {
|
||||
hasPendingChangeRequest?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: ["name", "createdAt", "updatedAt"],
|
||||
default: "name",
|
||||
description: "Column to order by. Defaults to name for backwards compatibility.",
|
||||
enum: ["review", "name", "createdAt", "updatedAt"],
|
||||
default: "review",
|
||||
description:
|
||||
"Column to order by. The default `review` is a review-queue ordering: " +
|
||||
"companies awaiting first approval, then those with a pending change " +
|
||||
"request, then everyone else — newest first within each group. The " +
|
||||
"other values are plain column sorts.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(["name", "createdAt", "updatedAt"])
|
||||
sortBy?: "name" | "createdAt" | "updatedAt";
|
||||
@IsIn(["review", "name", "createdAt", "updatedAt"])
|
||||
sortBy?: "review" | "name" | "createdAt" | "updatedAt";
|
||||
|
||||
@ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "ASC" })
|
||||
@ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "DESC" })
|
||||
@IsOptional()
|
||||
@Transform(({ value }: { value: unknown }) => String(value).toUpperCase())
|
||||
@IsIn(["ASC", "DESC"])
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Company } from '../entities/company.entity';
|
||||
import {
|
||||
ExternalProfile,
|
||||
} from '../entities/external-profile.entity';
|
||||
import { ProfileType } from '../entities/company-profile.entity';
|
||||
|
||||
export class ResponseExternalProfileDto {
|
||||
id: string;
|
||||
@@ -13,20 +11,12 @@ export class ResponseExternalProfileDto {
|
||||
nationalId?: string | null;
|
||||
jobTitle?: string | null;
|
||||
isPrimaryContact: boolean;
|
||||
/** The active operational mode (importer/exporter/forwarder). */
|
||||
activeProfileType?: ProfileType | null;
|
||||
/**
|
||||
* The id of the company_profile matching activeProfileType, resolved
|
||||
* server-side so the client never re-derives it. Null until a company
|
||||
* (with profiles) is loaded and a matching profile exists.
|
||||
*/
|
||||
activeCompanyProfileId?: string | null;
|
||||
onboardingStep?: string | null;
|
||||
onboardingCompleted: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
constructor(profile: ExternalProfile, company?: Company) {
|
||||
constructor(profile: ExternalProfile) {
|
||||
this.id = profile.id;
|
||||
this.userId = profile.userId;
|
||||
this.companyId = profile.companyId;
|
||||
@@ -35,13 +25,8 @@ export class ResponseExternalProfileDto {
|
||||
this.nationalId = profile.nationalId;
|
||||
this.jobTitle = profile.jobTitle;
|
||||
this.isPrimaryContact = profile.isPrimaryContact;
|
||||
this.activeProfileType = profile.activeProfileType ?? null;
|
||||
this.onboardingStep = profile.onboardingStep ?? null;
|
||||
this.onboardingCompleted = profile.onboardingCompleted ?? false;
|
||||
this.activeCompanyProfileId =
|
||||
company?.companyProfiles?.find(
|
||||
(p) => p.type === profile.activeProfileType,
|
||||
)?.id ?? null;
|
||||
this.createdAt = profile.createdAt;
|
||||
this.updatedAt = profile.updatedAt;
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { IsEnum } from 'class-validator';
|
||||
import { ProfileType } from '../entities/company-profile.entity';
|
||||
|
||||
export class SetActiveModeDto {
|
||||
@IsEnum(ProfileType)
|
||||
type!: ProfileType;
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, ManyToOne, JoinColumn } from 'typeorm';
|
||||
import { Company } from './company.entity';
|
||||
import { ProfileType } from './company-profile.entity';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'external_profiles' })
|
||||
@Index(['userId'])
|
||||
@@ -32,21 +31,6 @@ export class ExternalProfile extends BaseEntity {
|
||||
@Column({ name: 'is_primary_contact', type: 'boolean', default: false })
|
||||
isPrimaryContact!: boolean;
|
||||
|
||||
/**
|
||||
* The operational profile the user is currently "in" (importer vs exporter,
|
||||
* or the single forwarder profile). Drives header switching and scopes the
|
||||
* customer's bookings / dashboard to that company_profile. Nullable for
|
||||
* users who haven't picked a role yet.
|
||||
*/
|
||||
@Column({
|
||||
name: 'active_profile_type',
|
||||
type: 'varchar',
|
||||
length: 32,
|
||||
nullable: true,
|
||||
enum: ProfileType,
|
||||
})
|
||||
activeProfileType?: ProfileType | null;
|
||||
|
||||
/** Coarse resume point for the onboarding wizard (e.g. 'role', 'company', 'documents', 'done'). */
|
||||
@Column({
|
||||
name: 'onboarding_step',
|
||||
|
||||
@@ -10,18 +10,19 @@ import {
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { FleetManage, FleetView } from "../../common/booking-guards";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { ConsignmentsService } from "./consignments.service";
|
||||
import { CreateConsignmentDto } from "./dto/create-consignment.dto";
|
||||
import { FilterConsignmentDto } from "./dto/filter-consignment.dto";
|
||||
|
||||
@ApiTags("consignments")
|
||||
@Controller("consignments")
|
||||
@FleetView()
|
||||
@FleetView(FREIGHT_PERMS.consignments.view)
|
||||
export class ConsignmentsController {
|
||||
constructor(private readonly consignmentsService: ConsignmentsService) {}
|
||||
|
||||
@Post()
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.consignments.create)
|
||||
@ApiOperation({ summary: "Create a new consignment" })
|
||||
create(@Body() dto: CreateConsignmentDto) {
|
||||
return this.consignmentsService.create(dto);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { CreateContainerDto } from './dto/create-container.dto';
|
||||
import { UpdateContainerDto } from './dto/update-container.dto';
|
||||
import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto';
|
||||
@@ -18,12 +19,12 @@ import { ContainersService } from './containers.service';
|
||||
|
||||
@ApiTags('containers')
|
||||
@Controller('containers')
|
||||
@FleetView()
|
||||
@FleetView(FREIGHT_PERMS.containers.view)
|
||||
export class ContainersController {
|
||||
constructor(private readonly containersService: ContainersService) {}
|
||||
|
||||
@Post()
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.containers.create)
|
||||
@ApiOperation({ summary: 'Create a new container' })
|
||||
create(@Body() dto: CreateContainerDto) {
|
||||
return this.containersService.create(dto);
|
||||
@@ -42,28 +43,28 @@ export class ContainersController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.containers.update)
|
||||
@ApiOperation({ summary: 'Update a container' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContainerDto) {
|
||||
return this.containersService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.containers.delete)
|
||||
@ApiOperation({ summary: 'Delete a container' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.containersService.remove(id);
|
||||
}
|
||||
|
||||
@Post(':id/assign-wagon')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.containers.update)
|
||||
@ApiOperation({ summary: 'Assign container to a wagon' })
|
||||
assignToWagon(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignContainerToWagonDto) {
|
||||
return this.containersService.assignToWagon(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/unassign-wagon')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.containers.update)
|
||||
@ApiOperation({ summary: 'Unassign container from wagon' })
|
||||
unassignFromWagon(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.containersService.unassignFromWagon(id);
|
||||
|
||||
@@ -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, // workflowService
|
||||
{} as never, // invoiceService
|
||||
{} as never, // clearanceFeeService
|
||||
{ createdToStaff: jest.fn() } as never, // bookingNotifier
|
||||
{} as never, // dataSource
|
||||
{} as never, // trainSchedulingService
|
||||
|
||||
@@ -57,7 +57,6 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
|
||||
milestoneService as never,
|
||||
{} as never, // workflowService
|
||||
invoiceService as never,
|
||||
{} as never, // clearanceFeeService
|
||||
{ createdToStaff: jest.fn() } as never, // bookingNotifier
|
||||
{} as never, // dataSource
|
||||
{} as never, // trainSchedulingService
|
||||
|
||||
@@ -38,7 +38,6 @@ import { hasFreightPermission } from '../../common/freight-permission.util';
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { ContractRoute } from './entities/contract-route.entity';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { ClearanceFeeService } from './clearance-fee.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||
import {
|
||||
@@ -97,7 +96,6 @@ export class ContractBookingService {
|
||||
private readonly milestoneService: ClearanceMilestoneService,
|
||||
private readonly workflowService: ClearanceWorkflowService,
|
||||
private readonly invoiceService: BookingInvoiceService,
|
||||
private readonly clearanceFeeService: ClearanceFeeService,
|
||||
private readonly bookingNotifier: BookingLifecycleNotifierService,
|
||||
private readonly dataSource: DataSource,
|
||||
@Inject(forwardRef(() => TrainSchedulingService))
|
||||
@@ -289,6 +287,7 @@ export class ContractBookingService {
|
||||
tradeDirection: contract.tradeDirection,
|
||||
freightType,
|
||||
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
||||
cargoFreeText: dto.cargoFreeText?.trim() || null,
|
||||
isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'),
|
||||
isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'),
|
||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||
@@ -320,6 +319,12 @@ export class ContractBookingService {
|
||||
await this.applyWeightResults(loaded);
|
||||
}
|
||||
const computed = await this.bookingPricingService.computePriceForBooking(loaded);
|
||||
// A partially-priced booking (e.g. 40ft has a rate, 20ft has none) has
|
||||
// a positive total, so the zero-price gate below misses it — enforce
|
||||
// the pricing hard blocks first. The catch below rolls everything back.
|
||||
if (computed.hardBlocked.length > 0) {
|
||||
throw new BadRequestException(computed.hardBlocked.join('; '));
|
||||
}
|
||||
// Reject a zero-price booking outright. A total of 0 means no contract rate
|
||||
// matched the route/container (or the rate is unset), so the booking is not
|
||||
// valid to ship or invoice. The catch below rolls back the row + its lines.
|
||||
@@ -530,11 +535,8 @@ export class ContractBookingService {
|
||||
|
||||
const route = await this.resolveRoute(contract, opts.contractRouteId);
|
||||
|
||||
// Prepay gate: each shipment request owes its own flat clearance service
|
||||
// fee before the document step opens (the paid event advances the booking
|
||||
// to AWAITING_DOCUMENTS). Government/unlinked contracts skip the gate.
|
||||
const feeGate = await this.clearanceFeeService.gateApplies(contract);
|
||||
|
||||
// No prepay gate: the clearance service fee is billed on the booking
|
||||
// invoice at completion, so the document step opens immediately.
|
||||
const booking = await insertWithGeneratedReference(
|
||||
() => this.generateReference(),
|
||||
(reference) =>
|
||||
@@ -544,7 +546,7 @@ export class ContractBookingService {
|
||||
companyProfileId: contract.companyProfileId ?? null,
|
||||
isGovernment: contract.isGovernment,
|
||||
governmentInstitution: contract.governmentInstitution ?? null,
|
||||
status: feeGate ? 'AWAITING_CLEARANCE_PAYMENT' : 'AWAITING_DOCUMENTS',
|
||||
status: 'AWAITING_DOCUMENTS',
|
||||
bookingType: 'ONE_TIME',
|
||||
contractId: contract.id,
|
||||
contractRouteId: route?.id ?? null,
|
||||
@@ -583,10 +585,6 @@ export class ContractBookingService {
|
||||
contract.tradeDirection,
|
||||
);
|
||||
|
||||
if (feeGate) {
|
||||
await this.clearanceFeeService.issueForBooking(booking, contract);
|
||||
}
|
||||
|
||||
const created =
|
||||
(await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking;
|
||||
this.bookingNotifier.createdToStaff(created);
|
||||
@@ -732,6 +730,7 @@ export class ContractBookingService {
|
||||
}
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
||||
cargoFreeText: dto.cargoFreeText?.trim() || null,
|
||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
|
||||
} as never);
|
||||
@@ -744,15 +743,20 @@ export class ContractBookingService {
|
||||
const computed = await this.bookingPricingService.computePriceForBooking(loaded);
|
||||
// A zero price means no contract rate matches — roll the cargo back so
|
||||
// the instance stays CLEARANCE_READY and can be completed again once
|
||||
// the contract rates are fixed (the clearance work is not lost).
|
||||
if (!(computed.totalAmount > 0)) {
|
||||
// the contract rates are fixed (the clearance work is not lost). A
|
||||
// pricing hard block (e.g. one of two container sizes has no rate)
|
||||
// rolls back the same way: a partially-priced total is positive but
|
||||
// the booking must not proceed.
|
||||
if (!(computed.totalAmount > 0) || computed.hardBlocked.length > 0) {
|
||||
await this.bookingsRepository.deleteContainers(booking.id);
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
cargoTotalWeightVgm: 0,
|
||||
} as never);
|
||||
throw new BadRequestException(
|
||||
'Booking price came out as 0 — no contract rate matches this ' +
|
||||
'route/cargo. Set the contract rate and try again.',
|
||||
computed.hardBlocked.length > 0
|
||||
? computed.hardBlocked.join('; ')
|
||||
: 'Booking price came out as 0 — no contract rate matches this ' +
|
||||
'route/cargo. Set the contract rate and try again.',
|
||||
);
|
||||
}
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
@@ -1785,6 +1789,10 @@ export class ContractBookingService {
|
||||
// pricing service derives wagon counts from the in-memory lines.
|
||||
const route = await this.resolveRoute(contract, dto.contractRouteId);
|
||||
const previewBooking = Object.assign(new Booking(), {
|
||||
// contractId makes the preview price off the contract's frozen rate
|
||||
// snapshots exactly like the persisted booking will — without it the
|
||||
// preview total is 0 on a leg with no live rate and the form blocks.
|
||||
contractId: contract.id,
|
||||
freightType: contract.freightType,
|
||||
tradeDirection: contract.tradeDirection,
|
||||
paymentCurrency: contract.paymentCurrency,
|
||||
@@ -1892,7 +1900,10 @@ export class ContractBookingService {
|
||||
overweightSurchargeAmount,
|
||||
currency: computed.currency,
|
||||
pairingErrors,
|
||||
capacityErrors: [...scopeErrors, ...capacityErrors],
|
||||
// Pricing hard blocks (missing rate for a container size / requested
|
||||
// service) ride the capacity-errors channel so the form hard-blocks in
|
||||
// the preview instead of failing at the create call.
|
||||
capacityErrors: [...scopeErrors, ...capacityErrors, ...computed.hardBlocked],
|
||||
containerClashErrors,
|
||||
spaceErrors,
|
||||
lineItems: computed.lineItems,
|
||||
|
||||
@@ -499,11 +499,6 @@ export class ContractClearanceService {
|
||||
files: Express.Multer.File[],
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
if (contract.status === 'AWAITING_CLEARANCE_PAYMENT') {
|
||||
throw new ConflictException(
|
||||
'The customs clearance service fee has not been paid yet — pay it from the portal to unlock document upload.',
|
||||
);
|
||||
}
|
||||
if (
|
||||
contract.status !== 'AWAITING_CLEARANCE_DOCUMENTS' &&
|
||||
contract.status !== 'CLEARANCE_UNDER_REVIEW'
|
||||
|
||||
@@ -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 ──────────────────────────
|
||||
|
||||
/** 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 {
|
||||
code: 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;
|
||||
containerSize?: string | null;
|
||||
conditionalOn?: string | null;
|
||||
@@ -37,8 +37,9 @@ function toContractUnit(rateUnit: string): ContractUnitRateLineItem['unit'] {
|
||||
return 'per_ton';
|
||||
case 'PER_KM':
|
||||
return 'per_km';
|
||||
case 'PER_CONTAINER':
|
||||
case 'PER_WAGON':
|
||||
return 'per_wagon';
|
||||
case 'PER_CONTAINER':
|
||||
return 'per_container';
|
||||
default:
|
||||
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
|
||||
// contract like hazard/reefer. Billed at booking per WITH_RETURN container.
|
||||
if (
|
||||
contract.freightType === 'CONTAINER' &&
|
||||
contract.equipmentReturn === 'WITH_RETURN'
|
||||
) {
|
||||
const withReturn = liveRates.find(
|
||||
(r) => r.rateType === 'RETURN_SURCHARGE' && r.currency === 'USD',
|
||||
);
|
||||
if (withReturn && Number(withReturn.rateValue) > 0) {
|
||||
lineItems.push({
|
||||
code: 'RETURN_SURCHARGE',
|
||||
label: 'Empty container return',
|
||||
unit: toContractUnit(withReturn.rateUnit),
|
||||
unitPrice: convert(Number(withReturn.rateValue)),
|
||||
conditionalOn: 'with_return',
|
||||
// Return is sold per direction + route + container type (import-only) —
|
||||
// one display line per contract size that has a configured rate. A size
|
||||
// with no rate shows nothing here and hard-blocks at booking time.
|
||||
// ponytail: bookings bill the live route rate, not a frozen snapshot.
|
||||
const route = [...(contract.routes ?? [])].sort(
|
||||
(a, b) => a.sortOrder - b.sortOrder,
|
||||
)[0];
|
||||
const onLeg = route
|
||||
? liveRates.filter(
|
||||
(r) =>
|
||||
r.rateType === 'RETURN_SURCHARGE' &&
|
||||
r.currency === 'USD' &&
|
||||
r.tradeDirection === contract.tradeDirection &&
|
||||
r.originYardId === route.originYardId &&
|
||||
r.destinationYardId === route.destinationYardId,
|
||||
)
|
||||
: [];
|
||||
if (onLeg.length > 0) {
|
||||
const sizes = (contract.cargoScope ?? [])
|
||||
.map((c) => c.containerSize)
|
||||
.filter((s): s is string => !!s);
|
||||
const { items: containerTypes } = await this.containerTypesService.findAll({
|
||||
isActive: true,
|
||||
pageSize: 100,
|
||||
});
|
||||
for (const size of sizes) {
|
||||
const sizeFt = size === '40ft' ? 40 : 20;
|
||||
const matchedIds = new Set(
|
||||
containerTypes.filter((ct) => ct.sizeFt === sizeFt).map((ct) => ct.id),
|
||||
);
|
||||
const rate =
|
||||
onLeg.find((r) => r.containerTypeId && matchedIds.has(r.containerTypeId)) ??
|
||||
onLeg.find((r) => !r.containerTypeId);
|
||||
if (!rate || Number(rate.rateValue) <= 0) continue;
|
||||
lineItems.push({
|
||||
code: 'RETURN_SURCHARGE',
|
||||
label: `Empty container return (${size})`,
|
||||
unit: toContractUnit(rate.rateUnit),
|
||||
unitPrice: convert(Number(rate.rateValue)),
|
||||
containerSize: size,
|
||||
conditionalOn: 'with_return',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Customs clearance service fee (Path B) — a FLAT prepaid fee, shown on the
|
||||
// contract and billed via its own clearance invoice: after counter-sign for
|
||||
// ONE_TIME, per shipment request for GENERAL. Excluded from booking totals.
|
||||
// A customs contract may not proceed without a configured live rate.
|
||||
// Customs clearance service fee (Path B) — billed on the booking invoice
|
||||
// together with the freight. Sold per direction + route + cargo kind:
|
||||
// container contracts freeze one fee line per contract size (each size's
|
||||
// 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) {
|
||||
const clearance = liveRates.find(
|
||||
(r) => r.rateType === 'CUSTOMS_CLEARANCE' && r.currency === 'USD',
|
||||
);
|
||||
if (!clearance || Number(clearance.rateValue) <= 0) {
|
||||
throw new UnprocessableEntityException(
|
||||
'No customs clearance service fee is configured. Ask the rates team to set a live CUSTOMS_CLEARANCE rate before submitting customs contracts.',
|
||||
);
|
||||
// Strict, no route-less fallback.
|
||||
// ponytail: multi-route contracts bill the first lane's fee; per-lane fees need per-route snapshots.
|
||||
const route = [...(contract.routes ?? [])].sort(
|
||||
(a, b) => a.sortOrder - b.sortOrder,
|
||||
)[0];
|
||||
const 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 {
|
||||
|
||||
@@ -18,7 +18,15 @@ import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
||||
import { ContractViewModel } from '../../contracts/contract-view-model.builder';
|
||||
import { MinioService } from '../minio/minio.service';
|
||||
import { FileRecord } from '../files/entities/file.entity';
|
||||
import { assertCanApproveContractStep } from '../../common/freight-permission.util';
|
||||
import {
|
||||
assertCanApproveContractStep,
|
||||
assertFreightPermission,
|
||||
canEditContractStep,
|
||||
} from '../../common/freight-permission.util';
|
||||
import {
|
||||
FREIGHT_PERMS,
|
||||
forFreightType,
|
||||
} from '../../seed/freight-permissions.registry';
|
||||
import { ContractDocumentHistoryService } from './contract-document-history.service';
|
||||
import { ApprovalRulesService } from '../rule-engine/services/approval-rules.service';
|
||||
import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
|
||||
@@ -28,7 +36,6 @@ import { SignaturesService } from '../signatures/signatures.service';
|
||||
import { OtpService } from '../otp/otp.service';
|
||||
import { ContractTemplatesService } from '../contract-templates/contract-templates.service';
|
||||
import { ContractPricingService } from './contract-pricing.service';
|
||||
import { ClearanceFeeService } from './clearance-fee.service';
|
||||
import { ContractNotifierService } from './contract-notifier.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
@@ -154,7 +161,6 @@ export class ContractTransitionService {
|
||||
private readonly otpService: OtpService,
|
||||
private readonly notifier: ContractNotifierService,
|
||||
private readonly contractTemplates: ContractTemplatesService,
|
||||
private readonly clearanceFeeService: ClearanceFeeService,
|
||||
@InjectDataSource()
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
@@ -236,8 +242,15 @@ export class ContractTransitionService {
|
||||
actorId: string,
|
||||
validityDays: number,
|
||||
documentSnapshot?: ContractDocumentSnapshotInput | null,
|
||||
user?: TCurrentUser | null,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
// The route guard passes on either arm; the contract's freight type decides
|
||||
// which one is actually required (accept bulk ≠ accept container).
|
||||
assertFreightPermission(
|
||||
user,
|
||||
forFreightType(FREIGHT_PERMS.contracts.staffAccept, contract.freightType),
|
||||
);
|
||||
assertContractStatus(contract, ['SUBMITTED']);
|
||||
|
||||
if (!Number.isInteger(validityDays) || validityDays < 1) {
|
||||
@@ -431,12 +444,11 @@ export class ContractTransitionService {
|
||||
if (!next) return false;
|
||||
if (!user) return false;
|
||||
|
||||
try {
|
||||
assertCanApproveContractStep(user, next.requiredRole);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
// Strict match: ONLY the approver whose turn it is (the next pending step's
|
||||
// role) may edit. Using the looser approve gate here let any approver who
|
||||
// held a contract-approve permission keep the edit button after acting —
|
||||
// approval must hand edit rights to the next approver, not share them.
|
||||
return canEditContractStep(user, next.requiredRole);
|
||||
}
|
||||
|
||||
/** The role that currently holds editing rights, for UI messaging. */
|
||||
@@ -533,8 +545,13 @@ export class ContractTransitionService {
|
||||
contractId: string,
|
||||
note: string,
|
||||
actorId: string,
|
||||
user?: TCurrentUser | null,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
assertFreightPermission(
|
||||
user,
|
||||
forFreightType(FREIGHT_PERMS.contracts.requestChanges, contract.freightType),
|
||||
);
|
||||
assertContractStatus(contract, ['SUBMITTED']);
|
||||
|
||||
await this.contractsRepository.createReviewNote(
|
||||
@@ -552,8 +569,17 @@ export class ContractTransitionService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
async reject(contractId: string, reason: string, actorId: string): Promise<Contract> {
|
||||
async reject(
|
||||
contractId: string,
|
||||
reason: string,
|
||||
actorId: string,
|
||||
user?: TCurrentUser | null,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
assertFreightPermission(
|
||||
user,
|
||||
forFreightType(FREIGHT_PERMS.contracts.reject, contract.freightType),
|
||||
);
|
||||
assertContractStatus(contract, ['SUBMITTED', 'PENDING_APPROVAL']);
|
||||
|
||||
await this.contractsRepository.createReviewNote(
|
||||
@@ -563,10 +589,6 @@ export class ContractTransitionService {
|
||||
actorId,
|
||||
'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, {
|
||||
status: 'REJECTED',
|
||||
} as never);
|
||||
@@ -626,10 +648,6 @@ export class ContractTransitionService {
|
||||
'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, {
|
||||
status: 'REJECTED',
|
||||
} as never);
|
||||
@@ -641,9 +659,9 @@ export class ContractTransitionService {
|
||||
/**
|
||||
* Internal send-back branch of rejectStep: return the contract to an earlier,
|
||||
* already-approved stage of the chain instead of rejecting it outright.
|
||||
* Deliberately NOT the terminal path: no clearance-fee expiry (the contract
|
||||
* is still alive) and no customer-facing REJECTION note — the trail is a
|
||||
* staff note plus a backoffice inbox ping.
|
||||
* Deliberately NOT the terminal path: the contract is still alive and there
|
||||
* is no customer-facing REJECTION note — the trail is a staff note plus a
|
||||
* backoffice inbox ping.
|
||||
*/
|
||||
private async sendBackToStep(
|
||||
contract: Contract,
|
||||
@@ -1141,17 +1159,11 @@ export class ContractTransitionService {
|
||||
const cycleNumber = (contract.clearanceCycleNumber ?? 0) + 1;
|
||||
const cycle = await this.contractsRepository.openCycle(contractId, cycleNumber);
|
||||
await this.milestoneService.seedPreBookingMilestones(contract, cycle.id);
|
||||
// Path B prepay gate: the customs clearance service fee is invoiced here
|
||||
// and must settle before the document step opens (the paid event advances
|
||||
// to AWAITING_CLEARANCE_DOCUMENTS). Path A (self-clearance) has no GL fee.
|
||||
if (await this.clearanceFeeService.gateApplies(contract)) {
|
||||
await this.clearanceFeeService.issueForContract(contract);
|
||||
updates.status = 'AWAITING_CLEARANCE_PAYMENT';
|
||||
updates.clearanceStatus = 'AWAITING_PAYMENT';
|
||||
} else {
|
||||
updates.status = 'AWAITING_CLEARANCE_DOCUMENTS';
|
||||
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
|
||||
}
|
||||
// No prepay gate: the customs clearance service fee (Path B) is billed on
|
||||
// the booking invoice together with the freight, so the document step
|
||||
// opens immediately.
|
||||
updates.status = 'AWAITING_CLEARANCE_DOCUMENTS';
|
||||
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
|
||||
updates.clearanceCycleNumber = cycleNumber;
|
||||
} else {
|
||||
// No contract-level clearance gate — DOMESTIC, or any GENERAL contract
|
||||
|
||||
@@ -34,7 +34,11 @@ import {
|
||||
import { actorLabel } from '../warehouses/current-actor.util';
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { ContractDocumentHistoryService } from './contract-document-history.service';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import {
|
||||
FREIGHT_PERMS,
|
||||
bothFreightTypes,
|
||||
forFreightType,
|
||||
} from '../../seed/freight-permissions.registry';
|
||||
import {
|
||||
assertFreightPermission,
|
||||
hasFreightPermission,
|
||||
@@ -184,7 +188,10 @@ export class ContractsController {
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
if (dto.isGovernment) {
|
||||
assertFreightPermission(user, FREIGHT_PERMS.contracts.staffAccept);
|
||||
assertFreightPermission(
|
||||
user,
|
||||
forFreightType(FREIGHT_PERMS.contracts.staffAccept, dto.freightType),
|
||||
);
|
||||
}
|
||||
return this.contractsService.create(dto, files ?? [], user?.id);
|
||||
}
|
||||
@@ -196,7 +203,10 @@ export class ContractsController {
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
// Staff see every contract; customers are force-scoped to their own company.
|
||||
if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
if (
|
||||
hasFreightPermission(user, FREIGHT_PERMS.bookings.view) ||
|
||||
hasFreightPermission(user, FREIGHT_PERMS.contracts.view)
|
||||
) {
|
||||
return this.contractsService.findAll(filter);
|
||||
}
|
||||
const userId = user?.id;
|
||||
@@ -273,7 +283,8 @@ export class ContractsController {
|
||||
if (
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView) &&
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.reviewDocuments)
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.reviewDocuments) &&
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.contracts.view)
|
||||
) {
|
||||
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
||||
}
|
||||
@@ -333,23 +344,32 @@ export class ContractsController {
|
||||
}
|
||||
|
||||
@Post(':id/staff/accept')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.staffAccept)
|
||||
// One-of guard; the service then requires the arm matching the contract's freight type.
|
||||
@BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept))
|
||||
@ApiOperation({ summary: 'Staff accept → set validity window + start approval chain' })
|
||||
staffAccept(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: AcceptContractDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.transitionService.staffAccept(
|
||||
id,
|
||||
resolveAuthUserId(user),
|
||||
dto.validityDays,
|
||||
dto.documentSnapshot,
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
// 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')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.staffAccept)
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.contracts.view,
|
||||
...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept),
|
||||
])
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Editable contract-document draft (this contract\'s snapshot, or the live template) for the accept/edit dialog',
|
||||
@@ -372,8 +392,15 @@ export class ContractsController {
|
||||
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')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.staffAccept)
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.contracts.view,
|
||||
...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept),
|
||||
])
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Edit this contract\'s document articles only (per-contract; never touches the six shared templates)',
|
||||
@@ -392,29 +419,35 @@ export class ContractsController {
|
||||
}
|
||||
|
||||
@Post(':id/staff/request-changes')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.requestChanges)
|
||||
@BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.requestChanges))
|
||||
@ApiOperation({ summary: 'Staff return contract for customer updates' })
|
||||
requestChanges(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: RequestChangesDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.transitionService.requestChanges(
|
||||
id,
|
||||
dto.note,
|
||||
resolveAuthUserId(user),
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/staff/reject')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.reject)
|
||||
@BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.reject))
|
||||
@ApiOperation({ summary: 'Staff reject contract' })
|
||||
reject(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: RejectContractDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.transitionService.reject(id, dto.reason, resolveAuthUserId(user));
|
||||
return this.transitionService.reject(
|
||||
id,
|
||||
dto.reason,
|
||||
resolveAuthUserId(user),
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/approval-steps/:stepId/approve')
|
||||
@@ -475,7 +508,10 @@ export class ContractsController {
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const contract = await this.contractsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
if (
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.contracts.view)
|
||||
) {
|
||||
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
||||
}
|
||||
const { view, html, signatures } =
|
||||
@@ -510,7 +546,10 @@ export class ContractsController {
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const contract = await this.contractsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
if (
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.contracts.view)
|
||||
) {
|
||||
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
||||
}
|
||||
const { stream, record } = await this.transitionService.streamContractPdf(id);
|
||||
@@ -566,9 +605,12 @@ export class ContractsController {
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
// H12(c): a customer may only renew a contract their company owns. Staff
|
||||
// with bookings.view bypass, mirroring getContractView/downloadContractDocument.
|
||||
// with bookings.view/contracts.view bypass, mirroring getContractView/downloadContractDocument.
|
||||
const contract = await this.contractsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
if (
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.contracts.view)
|
||||
) {
|
||||
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
||||
}
|
||||
return this.transitionService.renew(id, resolveAuthUserId(user));
|
||||
@@ -592,9 +634,12 @@ export class ContractsController {
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
) {
|
||||
// H12(c): only the owning company's customer may upload clearance docs.
|
||||
// Staff with bookings.view bypass, mirroring the other contract handlers.
|
||||
// Staff with bookings.view/contracts.view bypass, mirroring the other contract handlers.
|
||||
const contract = await this.contractsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
if (
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.contracts.view)
|
||||
) {
|
||||
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
||||
}
|
||||
return this.clearanceService.uploadDocuments(id, files ?? []);
|
||||
|
||||
@@ -22,7 +22,6 @@ import { ContractsController } from './contracts.controller';
|
||||
import { ContractsService } from './contracts.service';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { ContractPricingService } from './contract-pricing.service';
|
||||
import { ClearanceFeeService } from './clearance-fee.service';
|
||||
import { ContractNotifierService } from './contract-notifier.service';
|
||||
import { ContractTransitionService } from './contract-transition.service';
|
||||
import { ContractClearanceService } from './contract-clearance.service';
|
||||
@@ -107,7 +106,6 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
||||
ContractsService,
|
||||
ContractsRepository,
|
||||
ContractPricingService,
|
||||
ClearanceFeeService,
|
||||
ContractNotifierService,
|
||||
ContractTransitionService,
|
||||
ContractDocumentHistoryService,
|
||||
|
||||
@@ -12,8 +12,6 @@ import { YardCountry } from '@edr/types';
|
||||
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { CompaniesService } from '../companies/companies.service';
|
||||
import { ProfileType } from '../companies/entities/company-profile.entity';
|
||||
import { CompanyStatus } from '../companies/entities/company.entity';
|
||||
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { FilesService } from '../files/files.service';
|
||||
@@ -181,11 +179,7 @@ export class ContractsService {
|
||||
);
|
||||
}
|
||||
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
|
||||
if (company.status !== CompanyStatus.Active) {
|
||||
throw new ForbiddenException(
|
||||
"Your company is awaiting approval — you can't create contracts yet.",
|
||||
);
|
||||
}
|
||||
this.companiesService.assertCompanyActiveFor(company, 'contracts');
|
||||
companyId = company.id;
|
||||
}
|
||||
|
||||
@@ -193,31 +187,31 @@ export class ContractsService {
|
||||
this.assertRouteShape(dto.contractKind, dto.routes);
|
||||
await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes);
|
||||
|
||||
// Stamp the operational profile (importer/exporter) for portal scoping.
|
||||
// Stamp the operational profile for portal scoping. A forwarder contract
|
||||
// pins its profile explicitly (trade direction can't tell it apart from a
|
||||
// direct import/export); everything else resolves from the trade direction.
|
||||
let companyProfileId: string | null = null;
|
||||
if (!isGovernment && companyId) {
|
||||
let fallbackType: ProfileType | null = null;
|
||||
if (userId) {
|
||||
try {
|
||||
const { profile } =
|
||||
await this.companiesService.getCompanyInfoByUserId(userId);
|
||||
fallbackType = profile.activeProfileType ?? null;
|
||||
} catch {
|
||||
// No profile (e.g. staff creating on behalf) — fall back to mapping.
|
||||
}
|
||||
}
|
||||
companyProfileId =
|
||||
await this.companiesService.resolveCompanyProfileIdForBooking(
|
||||
companyId,
|
||||
dto.tradeDirection,
|
||||
fallbackType,
|
||||
);
|
||||
if (dto.companyProfileId) {
|
||||
const profile =
|
||||
await this.companiesService.getActiveCompanyProfileForBooking(
|
||||
companyId,
|
||||
dto.companyProfileId,
|
||||
);
|
||||
companyProfileId = profile.id;
|
||||
} else {
|
||||
companyProfileId =
|
||||
await this.companiesService.resolveCompanyProfileIdForBooking(
|
||||
companyId,
|
||||
dto.tradeDirection,
|
||||
);
|
||||
|
||||
const customerSelfBooking = !dto.companyId && !!userId;
|
||||
if (customerSelfBooking && companyProfileId) {
|
||||
await this.companiesService.assertCompanyProfileApprovedForBooking(
|
||||
companyProfileId,
|
||||
);
|
||||
const customerSelfBooking = !dto.companyId && !!userId;
|
||||
if (customerSelfBooking && companyProfileId) {
|
||||
await this.companiesService.assertCompanyProfileApprovedForBooking(
|
||||
companyProfileId,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -182,6 +182,13 @@ export class CreateBookingUnderContractDto {
|
||||
@Type(() => CreateBulkLineDto)
|
||||
bulkLines?: CreateBulkLineDto[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'What the containers carry — captured per booking (container freight).',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cargoFreeText?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -124,6 +124,16 @@ export class CreateContractDto {
|
||||
@IsUUID()
|
||||
companyId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description:
|
||||
'Explicit company profile to stamp the contract to (a forwarder contract); ' +
|
||||
'commercial contracts otherwise auto-resolve from trade direction.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
companyProfileId?: string;
|
||||
|
||||
@ApiProperty({ enum: CONTRACT_KINDS, description: 'ONE_TIME | GENERAL' })
|
||||
@IsIn([...CONTRACT_KINDS])
|
||||
contractKind!: string;
|
||||
|
||||
@@ -13,6 +13,7 @@ export const INCIDENT_TYPES = [
|
||||
'CONTAINER_OPENED',
|
||||
'CONTAINER_DAMAGED',
|
||||
'FLUID_LEAKING',
|
||||
'OTHER',
|
||||
] as const;
|
||||
export type IncidentType = (typeof INCIDENT_TYPES)[number];
|
||||
|
||||
|
||||
@@ -46,8 +46,8 @@ export class ContractRateSnapshot extends BaseEntity {
|
||||
conditionalOn?: string | null;
|
||||
|
||||
/**
|
||||
* Customs clearance service fee line — billed up front via a clearance
|
||||
* invoice, excluded from shipment booking totals.
|
||||
* Customs clearance service fee line — billed on the booking invoice
|
||||
* together with the freight (no separate prepaid clearance invoice).
|
||||
*/
|
||||
@Column({ name: 'is_clearance', type: 'boolean', default: false })
|
||||
isClearance!: boolean;
|
||||
|
||||
@@ -25,7 +25,6 @@ export const CONTRACT_STATUSES = [
|
||||
'SIGNED_CUSTOMER',
|
||||
'FULLY_EXECUTED',
|
||||
'CONTRACT_ACTIVE',
|
||||
'AWAITING_CLEARANCE_PAYMENT', // Path B — clearance fee invoiced, unpaid
|
||||
'AWAITING_CLEARANCE_DOCUMENTS',
|
||||
'CLEARANCE_UNDER_REVIEW',
|
||||
'CLEARANCE_READY_FOR_BOOKING',
|
||||
@@ -85,7 +84,6 @@ export type ContractKindValue = (typeof CONTRACT_KINDS)[number];
|
||||
|
||||
export const CONTRACT_CLEARANCE_STATUSES = [
|
||||
'NOT_APPLICABLE',
|
||||
'AWAITING_PAYMENT', // Path B — clearance service fee must be paid first
|
||||
'AWAITING_DOCUMENTS',
|
||||
'DOCUMENTS_UNDER_REVIEW',
|
||||
'CLEARANCE_READY_FOR_BOOKING', // Path B — GL may create the booking
|
||||
@@ -217,10 +215,6 @@ export class Contract extends BaseEntity {
|
||||
@Column({ name: 'clearance_cycle_number', type: 'int', default: 0 })
|
||||
clearanceCycleNumber!: number;
|
||||
|
||||
/** When the prepaid customs clearance service fee settled (Path B ONE_TIME). */
|
||||
@Column({ name: 'clearance_fee_paid_at', type: 'timestamptz', nullable: true })
|
||||
clearanceFeePaidAt?: Date | null;
|
||||
|
||||
@Column({ name: 'pricing_breakdown', type: 'jsonb', nullable: true })
|
||||
pricingBreakdown?: Record<string, unknown> | null;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nes
|
||||
import { FindOptionsWhere, In, IsNull, Not } from 'typeorm';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { attachMileFinancials } from '../../common/mile-financials.util';
|
||||
import { VehicleAvailability } from '../vehicles/entities/vehicle.entity';
|
||||
import { BookingsRepository } from "../bookings/bookings.repository";
|
||||
import { DriversService } from "../drivers/drivers.service";
|
||||
@@ -66,6 +67,7 @@ export class FirstMileService {
|
||||
for (const r of records) {
|
||||
(r as FirstMile & { invoice?: unknown }).invoice = byId.get(r.id) ?? null;
|
||||
}
|
||||
await attachMileFinancials(this.dataSource, records, 'FIRST_MILE');
|
||||
}
|
||||
|
||||
/** Resolve a vehicle's driver + human labels, for stamping mile events onto
|
||||
|
||||
@@ -54,11 +54,4 @@ export class InterchangeDocumentsController {
|
||||
dispute(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DisputeInterchangeDocumentDto) {
|
||||
return this.service.dispute(id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/cancel')
|
||||
@BookingStaff(FREIGHT_PERMS.interchangeDocuments.cancel)
|
||||
@ApiOperation({ summary: 'Cancel a draft/generated interchange document' })
|
||||
cancel(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.cancel(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,11 +203,12 @@ export class InterchangeDocumentsService {
|
||||
|
||||
async dispute(id: string, dto: DisputeInterchangeDocumentDto): Promise<InterchangeDocument> {
|
||||
const document = await this.findOne(id);
|
||||
// A dispute can only be raised on a live handover — a GENERATED or already
|
||||
// ACKNOWLEDGED document. CANCELLED and already-DISPUTED are terminal here.
|
||||
if (!['GENERATED', 'ACKNOWLEDGED'].includes(document.status)) {
|
||||
// A dispute can only be raised BEFORE the handover is acknowledged — an
|
||||
// acknowledged document is settled. DISPUTED itself is terminal and
|
||||
// read-only: the registered dispute cannot be re-raised or overwritten.
|
||||
if (document.status !== 'GENERATED') {
|
||||
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, {
|
||||
@@ -217,15 +218,6 @@ export class InterchangeDocumentsService {
|
||||
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> {
|
||||
const [schedule] = await this.dataSource.query(
|
||||
`SELECT ts.id,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
SELF_HAUL_CONFLICT_MESSAGE,
|
||||
usesEdrMileService,
|
||||
} from '../../common/mile-haulage.util';
|
||||
import { attachMileFinancials } from '../../common/mile-financials.util';
|
||||
import {
|
||||
assertBulkTonnageRemains,
|
||||
assertTruckCountWithinContainers,
|
||||
@@ -88,6 +89,7 @@ export class LastMileService {
|
||||
for (const r of records) {
|
||||
(r as LastMile & { invoice?: unknown }).invoice = byId.get(r.id) ?? null;
|
||||
}
|
||||
await attachMileFinancials(this.dataSource, records, 'LAST_MILE');
|
||||
}
|
||||
|
||||
/** Resolve a vehicle's driver + human labels, for stamping mile events onto
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||
import { FleetManage, StaffReference } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { CreateLocomotiveDto } from './dto/create-locomotive.dto';
|
||||
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
|
||||
import { UpdateLocomotiveDto } from './dto/update-locomotive.dto';
|
||||
@@ -9,39 +10,43 @@ import { LocomotivesService } from './locomotives.service';
|
||||
|
||||
@ApiTags('locomotives')
|
||||
@ApiBearerAuth()
|
||||
// No class-level guard: reads are login-only reference data (any staff can
|
||||
// fetch a locomotive for a cross-flow view without the fleet:view that drives
|
||||
// the Fleet sidebar). Every mutation carries its own @FleetManage().
|
||||
@Controller('locomotives')
|
||||
@FleetView()
|
||||
export class LocomotivesController {
|
||||
constructor(private readonly locomotivesService: LocomotivesService) {}
|
||||
|
||||
@Get()
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'List locomotives' })
|
||||
findAll(@Query() filter: FilterLocomotivesDto) {
|
||||
return this.locomotivesService.findAll(filter);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'Get a locomotive by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.locomotivesService.findById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.locomotives.create)
|
||||
@ApiOperation({ summary: 'Create a locomotive' })
|
||||
create(@Body() dto: CreateLocomotiveDto) {
|
||||
return this.locomotivesService.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.locomotives.update)
|
||||
@ApiOperation({ summary: 'Update a locomotive' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLocomotiveDto) {
|
||||
return this.locomotivesService.update(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/decommission')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.locomotives.delete)
|
||||
@ApiOperation({ summary: 'Decommission a locomotive' })
|
||||
decommission(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.locomotivesService.decommission(id);
|
||||
|
||||
@@ -8,6 +8,11 @@ export class CreateMaintenanceScheduleDto {
|
||||
@IsEnum(MaintenanceType)
|
||||
maintenanceType!: MaintenanceType;
|
||||
|
||||
/** What is serviced — matched against the interval for auto-scheduling. */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
serviceItem?: string;
|
||||
|
||||
@IsString()
|
||||
description!: string;
|
||||
|
||||
@@ -81,7 +86,37 @@ export class UpdateMaintenanceScheduleDto {
|
||||
@IsNumber()
|
||||
actualCost?: number;
|
||||
|
||||
/** Odometer at completion — drives KM-based auto-scheduling of the next service. */
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
odometerReading?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class UpsertMaintenanceIntervalDto {
|
||||
@IsUUID()
|
||||
vehicleId!: string;
|
||||
|
||||
@IsEnum(MaintenanceType)
|
||||
maintenanceType!: MaintenanceType;
|
||||
|
||||
/** What is serviced — "oil change", "tires", … Distinguishes intervals of the same type. */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
serviceItem?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
intervalKm?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
intervalDays?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
maintenanceType!: MaintenanceType;
|
||||
|
||||
/** What is serviced — matches the interval's service_item for auto-scheduling. */
|
||||
@Column({ name: 'service_item', type: 'varchar', length: 120, nullable: true })
|
||||
serviceItem?: string | null;
|
||||
|
||||
@Column({ name: 'description' })
|
||||
description!: string;
|
||||
|
||||
@@ -62,4 +66,8 @@ export class MaintenanceSchedule extends BaseEntity {
|
||||
|
||||
@Column({ name: 'next_due_date', type: 'timestamptz', nullable: true })
|
||||
nextDueDate?: Date;
|
||||
|
||||
/** Stamped once the km/date-due alert has fired, so the daily check doesn't repeat it. */
|
||||
@Column({ name: 'due_notified_at', type: 'timestamptz', nullable: true })
|
||||
dueNotifiedAt?: Date;
|
||||
}
|
||||
|
||||
@@ -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 { MaintenanceService } from './maintenance.service';
|
||||
import { MaintenanceDepthService } from './maintenance-depth.service';
|
||||
import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto';
|
||||
import {
|
||||
CreateMaintenanceScheduleDto,
|
||||
CreateMaintenanceCostDto,
|
||||
UpdateMaintenanceScheduleDto,
|
||||
UpsertMaintenanceIntervalDto,
|
||||
} from './dto/create-maintenance.dto';
|
||||
import {
|
||||
CreateWorkOrderDto,
|
||||
UpdateWorkOrderDto,
|
||||
@@ -44,6 +49,34 @@ export class MaintenanceController {
|
||||
return this.maintenanceService.updateMaintenanceSchedule(id, dto);
|
||||
}
|
||||
|
||||
@Get('due-board')
|
||||
@BookingStaff([FREIGHT_PERMS.maintenance.view, FREIGHT_PERMS.fleetDashboard.view])
|
||||
@ApiOperation({ summary: 'Fleet-wide next-due maintenance board (by date and km)' })
|
||||
async getDueBoard() {
|
||||
return this.maintenanceService.getDueBoard();
|
||||
}
|
||||
|
||||
@Post('intervals')
|
||||
@BookingStaff(FREIGHT_PERMS.maintenance.create)
|
||||
@ApiOperation({ summary: 'Define/adjust a service interval (e.g. oil change every 10,000 km)' })
|
||||
async upsertInterval(@Body() dto: UpsertMaintenanceIntervalDto) {
|
||||
return this.maintenanceService.upsertInterval(dto);
|
||||
}
|
||||
|
||||
@Get('intervals/:vehicleId')
|
||||
@BookingStaff(FREIGHT_PERMS.maintenance.view)
|
||||
@ApiOperation({ summary: "A vehicle's active service intervals" })
|
||||
async getIntervals(@Param('vehicleId') vehicleId: string) {
|
||||
return this.maintenanceService.getIntervals(vehicleId);
|
||||
}
|
||||
|
||||
@Delete('intervals/:id')
|
||||
@BookingStaff(FREIGHT_PERMS.maintenance.delete)
|
||||
@ApiOperation({ summary: 'Deactivate a service interval (stops auto-scheduling)' })
|
||||
async deactivateInterval(@Param('id') id: string) {
|
||||
return this.maintenanceService.deactivateInterval(id);
|
||||
}
|
||||
|
||||
@Get('upcoming/:vehicleId')
|
||||
@BookingStaff(FREIGHT_PERMS.maintenance.view)
|
||||
@ApiOperation({ summary: 'Get upcoming maintenance' })
|
||||
|
||||
@@ -2,25 +2,37 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { MaintenanceSchedule } from './entities/maintenance-schedule.entity';
|
||||
import { MaintenanceCost } from './entities/maintenance-cost.entity';
|
||||
import { MaintenanceInterval } from './entities/maintenance-interval.entity';
|
||||
import { WorkOrder } from './entities/work-order.entity';
|
||||
import { Part } from './entities/part.entity';
|
||||
import { Warranty } from './entities/warranty.entity';
|
||||
import { MaintenanceService } from './maintenance.service';
|
||||
import { MaintenanceDepthService } from './maintenance-depth.service';
|
||||
import { MaintenanceRepository } from './maintenance.repository';
|
||||
import { MaintenanceIntervalRepository } from './maintenance-interval.repository';
|
||||
import { WorkOrderRepository } from './work-order.repository';
|
||||
import { PartRepository } from './part.repository';
|
||||
import { WarrantyRepository } from './warranty.repository';
|
||||
import { MaintenanceController } from './maintenance.controller';
|
||||
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([MaintenanceSchedule, MaintenanceCost, WorkOrder, Part, Warranty]),
|
||||
TypeOrmModule.forFeature([
|
||||
MaintenanceSchedule,
|
||||
MaintenanceCost,
|
||||
MaintenanceInterval,
|
||||
WorkOrder,
|
||||
Part,
|
||||
Warranty,
|
||||
]),
|
||||
NotificationInboxModule,
|
||||
],
|
||||
providers: [
|
||||
MaintenanceService,
|
||||
MaintenanceDepthService,
|
||||
MaintenanceRepository,
|
||||
MaintenanceIntervalRepository,
|
||||
WorkOrderRepository,
|
||||
PartRepository,
|
||||
WarrantyRepository,
|
||||
|
||||
@@ -47,4 +47,109 @@ export class MaintenanceRepository extends BaseRepository<MaintenanceSchedule> {
|
||||
.getRawOne();
|
||||
return result?.total || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fleet-wide "next due" board: one row per vehicle with a SCHEDULED
|
||||
* maintenance item, driven by time AND km — whichever is soonest. Current km
|
||||
* is the vehicle's latest fuel-up odometer reading (how mileage is actually
|
||||
* captured today), falling back to vehicle.actual_distance_km when the
|
||||
* vehicle has no fuel purchase on file yet.
|
||||
*/
|
||||
async getDueBoard(): Promise<
|
||||
Array<{
|
||||
scheduleId: string;
|
||||
vehicleId: string;
|
||||
plateNumber: string;
|
||||
maintenanceType: string;
|
||||
serviceItem: string | null;
|
||||
description: string;
|
||||
scheduledDate: Date;
|
||||
nextDueDate: Date | null;
|
||||
nextDueKm: number | null;
|
||||
currentKm: number | null;
|
||||
kmRemaining: number | null;
|
||||
daysRemaining: number | null;
|
||||
overdue: boolean;
|
||||
}>
|
||||
> {
|
||||
// Every SCHEDULED item, not one per vehicle — a truck legitimately holds
|
||||
// several (oil vs tires intervals differ).
|
||||
return this.scheduleRepository.manager.query(`
|
||||
SELECT
|
||||
s.id AS "scheduleId",
|
||||
s.vehicle_id AS "vehicleId",
|
||||
v.plate_number AS "plateNumber",
|
||||
s.maintenance_type AS "maintenanceType",
|
||||
s.service_item AS "serviceItem",
|
||||
s.description,
|
||||
s.scheduled_date AS "scheduledDate",
|
||||
s.next_due_date AS "nextDueDate",
|
||||
s.next_due_km AS "nextDueKm",
|
||||
COALESCE(fp.max_odometer, v.actual_distance_km) AS "currentKm",
|
||||
CASE WHEN s.next_due_km IS NOT NULL
|
||||
THEN s.next_due_km - COALESCE(fp.max_odometer, v.actual_distance_km, 0)
|
||||
ELSE NULL END AS "kmRemaining",
|
||||
CASE WHEN s.next_due_date IS NOT NULL
|
||||
THEN EXTRACT(DAY FROM s.next_due_date - now())
|
||||
ELSE NULL END AS "daysRemaining",
|
||||
(
|
||||
(s.next_due_date IS NOT NULL AND s.next_due_date <= now())
|
||||
OR (s.next_due_km IS NOT NULL
|
||||
AND COALESCE(fp.max_odometer, v.actual_distance_km, 0) >= s.next_due_km)
|
||||
) AS overdue
|
||||
FROM freight.maintenance_schedules s
|
||||
JOIN freight.vehicles v ON v.id = s.vehicle_id AND v.deleted_at IS NULL
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT MAX(odometer_reading) AS max_odometer
|
||||
FROM freight.fuel_purchases fp2
|
||||
WHERE fp2.vehicle_id = s.vehicle_id
|
||||
) fp ON true
|
||||
WHERE s.status = 'SCHEDULED' AND s.deleted_at IS NULL
|
||||
ORDER BY s.vehicle_id, s.scheduled_date ASC
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* SCHEDULED items that have crossed their km or date due-point and have not
|
||||
* yet been notified. Backs the daily km/date maintenance alert.
|
||||
*/
|
||||
async getUnnotifiedDue(): Promise<
|
||||
Array<{
|
||||
id: string;
|
||||
vehicleId: string;
|
||||
plateNumber: string;
|
||||
maintenanceType: string;
|
||||
description: string;
|
||||
nextDueKm: number | null;
|
||||
nextDueDate: Date | null;
|
||||
currentKm: number | null;
|
||||
}>
|
||||
> {
|
||||
return this.scheduleRepository.manager.query(`
|
||||
SELECT
|
||||
s.id,
|
||||
s.vehicle_id AS "vehicleId",
|
||||
v.plate_number AS "plateNumber",
|
||||
s.maintenance_type AS "maintenanceType",
|
||||
s.description,
|
||||
s.next_due_km AS "nextDueKm",
|
||||
s.next_due_date AS "nextDueDate",
|
||||
COALESCE(fp.max_odometer, v.actual_distance_km) AS "currentKm"
|
||||
FROM freight.maintenance_schedules s
|
||||
JOIN freight.vehicles v ON v.id = s.vehicle_id AND v.deleted_at IS NULL
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT MAX(odometer_reading) AS max_odometer
|
||||
FROM freight.fuel_purchases fp2
|
||||
WHERE fp2.vehicle_id = s.vehicle_id
|
||||
) fp ON true
|
||||
WHERE s.status = 'SCHEDULED'
|
||||
AND s.deleted_at IS NULL
|
||||
AND s.due_notified_at IS NULL
|
||||
AND (
|
||||
(s.next_due_date IS NOT NULL AND s.next_due_date <= now())
|
||||
OR (s.next_due_km IS NOT NULL
|
||||
AND COALESCE(fp.max_odometer, v.actual_distance_km, 0) >= s.next_due_km)
|
||||
)
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { NotificationAudience, NotificationType } from '@edr/types';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { MaintenanceRepository } from './maintenance.repository';
|
||||
import { MaintenanceSchedule, MaintenanceStatus } from './entities/maintenance-schedule.entity';
|
||||
import { MaintenanceIntervalRepository } from './maintenance-interval.repository';
|
||||
import { MaintenanceSchedule, MaintenanceStatus, MaintenanceType } from './entities/maintenance-schedule.entity';
|
||||
import { MaintenanceCost } from './entities/maintenance-cost.entity';
|
||||
import { Vehicle, VehicleAvailability, VehicleStatus } from '../vehicles/entities/vehicle.entity';
|
||||
import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto';
|
||||
import {
|
||||
CreateMaintenanceScheduleDto,
|
||||
CreateMaintenanceCostDto,
|
||||
UpdateMaintenanceScheduleDto,
|
||||
UpsertMaintenanceIntervalDto,
|
||||
} from './dto/create-maintenance.dto';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
|
||||
@Injectable()
|
||||
export class MaintenanceService {
|
||||
private readonly logger = new Logger(MaintenanceService.name);
|
||||
|
||||
constructor(
|
||||
private readonly maintenanceRepository: MaintenanceRepository,
|
||||
private readonly intervalRepository: MaintenanceIntervalRepository,
|
||||
@InjectRepository(MaintenanceSchedule)
|
||||
private readonly scheduleRepository: Repository<MaintenanceSchedule>,
|
||||
@InjectRepository(MaintenanceCost)
|
||||
@@ -18,8 +30,44 @@ export class MaintenanceService {
|
||||
// Vehicle isn't registered in this module's TypeOrmModule.forFeature, so we
|
||||
// reach it through the global DataSource rather than @InjectRepository.
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
) {}
|
||||
|
||||
/** Fleet-wide next-due board — see MaintenanceRepository.getDueBoard. */
|
||||
async getDueBoard() {
|
||||
return this.maintenanceRepository.getDueBoard();
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily check: a vehicle's driven km (latest fuel-up odometer reading, since
|
||||
* that's the only place mileage is actually recorded) or its due date has
|
||||
* reached a SCHEDULED item's threshold → alert backoffice once.
|
||||
*/
|
||||
@Cron(CronExpression.EVERY_DAY_AT_7AM, { name: 'maintenance-due-alert' })
|
||||
async sendDueAlerts(): Promise<void> {
|
||||
try {
|
||||
const due = await this.maintenanceRepository.getUnnotifiedDue();
|
||||
for (const item of due) {
|
||||
const reason =
|
||||
item.nextDueKm != null && (item.currentKm ?? 0) >= item.nextDueKm
|
||||
? `driven ${item.currentKm} km (due at ${item.nextDueKm} km)`
|
||||
: `due ${new Date(item.nextDueDate as Date).toLocaleDateString()}`;
|
||||
await this.inbox.notify({
|
||||
recipients: { allBackoffice: true },
|
||||
audience: NotificationAudience.BACKOFFICE,
|
||||
type: NotificationType.GENERIC,
|
||||
title: `Maintenance due — ${item.plateNumber}`,
|
||||
body: `${item.plateNumber} (${item.maintenanceType}) is due for maintenance — ${reason}. ${item.description}`,
|
||||
link: `/dashboard/maintenance?vehicleId=${item.vehicleId}`,
|
||||
data: { vehicleId: item.vehicleId, scheduleId: item.id, action: 'MAINTENANCE_DUE' },
|
||||
});
|
||||
await this.scheduleRepository.update(item.id, { dueNotifiedAt: new Date() });
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(`sendDueAlerts failed: ${(err as Error).message}`, (err as Error).stack);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reflect a maintenance schedule's lifecycle on the target vehicle. A vehicle
|
||||
* under maintenance is taken out of service (MAINTENANCE + BUSY); once the
|
||||
@@ -64,6 +112,10 @@ export class MaintenanceService {
|
||||
id: string,
|
||||
dto: UpdateMaintenanceScheduleDto,
|
||||
): Promise<MaintenanceSchedule> {
|
||||
// Status BEFORE the write: completing an already-COMPLETED schedule again
|
||||
// must not auto-create a second "next" schedule.
|
||||
const before = await this.scheduleRepository.findOneBy({ id });
|
||||
|
||||
await this.scheduleRepository.update(id, {
|
||||
...dto,
|
||||
completedDate: dto.completedDate ? new Date(dto.completedDate) : undefined,
|
||||
@@ -78,6 +130,15 @@ export class MaintenanceService {
|
||||
) {
|
||||
// Maintenance finished/aborted → vehicle back in service.
|
||||
await this.setVehicleMaintenanceState(updated.vehicleId, false);
|
||||
|
||||
// First transition into COMPLETED with an odometer → auto-schedule next.
|
||||
if (
|
||||
dto.status === MaintenanceStatus.COMPLETED &&
|
||||
before?.status !== MaintenanceStatus.COMPLETED &&
|
||||
updated.odometerReading != null
|
||||
) {
|
||||
await this.scheduleNextMaintenance(updated);
|
||||
}
|
||||
} else if (dto.status === MaintenanceStatus.IN_PROGRESS) {
|
||||
// Maintenance started → keep the vehicle out of service.
|
||||
await this.setVehicleMaintenanceState(updated.vehicleId, true);
|
||||
@@ -87,6 +148,83 @@ export class MaintenanceService {
|
||||
return updated!;
|
||||
}
|
||||
|
||||
/** Define/adjust how often a vehicle needs a service ("oil change every 10,000 km"). */
|
||||
async upsertInterval(dto: UpsertMaintenanceIntervalDto) {
|
||||
return this.intervalRepository.upsertInterval(
|
||||
dto.vehicleId,
|
||||
dto.maintenanceType,
|
||||
dto.serviceItem ?? null,
|
||||
dto.intervalKm ?? null,
|
||||
dto.intervalDays ?? null,
|
||||
dto.description ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
async getIntervals(vehicleId: string) {
|
||||
return this.intervalRepository.getActiveIntervals(vehicleId);
|
||||
}
|
||||
|
||||
async deactivateInterval(id: string): Promise<{ id: string; deactivated: boolean }> {
|
||||
await this.intervalRepository.deactivate(id);
|
||||
return { id, deactivated: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-schedule the next service after a completion: matched on the
|
||||
* completed schedule's (type, serviceItem) interval; one SCHEDULED row
|
||||
* carrying BOTH thresholds when the interval defines km and days —
|
||||
* whichever is crossed first makes it due.
|
||||
*/
|
||||
private async scheduleNextMaintenance(completed: MaintenanceSchedule): Promise<void> {
|
||||
try {
|
||||
const interval = await this.intervalRepository.getByVehicleAndType(
|
||||
completed.vehicleId,
|
||||
completed.maintenanceType as MaintenanceType,
|
||||
completed.serviceItem,
|
||||
);
|
||||
|
||||
if (!interval) return; // No interval defined, skip auto-scheduling
|
||||
|
||||
const now = new Date();
|
||||
const completedKm = Number(completed.odometerReading ?? 0);
|
||||
const intervalKm = Number(interval.intervalKm ?? 0);
|
||||
const intervalDays = Number(interval.intervalDays ?? 0);
|
||||
if (intervalKm <= 0 && intervalDays <= 0) return;
|
||||
|
||||
const nextDueKm = intervalKm > 0 ? completedKm + intervalKm : undefined;
|
||||
const nextDueDate =
|
||||
intervalDays > 0
|
||||
? new Date(now.getTime() + intervalDays * 24 * 60 * 60 * 1000)
|
||||
: undefined;
|
||||
|
||||
const label = interval.serviceItem ? `${interval.serviceItem}: ` : '';
|
||||
const due = [
|
||||
nextDueKm != null ? `${nextDueKm} km` : null,
|
||||
nextDueDate != null ? nextDueDate.toISOString().slice(0, 10) : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' / ');
|
||||
|
||||
await this.scheduleRepository.save(
|
||||
this.scheduleRepository.create({
|
||||
vehicleId: completed.vehicleId,
|
||||
maintenanceType: completed.maintenanceType,
|
||||
serviceItem: completed.serviceItem ?? interval.serviceItem ?? null,
|
||||
description: `${label}${interval.description || completed.description} (next due: ${due})`,
|
||||
scheduledDate: now,
|
||||
nextDueKm,
|
||||
nextDueDate,
|
||||
status: MaintenanceStatus.SCHEDULED,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to schedule next maintenance for vehicle ${completed.vehicleId}: ${(err as Error).message}`,
|
||||
(err as Error).stack,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async getUpcomingMaintenance(vehicleId: string) {
|
||||
return this.maintenanceRepository.getUpcomingMaintenance(vehicleId);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { OtpController } from './otp.controller';
|
||||
import { OtpService } from './otp.service';
|
||||
|
||||
describe('OtpController', () => {
|
||||
let controller: OtpController;
|
||||
@@ -7,6 +8,9 @@ describe('OtpController', () => {
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [OtpController],
|
||||
providers: [
|
||||
{ provide: OtpService, useValue: { send: jest.fn(), verify: jest.fn() } },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<OtpController>(OtpController);
|
||||
|
||||
@@ -1,33 +1,46 @@
|
||||
import { OtpService, normalizeOtpTarget } from './otp.service';
|
||||
import { OtpService, isDomesticPhone, normalizeOtpTarget } from "./otp.service";
|
||||
|
||||
describe('normalizeOtpTarget', () => {
|
||||
it('canonicalises Ethiopian forms to one E.164 key', () => {
|
||||
const forms = ['+251986680099', '251986680099', '0986680099', '+251 98 668 0099'];
|
||||
describe("normalizeOtpTarget", () => {
|
||||
it("canonicalises Ethiopian forms to one E.164 key", () => {
|
||||
const forms = [
|
||||
"+251986680099",
|
||||
"251986680099",
|
||||
"0986680099",
|
||||
"+251 98 668 0099",
|
||||
];
|
||||
const keys = forms.map((phone) => normalizeOtpTarget({ phone }).phone);
|
||||
expect(new Set(keys)).toEqual(new Set(['+251986680099']));
|
||||
expect(new Set(keys)).toEqual(new Set(["+251986680099"]));
|
||||
});
|
||||
|
||||
it('maps local 07… mobile to +2517…', () => {
|
||||
expect(normalizeOtpTarget({ phone: '0712345678' }).phone).toBe('+251712345678');
|
||||
});
|
||||
|
||||
it('canonicalises email case and surrounding whitespace to one key', () => {
|
||||
const forms = ['a@b.com', 'A@B.com', ' a@B.COM ', 'A@b.COM'];
|
||||
it("canonicalises email case and surrounding whitespace to one key", () => {
|
||||
const forms = ["a@b.com", "A@B.com", " a@B.COM ", "A@b.COM"];
|
||||
const keys = forms.map((email) => normalizeOtpTarget({ email }).email);
|
||||
expect(new Set(keys)).toEqual(new Set(['a@b.com']));
|
||||
expect(new Set(keys)).toEqual(new Set(["a@b.com"]));
|
||||
});
|
||||
|
||||
it('keeps an already-normalised email stable (idempotent)', () => {
|
||||
const once = normalizeOtpTarget({ email: ' User@Example.COM ' }).email!;
|
||||
it("keeps an already-normalised email stable (idempotent)", () => {
|
||||
const once = normalizeOtpTarget({ email: " User@Example.COM " }).email!;
|
||||
expect(normalizeOtpTarget({ email: once }).email).toBe(once);
|
||||
});
|
||||
|
||||
it('keeps an already-normalised number stable (idempotent)', () => {
|
||||
const once = normalizeOtpTarget({ phone: '0986680099' }).phone!;
|
||||
it("keeps an already-normalised number stable (idempotent)", () => {
|
||||
const once = normalizeOtpTarget({ phone: "0986680099" }).phone!;
|
||||
expect(normalizeOtpTarget({ phone: once }).phone).toBe(once);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDomesticPhone", () => {
|
||||
it.each(["+251986680099", "0986680099", "251986680099"])(
|
||||
"accepts Ethiopian mobile form %s",
|
||||
(phone) => expect(isDomesticPhone(phone)).toBe(true),
|
||||
);
|
||||
|
||||
it.each(["+14155550123", "+447911123456", "0712345678", "+2519866", "12345"])(
|
||||
"rejects non-domestic or malformed %s",
|
||||
(phone) => expect(isDomesticPhone(phone)).toBe(false),
|
||||
);
|
||||
});
|
||||
|
||||
interface FakeRow {
|
||||
id: string;
|
||||
phone?: string;
|
||||
@@ -51,7 +64,8 @@ function makeService(
|
||||
let nextId = 1;
|
||||
|
||||
const matches = (row: FakeRow, t: { phone?: string; email?: string }) =>
|
||||
(!!t.email && row.email === t.email) || (!!t.phone && row.phone === t.phone);
|
||||
(!!t.email && row.email === t.email) ||
|
||||
(!!t.phone && row.phone === t.phone);
|
||||
|
||||
const repo = {
|
||||
findByTarget: jest.fn(
|
||||
@@ -89,30 +103,30 @@ function makeService(
|
||||
return { service, sms, email, rows: () => rows };
|
||||
}
|
||||
|
||||
describe('OtpService — send/verify agree across phone formats', () => {
|
||||
it('verifies a code sent to +251… when verify is called with 09…', async () => {
|
||||
describe("OtpService — send/verify agree across phone formats", () => {
|
||||
it("verifies a code sent to +251… when verify is called with 09…", async () => {
|
||||
const { service, rows } = makeService();
|
||||
await service.sendOtp({ phone: '+251986680099' });
|
||||
await service.sendOtp({ phone: "+251986680099" });
|
||||
|
||||
await expect(
|
||||
service.verifyOtpForAction({ phone: '0986680099' }, rows()[0]!.otp),
|
||||
service.verifyOtpForAction({ phone: "0986680099" }, rows()[0]!.otp),
|
||||
).resolves.toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('verifies a code sent to User@X.com when verify is called with user@x.com', async () => {
|
||||
it("verifies a code sent to User@X.com when verify is called with user@x.com", async () => {
|
||||
const { service, rows } = makeService();
|
||||
await service.sendOtp({ email: ' User@Example.COM ' });
|
||||
await service.sendOtp({ email: " User@Example.COM " });
|
||||
|
||||
await expect(
|
||||
service.verifyOtpForAction({ email: 'user@example.com' }, rows()[0]!.otp),
|
||||
service.verifyOtpForAction({ email: "user@example.com" }, rows()[0]!.otp),
|
||||
).resolves.toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('OtpService — dual-channel send', () => {
|
||||
const both = { phone: '0986680099', email: 'User@Example.COM' };
|
||||
describe("OtpService — dual-channel send", () => {
|
||||
const both = { phone: "0986680099", email: "User@Example.COM" };
|
||||
|
||||
it('sends ONE code to both transports', async () => {
|
||||
it("sends ONE code to both transports", async () => {
|
||||
const { service, sms, email, rows } = makeService();
|
||||
await service.sendOtp(both);
|
||||
|
||||
@@ -122,72 +136,95 @@ describe('OtpService — dual-channel send', () => {
|
||||
// Same secret on both messages — the user types whichever arrives first.
|
||||
expect(sms.sendSms).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: '+251986680099',
|
||||
to: "+251986680099",
|
||||
message: expect.stringContaining(otp),
|
||||
}),
|
||||
);
|
||||
expect(email.sendEmail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: 'user@example.com',
|
||||
to: "user@example.com",
|
||||
text: expect.stringContaining(otp),
|
||||
}),
|
||||
);
|
||||
// One row, both channels canonicalised.
|
||||
expect(rows()).toHaveLength(1);
|
||||
expect(rows()[0]).toMatchObject({
|
||||
phone: '+251986680099',
|
||||
email: 'user@example.com',
|
||||
phone: "+251986680099",
|
||||
email: "user@example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['phone alone', { phone: '0986680099' }],
|
||||
['email alone', { email: 'user@example.com' }],
|
||||
['both', both],
|
||||
])('verifies a dual-channel code when quoted back by %s', async (_label, target) => {
|
||||
const { service, rows } = makeService();
|
||||
await service.sendOtp(both);
|
||||
["phone alone", { phone: "0986680099" }],
|
||||
["email alone", { email: "user@example.com" }],
|
||||
["both", both],
|
||||
])(
|
||||
"verifies a dual-channel code when quoted back by %s",
|
||||
async (_label, target) => {
|
||||
const { service, rows } = makeService();
|
||||
await service.sendOtp(both);
|
||||
|
||||
await expect(
|
||||
service.verifyOtpForAction(target, rows()[0]!.otp),
|
||||
).resolves.toEqual({ success: true });
|
||||
});
|
||||
await expect(
|
||||
service.verifyOtpForAction(target, rows()[0]!.otp),
|
||||
).resolves.toEqual({ success: true });
|
||||
},
|
||||
);
|
||||
|
||||
it('consuming the code via one channel kills the other', async () => {
|
||||
it("consuming the code via one channel kills the other", async () => {
|
||||
const { service, rows } = makeService();
|
||||
await service.sendOtp(both);
|
||||
const otp = rows()[0]!.otp;
|
||||
|
||||
await service.verifyOtpForAction({ email: 'user@example.com' }, otp);
|
||||
await service.verifyOtpForAction({ email: "user@example.com" }, otp);
|
||||
|
||||
// Single-use is per-code, not per-channel: the phone half must be dead too.
|
||||
await expect(
|
||||
service.verifyOtpForAction({ phone: '0986680099' }, otp),
|
||||
service.verifyOtpForAction({ phone: "0986680099" }, otp),
|
||||
).rejects.toThrow(/No verification code was requested/);
|
||||
});
|
||||
|
||||
it('replaces an overlapping single-channel row instead of colliding with it', async () => {
|
||||
it("replaces an overlapping single-channel row instead of colliding with it", async () => {
|
||||
const { service, rows } = makeService();
|
||||
// A pending signup code on the phone only, then a dual-channel send.
|
||||
await service.sendOtp({ phone: '0986680099' });
|
||||
await service.sendOtp({ phone: "0986680099" });
|
||||
await service.sendOtp(both);
|
||||
|
||||
expect(rows()).toHaveLength(1);
|
||||
expect(rows()[0]).toMatchObject({ email: 'user@example.com' });
|
||||
expect(rows()[0]).toMatchObject({ email: "user@example.com" });
|
||||
});
|
||||
|
||||
it('degrades to one channel when the account has only one contact', async () => {
|
||||
it("degrades to one channel when the account has only one contact", async () => {
|
||||
const { service, sms, email } = makeService();
|
||||
await service.sendOtp({ phone: '0986680099' });
|
||||
await service.sendOtp({ phone: "0986680099" });
|
||||
|
||||
expect(sms.sendSms).toHaveBeenCalledTimes(1);
|
||||
expect(email.sendEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still succeeds when one transport throws', async () => {
|
||||
it("skips SMS for a foreign number when email is available", async () => {
|
||||
const { service, sms, email, rows } = makeService();
|
||||
await service.sendOtp({ phone: "+14155550123", email: "user@example.com" });
|
||||
|
||||
// The gateway is domestic-only — email is the delivery route, but the
|
||||
// foreign phone stays on the row so verify still matches either channel.
|
||||
expect(sms.sendSms).not.toHaveBeenCalled();
|
||||
expect(email.sendEmail).toHaveBeenCalledTimes(1);
|
||||
await expect(
|
||||
service.verifyOtpForAction({ phone: "+14155550123" }, rows()[0]!.otp),
|
||||
).resolves.toEqual({ success: true });
|
||||
});
|
||||
|
||||
it("still attempts SMS for a foreign number when it is the only channel", async () => {
|
||||
const { service, sms } = makeService();
|
||||
await service.sendOtp({ phone: "+14155550123" });
|
||||
|
||||
expect(sms.sendSms).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("still succeeds when one transport throws", async () => {
|
||||
const { service, rows } = makeService({
|
||||
sms: async () => {
|
||||
throw new Error('broker down');
|
||||
throw new Error("broker down");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -197,24 +234,24 @@ describe('OtpService — dual-channel send', () => {
|
||||
});
|
||||
// The code is live and verifiable on the channel that worked.
|
||||
await expect(
|
||||
service.verifyOtpForAction({ email: 'user@example.com' }, rows()[0]!.otp),
|
||||
service.verifyOtpForAction({ email: "user@example.com" }, rows()[0]!.otp),
|
||||
).resolves.toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('fails the request when every transport throws', async () => {
|
||||
it("fails the request when every transport throws", async () => {
|
||||
const { service } = makeService({
|
||||
sms: async () => {
|
||||
throw new Error('broker down');
|
||||
throw new Error("broker down");
|
||||
},
|
||||
email: async () => {
|
||||
throw new Error('broker down');
|
||||
throw new Error("broker down");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(service.sendOtp(both)).rejects.toThrow('Failed to send OTP');
|
||||
await expect(service.sendOtp(both)).rejects.toThrow("Failed to send OTP");
|
||||
});
|
||||
|
||||
it('shares one brute-force budget across both channels', async () => {
|
||||
it("shares one brute-force budget across both channels", async () => {
|
||||
const { service, rows } = makeService();
|
||||
await service.sendOtp(both);
|
||||
const otp = rows()[0]!.otp;
|
||||
@@ -222,17 +259,17 @@ describe('OtpService — dual-channel send', () => {
|
||||
// Alternating channels must not hand the attacker two independent budgets:
|
||||
// 5 wrong guesses in total burn the code regardless of how they are split.
|
||||
for (const target of [
|
||||
{ phone: '0986680099' },
|
||||
{ email: 'user@example.com' },
|
||||
{ phone: '0986680099' },
|
||||
{ email: 'user@example.com' },
|
||||
{ phone: "0986680099" },
|
||||
{ email: "user@example.com" },
|
||||
{ phone: "0986680099" },
|
||||
{ email: "user@example.com" },
|
||||
]) {
|
||||
await expect(service.verifyOtpForAction(target, '000000')).rejects.toThrow(
|
||||
'Invalid verification code',
|
||||
);
|
||||
await expect(
|
||||
service.verifyOtpForAction(target, "000000"),
|
||||
).rejects.toThrow("Invalid verification code");
|
||||
}
|
||||
await expect(
|
||||
service.verifyOtpForAction({ email: 'user@example.com' }, '000000'),
|
||||
service.verifyOtpForAction({ email: "user@example.com" }, "000000"),
|
||||
).rejects.toThrow(/Too many incorrect attempts/);
|
||||
|
||||
// Burned: even the correct code no longer works.
|
||||
|
||||
@@ -36,9 +36,9 @@ function channelsOf(target: OtpTarget): Array<"email" | "sms"> {
|
||||
*/
|
||||
function normalizePhone(rawPhone: string): string {
|
||||
const raw = rawPhone.trim();
|
||||
const digits = raw.replace(/[^\d+]/g, '');
|
||||
if (digits.startsWith('+')) return digits;
|
||||
const bare = digits.replace(/^0+/, '');
|
||||
const digits = raw.replace(/[^\d+]/g, "");
|
||||
if (digits.startsWith("+")) return digits;
|
||||
const bare = digits.replace(/^0+/, "");
|
||||
if (/^251\d{9}$/.test(digits)) return `+${digits}`;
|
||||
if (/^9\d{8}$|^7\d{8}$/.test(bare)) return `+251${bare}`;
|
||||
// Unknown shape (foreign number, already-clean intl without +) — prefix + if
|
||||
@@ -46,6 +46,16 @@ function normalizePhone(rawPhone: string): string {
|
||||
return digits.length >= 11 ? `+${digits}` : raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a phone is an Ethiopian mobile the SMS gateway can actually reach —
|
||||
* the carrier integration is domestic-only, so a send to anything else is
|
||||
* queued and silently lost. Callers use this to fall back to email instead of
|
||||
* pretending an SMS is on its way.
|
||||
*/
|
||||
export function isDomesticPhone(rawPhone: string): boolean {
|
||||
return /^\+2519\d{8}$/.test(normalizePhone(rawPhone));
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonicalise every channel present on the target. Each field is normalised
|
||||
* independently — a dual-channel target must end up with both halves in their
|
||||
@@ -143,6 +153,20 @@ export class OtpService {
|
||||
// /otp/verify routes (a NestJS ThrottlerGuard / @Throttle) — none exists
|
||||
// in the codebase yet.
|
||||
|
||||
// A foreign number is unreachable by the domestic-only SMS gateway; when
|
||||
// email is also on the target, go email-only rather than queueing an SMS
|
||||
// that will never arrive. With no email the SMS attempt stays — it is the
|
||||
// only route there is.
|
||||
const smsPhone =
|
||||
target.phone && (!target.email || isDomesticPhone(target.phone))
|
||||
? target.phone
|
||||
: null;
|
||||
if (target.phone && !smsPhone) {
|
||||
this.logger.warn(
|
||||
`otp.dispatch.sms-skipped target=${label} — non-domestic phone, delivering via email only`,
|
||||
);
|
||||
}
|
||||
|
||||
// Fan out to every channel the target has, independently: one transport
|
||||
// being down must not suppress the other, which is the whole point of
|
||||
// sending to both. Each helper swallows its own failure so a rejected
|
||||
@@ -150,16 +174,14 @@ export class OtpService {
|
||||
const outcomes = (
|
||||
await Promise.all([
|
||||
target.email ? this.dispatchEmail(target.email, otp) : null,
|
||||
target.phone ? this.dispatchSms(target.phone, otp) : null,
|
||||
smsPhone ? this.dispatchSms(smsPhone, otp) : null,
|
||||
])
|
||||
).filter((outcome): outcome is DispatchOutcome => outcome !== null);
|
||||
|
||||
for (const outcome of outcomes) {
|
||||
this.logger.log(
|
||||
`otp.dispatch channel=${outcome.channel} target=${label} queued=${
|
||||
outcome.queued
|
||||
} latencyMs=${Date.now() - startedAt}${
|
||||
outcome.error ? ` error=${outcome.error}` : ""
|
||||
`otp.dispatch channel=${outcome.channel} target=${label} queued=${outcome.queued
|
||||
} latencyMs=${Date.now() - startedAt}${outcome.error ? ` error=${outcome.error}` : ""
|
||||
}`,
|
||||
);
|
||||
}
|
||||
@@ -183,8 +205,7 @@ export class OtpService {
|
||||
// user who never receives a code — indistinguishable from carrier loss,
|
||||
// and the misleading success response makes it look like our side worked.
|
||||
this.logger.error(
|
||||
`otp.dispatch.dropped channels=${channels.join("+")} target=${label} rabbitmqEnabled=${
|
||||
process.env.RABBITMQ_ENABLED ?? "unset"
|
||||
`otp.dispatch.dropped channels=${channels.join("+")} target=${label} rabbitmqEnabled=${process.env.RABBITMQ_ENABLED ?? "unset"
|
||||
} — no transport reported hand-off; no code will arrive for this send`,
|
||||
);
|
||||
}
|
||||
@@ -209,8 +230,7 @@ export class OtpService {
|
||||
// Log the real cause (DB/SMS/email failure) with its stack so a deployed
|
||||
// "Failed to send OTP" 400 is diagnosable from the API logs, not opaque.
|
||||
this.logger.error(
|
||||
`otp.dispatch.failed channels=${channels.join("+")} target=${label} latencyMs=${
|
||||
Date.now() - startedAt
|
||||
`otp.dispatch.failed channels=${channels.join("+")} target=${label} latencyMs=${Date.now() - startedAt
|
||||
}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
@@ -270,9 +290,7 @@ export class OtpService {
|
||||
* address while printing the credential next to it would buy nothing.
|
||||
*/
|
||||
private targetLabel(target: OtpTarget): string {
|
||||
return (
|
||||
[target.email, target.phone].filter(Boolean).join("+") || "unknown"
|
||||
);
|
||||
return [target.email, target.phone].filter(Boolean).join("+") || "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -288,9 +306,8 @@ export class OtpService {
|
||||
) {
|
||||
const line = `otp.verify channels=${channelsOf(target).join(
|
||||
"+",
|
||||
)} target=${this.targetLabel(target)} mode=${mode} result=${result}${
|
||||
detail ? ` ${detail}` : ""
|
||||
}`;
|
||||
)} target=${this.targetLabel(target)} mode=${mode} result=${result}${detail ? ` ${detail}` : ""
|
||||
}`;
|
||||
if (result === "ok") this.logger.log(line);
|
||||
else this.logger.warn(line);
|
||||
}
|
||||
@@ -439,7 +456,12 @@ export class OtpService {
|
||||
await this.otpRepository.deleteOtp(otpData);
|
||||
this.actionAttempts.delete(key);
|
||||
|
||||
this.logVerify(target, "action", "expired", `ageMs=${ageMs} ttlMs=${ttlMs}`);
|
||||
this.logVerify(
|
||||
target,
|
||||
"action",
|
||||
"expired",
|
||||
`ageMs=${ageMs} ttlMs=${ttlMs}`,
|
||||
);
|
||||
throw new BadRequestException(
|
||||
"Verification code has expired. Request a new one.",
|
||||
);
|
||||
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
} from "@nestjs/swagger";
|
||||
import { Response } from "express";
|
||||
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 { IntentStatusDto } from "./payments.dto";
|
||||
|
||||
@@ -25,7 +26,9 @@ import { IntentStatusDto } from "./payments.dto";
|
||||
export class PaymentController {
|
||||
constructor(private readonly paymentService: PaymentService) { }
|
||||
|
||||
// Customer-detail payments tab — same one-of rule as the bookings tab.
|
||||
@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)" })
|
||||
findByCompanyCustomerView(
|
||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
IsOptional,
|
||||
IsEnum,
|
||||
IsBoolean,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { VendorType } from '../entities/vendor.entity';
|
||||
import { AcquisitionType, AcquisitionStatus } from '../entities/asset-acquisition.entity';
|
||||
@@ -72,6 +73,11 @@ export class UpdateVendorDto {
|
||||
}
|
||||
|
||||
export class CreateAcquisitionDto {
|
||||
/** WHAT was acquired — required so an acquisition can't be saved empty. */
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
itemName!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
vehicleId?: string;
|
||||
@@ -120,6 +126,11 @@ export class CreateAcquisitionDto {
|
||||
}
|
||||
|
||||
export class UpdateAcquisitionDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
itemName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
vehicleId?: string;
|
||||
|
||||
@@ -18,6 +18,12 @@ export enum AcquisitionStatus {
|
||||
@Entity({ name: 'asset_acquisitions', schema: 'freight' })
|
||||
@Index(['vehicleId', 'acquisitionDate'])
|
||||
export class AssetAcquisition extends BaseEntity {
|
||||
/** WHAT was acquired (vehicle, parts, equipment…) — the asset itself. */
|
||||
@Column({ name: 'item_name', type: 'varchar', length: 200, nullable: true })
|
||||
itemName?: string;
|
||||
|
||||
/** Optional link — only when the acquisition IS a fleet vehicle. Parts and
|
||||
* general procurement stay unlinked so reports don't misattribute them. */
|
||||
@Column({ name: 'vehicle_id', type: 'uuid', nullable: true })
|
||||
vehicleId?: string;
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { ProcurementService } from './procurement.service';
|
||||
import { AcquisitionType } from './entities/asset-acquisition.entity';
|
||||
|
||||
// PURCHASE acquisitions must not carry lease terms; LEASE/RENTAL may.
|
||||
describe('ProcurementService acquisition lease-field guard', () => {
|
||||
const repo = {
|
||||
createAcquisition: jest.fn(async (dto) => dto),
|
||||
findAcquisitionById: jest.fn(async () => ({ acquisitionType: AcquisitionType.PURCHASE })),
|
||||
updateAcquisition: jest.fn(async (_id, dto) => dto),
|
||||
};
|
||||
const svc = new ProcurementService(repo as never);
|
||||
|
||||
it('rejects a PURCHASE with lease dates', async () => {
|
||||
await expect(
|
||||
svc.createAcquisition({
|
||||
itemName: 'Brake pads',
|
||||
acquisitionType: AcquisitionType.PURCHASE,
|
||||
acquisitionDate: '2026-07-22',
|
||||
leaseStart: '2026-07-01',
|
||||
} as never),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('accepts a LEASE with lease dates and a plain PURCHASE', async () => {
|
||||
await expect(
|
||||
svc.createAcquisition({
|
||||
itemName: 'Rented crane',
|
||||
acquisitionType: AcquisitionType.LEASE,
|
||||
acquisitionDate: '2026-07-22',
|
||||
leaseStart: '2026-07-01',
|
||||
leaseEnd: '2027-07-01',
|
||||
} as never),
|
||||
).resolves.toBeDefined();
|
||||
await expect(
|
||||
svc.createAcquisition({
|
||||
itemName: 'Brake pads',
|
||||
acquisitionType: AcquisitionType.PURCHASE,
|
||||
acquisitionDate: '2026-07-22',
|
||||
} as never),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects adding lease terms to an acquisition that is a PURCHASE', async () => {
|
||||
await expect(
|
||||
svc.updateAcquisition('a1', { monthlyPayment: 500 } as never),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { ProcurementRepository } from './procurement.repository';
|
||||
import { Vendor } from './entities/vendor.entity';
|
||||
import { AssetAcquisition } from './entities/asset-acquisition.entity';
|
||||
import { AcquisitionType, AssetAcquisition } from './entities/asset-acquisition.entity';
|
||||
import { AssetDisposal } from './entities/asset-disposal.entity';
|
||||
import {
|
||||
CreateVendorDto,
|
||||
@@ -51,7 +51,23 @@ export class ProcurementService {
|
||||
}
|
||||
|
||||
// ---- Acquisitions ----
|
||||
/** Lease terms only make sense on LEASE / RENTAL — a PURCHASE must not carry them. */
|
||||
private assertLeaseFieldsValid(dto: {
|
||||
acquisitionType?: string;
|
||||
leaseStart?: string;
|
||||
leaseEnd?: string;
|
||||
monthlyPayment?: number;
|
||||
}): void {
|
||||
if (dto.acquisitionType !== AcquisitionType.PURCHASE) return;
|
||||
if (dto.leaseStart || dto.leaseEnd || dto.monthlyPayment != null) {
|
||||
throw new BadRequestException(
|
||||
'Lease start/end and monthly payment are not valid for a PURCHASE acquisition',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async createAcquisition(dto: CreateAcquisitionDto): Promise<AssetAcquisition> {
|
||||
this.assertLeaseFieldsValid(dto);
|
||||
return this.procurementRepository.createAcquisition(dto);
|
||||
}
|
||||
|
||||
@@ -64,6 +80,20 @@ export class ProcurementService {
|
||||
}
|
||||
|
||||
async updateAcquisition(id: string, dto: UpdateAcquisitionDto): Promise<AssetAcquisition | null> {
|
||||
// Validate against the resulting record, not just the patch — switching an
|
||||
// acquisition to PURCHASE must also shed any stored lease terms.
|
||||
const existing = await this.procurementRepository.findAcquisitionById(id);
|
||||
if (existing) {
|
||||
const next = { ...existing, ...dto };
|
||||
if (next.acquisitionType === AcquisitionType.PURCHASE) {
|
||||
this.assertLeaseFieldsValid({
|
||||
acquisitionType: next.acquisitionType,
|
||||
leaseStart: dto.leaseStart,
|
||||
leaseEnd: dto.leaseEnd,
|
||||
monthlyPayment: dto.monthlyPayment,
|
||||
});
|
||||
}
|
||||
}
|
||||
return this.procurementRepository.updateAcquisition(id, dto);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { CreateRouteDto } from './dto/create-route.dto';
|
||||
import { FilterRoutesDto } from './dto/filter-routes.dto';
|
||||
import { UpdateRouteDto } from './dto/update-route.dto';
|
||||
@@ -10,7 +11,7 @@ import { RoutesService } from './routes.service';
|
||||
@ApiTags('routes')
|
||||
@ApiBearerAuth()
|
||||
@Controller('routes')
|
||||
@FleetView()
|
||||
@FleetView(FREIGHT_PERMS.routes.view)
|
||||
export class RoutesController {
|
||||
constructor(private readonly routesService: RoutesService) {}
|
||||
|
||||
@@ -27,21 +28,21 @@ export class RoutesController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.routes.create)
|
||||
@ApiOperation({ summary: 'Create route' })
|
||||
create(@Body() dto: CreateRouteDto) {
|
||||
return this.routesService.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.routes.update)
|
||||
@ApiOperation({ summary: 'Update route' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateRouteDto) {
|
||||
return this.routesService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.routes.delete)
|
||||
@ApiOperation({ summary: 'Deactivate route' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.routesService.deactivate(id);
|
||||
|
||||
@@ -3,7 +3,8 @@ import {
|
||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { RuleEngineManage } from '../../../common/rule-engine-guards';
|
||||
import { StaffReference } from '../../../common/booking-guards';
|
||||
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
|
||||
import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { MoveOrderDto } from '../dto/move-order.dto';
|
||||
@@ -18,7 +19,7 @@ export class CargoTypesController {
|
||||
constructor(private readonly service: CargoTypesService) {}
|
||||
|
||||
@Get()
|
||||
@RuleEngineView('cargo-types')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'List cargo types' })
|
||||
findAll(@Query() query: ListCargoTypesQueryDto) {
|
||||
return this.service.findAll(query);
|
||||
@@ -41,7 +42,7 @@ export class CargoTypesController {
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RuleEngineView('cargo-types')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'Get a cargo type by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.findById(id);
|
||||
|
||||
@@ -3,7 +3,8 @@ import {
|
||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { RuleEngineManage } from '../../../common/rule-engine-guards';
|
||||
import { StaffReference } from '../../../common/booking-guards';
|
||||
import { CreateContainerTypeDto } from '../dto/create-container-type.dto';
|
||||
import { ListContainerTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { MoveOrderDto } from '../dto/move-order.dto';
|
||||
@@ -18,7 +19,7 @@ export class ContainerTypesController {
|
||||
constructor(private readonly service: ContainerTypesService) {}
|
||||
|
||||
@Get()
|
||||
@RuleEngineView('container-types')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'List container types' })
|
||||
findAll(@Query() query: ListContainerTypesQueryDto) {
|
||||
return this.service.findAll(query);
|
||||
@@ -41,7 +42,7 @@ export class ContainerTypesController {
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RuleEngineView('container-types')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'Get a container type by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.findById(id);
|
||||
|
||||
@@ -2,7 +2,8 @@ import {
|
||||
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||
} from '@nestjs/common';
|
||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { RuleEngineManage } from '../../../common/rule-engine-guards';
|
||||
import { StaffReference } from '../../../common/booking-guards';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CreateServiceTypeDto } from '../dto/create-service-type.dto';
|
||||
import { ListServiceTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
@@ -18,7 +19,7 @@ export class ServiceTypesController {
|
||||
constructor(private readonly service: ServiceTypesService) {}
|
||||
|
||||
@Get()
|
||||
@RuleEngineView('service-types')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'List service types' })
|
||||
findAll(@Query() query: ListServiceTypesQueryDto) {
|
||||
return this.service.findAll(query);
|
||||
@@ -41,7 +42,7 @@ export class ServiceTypesController {
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RuleEngineView('service-types')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'Get a service type by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.findById(id);
|
||||
|
||||
@@ -2,7 +2,8 @@ import {
|
||||
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||
} from '@nestjs/common';
|
||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { RuleEngineManage } from '../../../common/rule-engine-guards';
|
||||
import { StaffReference } from '../../../common/booking-guards';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CreateShippingLineDto } from '../dto/create-shipping-line.dto';
|
||||
import { ListRuleEngineQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
@@ -16,14 +17,14 @@ export class ShippingLinesController {
|
||||
constructor(private readonly service: ShippingLinesService) {}
|
||||
|
||||
@Get()
|
||||
@RuleEngineView('shipping-lines')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'List shipping lines' })
|
||||
findAll(@Query() query: ListRuleEngineQueryDto) {
|
||||
return this.service.findAll(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RuleEngineView('shipping-lines')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'Get a shipping line by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.findById(id);
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { RuleEngineManage } from '../../../common/rule-engine-guards';
|
||||
import { StaffReference } from '../../../common/booking-guards';
|
||||
import { CreateYardDistanceDto } from '../dto/create-yard-distance.dto';
|
||||
import { ListYardDistancesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { UpdateYardDistanceDto } from '../dto/update-yard-distance.dto';
|
||||
@@ -25,14 +26,14 @@ export class YardDistancesController {
|
||||
constructor(private readonly service: YardDistancesService) {}
|
||||
|
||||
@Get()
|
||||
@RuleEngineView('yard-distances')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'List yard distances' })
|
||||
findAll(@Query() query: ListYardDistancesQueryDto) {
|
||||
return this.service.findAll(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RuleEngineView('yard-distances')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'Get a yard distance by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.findById(id);
|
||||
|
||||
@@ -2,7 +2,8 @@ import {
|
||||
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||
} from '@nestjs/common';
|
||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { RuleEngineManage } from '../../../common/rule-engine-guards';
|
||||
import { StaffReference } from '../../../common/booking-guards';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CreateYardDto } from '../dto/create-yard.dto';
|
||||
import { ListYardsQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
@@ -18,7 +19,9 @@ export class YardsController {
|
||||
constructor(private readonly service: YardsService) {}
|
||||
|
||||
@Get()
|
||||
@RuleEngineView('yards')
|
||||
// Reference read: every staff form/search needs the yard list (origin /
|
||||
// destination pickers), so login is the only requirement.
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'List yards' })
|
||||
findAll(@Query() query: ListYardsQueryDto) {
|
||||
return this.service.findAll(query);
|
||||
@@ -41,7 +44,7 @@ export class YardsController {
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RuleEngineView('yards')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'Get a yard by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.findById(id);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const;
|
||||
const CURRENCIES = ['USD'] as const;
|
||||
export const INTERCITY_KINDS = ['CONTAINER', 'BULK'] as const;
|
||||
export const CARGO_KINDS = ['CONTAINER', 'BULK'] as const;
|
||||
|
||||
export class CreateRateDto {
|
||||
@ApiProperty({ enum: RATE_APPLIES_TO, description: 'Friendly category the rate applies to' })
|
||||
@@ -47,6 +48,15 @@ export class CreateRateDto {
|
||||
@IsIn([...INTERCITY_KINDS])
|
||||
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({
|
||||
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.',
|
||||
|
||||
@@ -125,6 +125,22 @@ export class ListRatesQueryDto extends PaginationQueryDto {
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
rateType?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Filter by rate category — comma-separated appliesTo values (e.g. "CONTAINER" or "FIRST_MILE,LAST_MILE").',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
appliesTo?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Filter by surcharge trigger — comma-separated trigger values (e.g. "CUSTOMS_CLEARANCE" or "HAZARDOUS,REEFER").',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
trigger?: string;
|
||||
}
|
||||
|
||||
export class ListWeightLimitRulesQueryDto extends PaginationQueryDto {
|
||||
|
||||
@@ -13,6 +13,8 @@ import type { RateAppliesTo, RateTrigger, RateUnit } from './rate.entity';
|
||||
export function allowedRateUnits(input: {
|
||||
appliesTo: RateAppliesTo;
|
||||
trigger: RateTrigger;
|
||||
/** CUSTOMS_CLEARANCE only: which cargo kind the fee covers. */
|
||||
cargoKind?: 'CONTAINER' | 'BULK' | null;
|
||||
}): RateUnit[] {
|
||||
const { appliesTo, trigger } = input;
|
||||
|
||||
@@ -29,16 +31,20 @@ export function allowedRateUnits(input: {
|
||||
case 'DEMURRAGE':
|
||||
return ['PER_CONTAINER', 'PER_TON'];
|
||||
case 'WITH_RETURN':
|
||||
// Container-only empty-return service — bills per returned container.
|
||||
return ['PER_CONTAINER', 'FLAT'];
|
||||
// Container-only empty-return service — per returned container, per
|
||||
// wagon the empties ride back on, or a flat fee.
|
||||
return ['PER_CONTAINER', 'PER_WAGON', 'FLAT'];
|
||||
case 'CANCELLATION':
|
||||
return ['FLAT', 'PER_INVOICE'];
|
||||
case 'CUSTOMS_CLEARANCE':
|
||||
// Flat per clearance (ONE_TIME contract) / per shipment request (GENERAL).
|
||||
return ['FLAT'];
|
||||
// Sold per cargo kind: container fees bill per box or per wagon, bulk
|
||||
// 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':
|
||||
// Flat cargo-securing fee, billed once per booking.
|
||||
return ['FLAT'];
|
||||
// Bulk-only cargo securing — per ton or per wagon.
|
||||
return ['PER_TON', 'PER_WAGON'];
|
||||
case 'CONSOLIDATION':
|
||||
return ['PER_CONTAINER', 'FLAT'];
|
||||
case 'SHIPPING_LINE':
|
||||
@@ -74,6 +80,7 @@ export function defaultRateUnit(input: { appliesTo: RateAppliesTo; trigger: Rate
|
||||
export function isRateUnitAllowed(input: {
|
||||
appliesTo: RateAppliesTo;
|
||||
trigger: RateTrigger;
|
||||
cargoKind?: 'CONTAINER' | 'BULK' | null;
|
||||
unit: RateUnit;
|
||||
}): boolean {
|
||||
return allowedRateUnits(input).includes(input.unit);
|
||||
|
||||
@@ -117,6 +117,21 @@ export class RatesRepository implements IRatesRepository {
|
||||
if (query.rateType) {
|
||||
qb.andWhere('rate.rateType = :rateType', { rateType: query.rateType });
|
||||
}
|
||||
// Category tabs on the admin page: comma-separated appliesTo / trigger
|
||||
// lists, ANDed together (e.g. appliesTo=OTHER + trigger=CUSTOMS_CLEARANCE).
|
||||
const csv = (v?: string) =>
|
||||
(v ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const appliesTo = csv(query.appliesTo);
|
||||
if (appliesTo.length > 0) {
|
||||
qb.andWhere('rate.appliesTo IN (:...appliesTo)', { appliesTo });
|
||||
}
|
||||
const triggers = csv(query.trigger);
|
||||
if (triggers.length > 0) {
|
||||
qb.andWhere('rate.trigger IN (:...triggers)', { triggers });
|
||||
}
|
||||
if (query.search) {
|
||||
qb.andWhere(
|
||||
'(rate.rateType ILIKE :search OR rate.status ILIKE :search OR rate.rateUnit ILIKE :search OR rate.currency ILIKE :search)',
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
import { RuleEngineService } from './rule-engine.service';
|
||||
import type { BookingEvaluationInput } from './rule-engine.service';
|
||||
import type { Rate } from './entities/rate.entity';
|
||||
|
||||
describe('RuleEngineService — requested service without a configured surcharge rate', () => {
|
||||
const hazardRate: Rate = {
|
||||
id: 'rate-hazard',
|
||||
rateType: 'HAZARD_SURCHARGE',
|
||||
trigger: 'HAZARDOUS',
|
||||
rateValue: 50,
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
currency: 'USD',
|
||||
status: 'LIVE',
|
||||
containerTypeId: null,
|
||||
cargoTypeId: null,
|
||||
} as Rate;
|
||||
|
||||
let ratesRepo: { findLiveRates: jest.Mock };
|
||||
let service: RuleEngineService;
|
||||
|
||||
beforeEach(() => {
|
||||
ratesRepo = { findLiveRates: jest.fn().mockResolvedValue([]) };
|
||||
service = new RuleEngineService(
|
||||
{ findById: jest.fn().mockResolvedValue(null) } as never, // cargoTypes
|
||||
{ findById: jest.fn().mockResolvedValue(null) } as never, // serviceTypes
|
||||
{ findActiveByContainerTypeId: jest.fn().mockResolvedValue([]) } as never, // weightLimits
|
||||
{ findAllActive: jest.fn().mockResolvedValue([]) } as never, // priorityConfigs
|
||||
ratesRepo as never,
|
||||
{ findById: jest.fn().mockResolvedValue(null) } as never, // shippingLines
|
||||
{} as never, // dataSource (unused by evaluate)
|
||||
);
|
||||
});
|
||||
|
||||
const input = (overrides: Partial<BookingEvaluationInput>): BookingEvaluationInput => ({
|
||||
serviceTypeId: 'svc-1',
|
||||
paymentCurrency: 'USD',
|
||||
tradeDirection: 'IMPORT',
|
||||
isHazardous: false,
|
||||
totalWagons: 1,
|
||||
containers: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('hard-blocks a hazardous booking when no HAZARDOUS surcharge rate is LIVE', async () => {
|
||||
const result = await service.evaluate(input({ isHazardous: true }));
|
||||
expect(result.hardBlocked).toHaveLength(1);
|
||||
expect(result.hardBlocked[0]).toContain('hazardous');
|
||||
});
|
||||
|
||||
it('passes a hazardous booking when a HAZARDOUS surcharge rate is LIVE', async () => {
|
||||
ratesRepo.findLiveRates.mockResolvedValue([hazardRate]);
|
||||
const result = await service.evaluate(input({ isHazardous: true }));
|
||||
expect(result.hardBlocked).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not block a non-hazardous booking when no surcharge rates exist', async () => {
|
||||
const result = await service.evaluate(input({}));
|
||||
expect(result.hardBlocked).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('hard-blocks on per-container opt-in counts even without the booking-level flag', async () => {
|
||||
const result = await service.evaluate(
|
||||
input({
|
||||
containers: [
|
||||
{
|
||||
containerTypeId: 'ct-20',
|
||||
quantity: 2,
|
||||
vgmPerUnitTons: 10,
|
||||
totalVgmTons: 20,
|
||||
reeferQuantity: 1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result.hardBlocked).toHaveLength(1);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,10 @@ import {
|
||||
} from './interfaces/shipping-lines.repository.interface';
|
||||
import { GOVERNMENT_PRIORITY_BONUS } from './government-priority.constants';
|
||||
|
||||
// Coerce defensively: a flag may arrive as the string "true"/"false" (e.g.
|
||||
// from multipart form-data) and a non-empty "false" string is truthy.
|
||||
const truthy = (v: unknown): boolean => v === true || v === 'true';
|
||||
|
||||
export interface BookingContainerEvalInput {
|
||||
containerTypeId: string;
|
||||
quantity: number;
|
||||
@@ -44,6 +48,12 @@ export interface BookingContainerEvalInput {
|
||||
hazardousQuantity?: number;
|
||||
reeferQuantity?: 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 {
|
||||
@@ -63,6 +73,12 @@ export interface BookingEvaluationInput {
|
||||
isGovernment?: boolean;
|
||||
allowConsolidation?: boolean;
|
||||
shippingLineId?: string | null;
|
||||
/**
|
||||
* The booking's rail leg. Import overweight derives its per-ton price from
|
||||
* this route's own container freight rate, so the engine needs the yards.
|
||||
*/
|
||||
originYardId?: string | null;
|
||||
destinationYardId?: string | null;
|
||||
/**
|
||||
* Booking's cargo type needs EDR-provided lashing/securing (cargoType
|
||||
* hasLashing = true). Fires the flat LASHING surcharge. Resolved by the
|
||||
@@ -76,6 +92,12 @@ export interface BookingEvaluationInput {
|
||||
* container freight, which is scaled by container count instead.
|
||||
*/
|
||||
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[];
|
||||
}
|
||||
|
||||
@@ -87,6 +109,15 @@ export interface AppliedCargoModifier {
|
||||
triggerValue: number | null;
|
||||
calculatedAmount: number;
|
||||
currency: string;
|
||||
/**
|
||||
* Effective per-unit USD price when it differs from the rate row's own value
|
||||
* — set by derived charges (import overweight: base freight ÷ 2×limit) so
|
||||
* the breakdown shows the real per-ton figure, not the base container price.
|
||||
* Any modifier carrying it also bypasses frozen contract snapshots.
|
||||
*/
|
||||
unitPriceUsd?: number | null;
|
||||
/** Display unit for a unitPriceUsd modifier (e.g. PER_TON for overweight). */
|
||||
billingUnit?: string;
|
||||
}
|
||||
|
||||
export interface ContainerWeightResult {
|
||||
@@ -161,12 +192,16 @@ export class RuleEngineService {
|
||||
...(await this.capacityViolations(input.containers, input.tradeDirection)),
|
||||
);
|
||||
|
||||
// Per-container-line weight limit (maxVgmTons), index-aligned with
|
||||
// containerWeightResults — the derived import overweight divides by it.
|
||||
const lineMaxVgmTons: Array<number | null> = [];
|
||||
for (const container of input.containers) {
|
||||
const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId(
|
||||
container.containerTypeId,
|
||||
input.tradeDirection,
|
||||
);
|
||||
const rule = rules[0];
|
||||
lineMaxVgmTons.push(rule ? Number(rule.maxVgmTons) : null);
|
||||
let isOverweight = container.isOverweight ?? false;
|
||||
let excess = container.overweightExcessTons ?? null;
|
||||
|
||||
@@ -245,7 +280,53 @@ export class RuleEngineService {
|
||||
liveRates.filter((r) => r.trigger && r.trigger !== 'ALWAYS'),
|
||||
);
|
||||
|
||||
// A handling service the booking asks for (booking-level flag OR any
|
||||
// per-container opt-in count) with no LIVE surcharge rate configured is a
|
||||
// hard block — pricing would otherwise ship the service for free. System-
|
||||
// derived charges (consolidation, overweight, shipping line, lashing) stay
|
||||
// exempt: the customer never opted into those, so they must not block.
|
||||
const requestedServices: Array<{
|
||||
trigger: RateTrigger;
|
||||
wanted: boolean;
|
||||
label: string;
|
||||
}> = [
|
||||
{
|
||||
trigger: 'HAZARDOUS',
|
||||
wanted:
|
||||
truthy(input.isHazardous) ||
|
||||
input.containers.some((c) => Number(c.hazardousQuantity ?? 0) > 0),
|
||||
label: 'hazardous cargo',
|
||||
},
|
||||
{
|
||||
trigger: 'REEFER',
|
||||
wanted:
|
||||
hasReefer ||
|
||||
input.containers.some((c) => Number(c.reeferQuantity ?? 0) > 0),
|
||||
label: 'refrigerated (reefer) cargo',
|
||||
},
|
||||
];
|
||||
for (const svc of requestedServices) {
|
||||
if (svc.wanted && !surchargeRates.some((r) => r.trigger === svc.trigger)) {
|
||||
hardBlocked.push(
|
||||
`No ${svc.label} surcharge rate is configured — the booking cannot ` +
|
||||
`be priced with this service. Remove the ${svc.label} option or ` +
|
||||
'ask EDR to configure its rate.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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, {
|
||||
isHazardous: input.isHazardous,
|
||||
hasReefer,
|
||||
@@ -338,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 {
|
||||
priorityScore,
|
||||
appliedModifiers,
|
||||
@@ -348,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
|
||||
* ceiling (weight_limit_rules.max_capacity_tons). Non-empty ⇒ the booking
|
||||
@@ -438,9 +718,6 @@ export class RuleEngineService {
|
||||
hasLashing: boolean;
|
||||
},
|
||||
): boolean {
|
||||
// Coerce defensively: a flag may arrive as the string "true"/"false" (e.g.
|
||||
// from multipart form-data) and a non-empty "false" string is truthy.
|
||||
const truthy = (v: unknown): boolean => v === true || v === 'true';
|
||||
switch (trigger) {
|
||||
case 'HAZARDOUS':
|
||||
return truthy(state.isHazardous);
|
||||
|
||||
@@ -101,6 +101,23 @@ describe('RateChangeRequestsService', () => {
|
||||
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 () => {
|
||||
const { service } = build();
|
||||
await expect(
|
||||
|
||||
@@ -33,6 +33,10 @@ const DIFFABLE_FIELDS = [
|
||||
'tradeDirection',
|
||||
'containerTypeId',
|
||||
'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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -69,18 +69,19 @@ export class RatesService {
|
||||
appliesTo: Rate['appliesTo'],
|
||||
trigger: Rate['trigger'],
|
||||
requestedUnit: Rate['rateUnit'] | undefined,
|
||||
cargoKind?: 'CONTAINER' | 'BULK' | null,
|
||||
): Rate['rateUnit'] {
|
||||
// Overweight is per-ton, full stop — the admin form hides the unit field
|
||||
// for it and omits rateUnit from the payload entirely.
|
||||
if (trigger === 'OVERWEIGHT') return 'PER_TON';
|
||||
|
||||
const allowed = allowedRateUnits({ appliesTo, trigger });
|
||||
const allowed = allowedRateUnits({ appliesTo, trigger, cargoKind });
|
||||
if (!requestedUnit) {
|
||||
throw new BadRequestException(
|
||||
`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(
|
||||
`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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rates sold per direction + route. Base freight always; customs clearance
|
||||
* and empty-container return are the surcharges that are too — their fee
|
||||
* depends on the lane (and, for returns, the container type).
|
||||
*/
|
||||
private isRouteScoped(appliesTo: Rate['appliesTo'], trigger: Rate['trigger']): boolean {
|
||||
return (
|
||||
this.isBaseFreight(appliesTo, trigger) ||
|
||||
trigger === 'CUSTOMS_CLEARANCE' ||
|
||||
trigger === 'WITH_RETURN'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which country each end of the leg must sit in, given what the rate is for.
|
||||
* The railway only sells three shapes: import lands at the Djibouti ports and
|
||||
@@ -126,7 +140,7 @@ export class RatesService {
|
||||
destinationYardId?: string | null;
|
||||
}): Promise<YardScope> {
|
||||
const { appliesTo, trigger, tradeDirection } = input;
|
||||
if (!this.isBaseFreight(appliesTo, trigger)) {
|
||||
if (!this.isRouteScoped(appliesTo, trigger)) {
|
||||
return { originYardId: null, destinationYardId: null };
|
||||
}
|
||||
|
||||
@@ -134,7 +148,7 @@ export class RatesService {
|
||||
const destinationYardId = input.destinationYardId ?? null;
|
||||
if (!originYardId || !destinationYardId) {
|
||||
throw new BadRequestException(
|
||||
'Base freight rates are priced per leg — pick both an origin and a destination yard.',
|
||||
'This rate is priced per leg — pick both an origin and a destination yard.',
|
||||
);
|
||||
}
|
||||
if (originYardId === destinationYardId) {
|
||||
@@ -174,11 +188,76 @@ export class RatesService {
|
||||
trigger: Rate['trigger'];
|
||||
tradeDirection: string | null;
|
||||
intercityKind: string | null;
|
||||
cargoKind: string | null;
|
||||
containerTypeId: string | null;
|
||||
cargoTypeId: string | null;
|
||||
}): void {
|
||||
const { appliesTo, trigger, tradeDirection, intercityKind } = input;
|
||||
const { appliesTo, trigger, tradeDirection, intercityKind, cargoKind } = 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 (appliesTo === 'INTERCITY') {
|
||||
@@ -247,13 +326,35 @@ export class RatesService {
|
||||
const trigger = dto.trigger as Rate['trigger'];
|
||||
// Surcharges (trigger ≠ ALWAYS) carry no direction/scope — clear them so
|
||||
// the engine never accidentally narrows a surcharge by container/direction.
|
||||
// Exceptions: customs clearance and empty-container return keep direction +
|
||||
// container type — both are sold per lane (and per container type).
|
||||
const isSurcharge = trigger !== 'ALWAYS';
|
||||
const containerTypeId = isSurcharge ? null : (dto.containerTypeId ?? null);
|
||||
const cargoTypeId = isSurcharge ? null : (dto.cargoTypeId ?? null);
|
||||
const cargoKind =
|
||||
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 —
|
||||
// its yard pair already says where it runs.
|
||||
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;
|
||||
this.assertScopeCoherent({
|
||||
@@ -261,6 +362,7 @@ export class RatesService {
|
||||
trigger,
|
||||
tradeDirection,
|
||||
intercityKind,
|
||||
cargoKind,
|
||||
containerTypeId,
|
||||
cargoTypeId,
|
||||
});
|
||||
@@ -282,6 +384,7 @@ export class RatesService {
|
||||
appliesTo,
|
||||
trigger,
|
||||
dto.rateUnit as Rate['rateUnit'] | undefined,
|
||||
cargoKind,
|
||||
);
|
||||
|
||||
await this.assertNoDuplicatePattern({
|
||||
@@ -376,22 +479,42 @@ export class RatesService {
|
||||
if (dto.appliesTo) updates.appliesTo = appliesTo;
|
||||
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
|
||||
: dto.containerTypeId !== undefined
|
||||
? dto.containerTypeId
|
||||
: existing.containerTypeId;
|
||||
const cargoTypeId = isSurcharge
|
||||
const keepsCargoType =
|
||||
!isSurcharge ||
|
||||
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') ||
|
||||
trigger === 'LASHING';
|
||||
const cargoTypeId = !keepsCargoType
|
||||
? null
|
||||
: dto.cargoTypeId !== undefined
|
||||
? dto.cargoTypeId
|
||||
: existing.cargoTypeId;
|
||||
const tradeDirection =
|
||||
isSurcharge || appliesTo === 'INTERCITY'
|
||||
? null
|
||||
: dto.tradeDirection !== undefined
|
||||
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN' || trigger === 'LASHING'
|
||||
? dto.tradeDirection !== undefined
|
||||
? dto.tradeDirection
|
||||
: existing.tradeDirection;
|
||||
: existing.tradeDirection
|
||||
: isSurcharge || appliesTo === 'INTERCITY'
|
||||
? null
|
||||
: dto.tradeDirection !== undefined
|
||||
? dto.tradeDirection
|
||||
: existing.tradeDirection;
|
||||
|
||||
updates.containerTypeId = containerTypeId ?? null;
|
||||
updates.cargoTypeId = cargoTypeId ?? null;
|
||||
@@ -407,6 +530,7 @@ export class RatesService {
|
||||
trigger,
|
||||
tradeDirection: updates.tradeDirection,
|
||||
intercityKind,
|
||||
cargoKind,
|
||||
containerTypeId: updates.containerTypeId,
|
||||
cargoTypeId: updates.cargoTypeId,
|
||||
});
|
||||
@@ -438,7 +562,7 @@ export class RatesService {
|
||||
// Re-validate the unit against the (possibly changed) shape; overweight is
|
||||
// forced to PER_TON.
|
||||
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.
|
||||
await this.assertNoDuplicatePattern({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user