mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
10
.gitignore
vendored
10
.gitignore
vendored
@@ -37,3 +37,13 @@ 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/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
|
||||
@@ -163,11 +163,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 +198,45 @@ 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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -137,8 +137,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;
|
||||
@@ -248,7 +252,7 @@ export class BookingPricingService {
|
||||
appliedModifiers: ruleResult.appliedModifiers,
|
||||
priorityScore: ruleResult.priorityScore,
|
||||
warnings: [...ruleResult.warnings, ...baseWarnings],
|
||||
hardBlocked: ruleResult.hardBlocked,
|
||||
hardBlocked: [...ruleResult.hardBlocked, ...baseBlocked],
|
||||
overweightLines,
|
||||
};
|
||||
}
|
||||
@@ -454,7 +458,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 +486,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 +510,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 +600,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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -436,18 +436,26 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Get(':id/customer-truck-assignment/freight-order')
|
||||
@ApiOperation({ summary: 'Download duplicate freight order copies for customer truck assignment' })
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Download freight order copies. The 2 gate copies always print; ?copies=1,2,8 adds waybill-style copies (catalog indexes 1-8).',
|
||||
})
|
||||
async customerTruckFreightOrder(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Res() res: Response,
|
||||
@Query('copies') copies?: string,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
}
|
||||
const extraCopyIndexes = (copies ?? '')
|
||||
.split(',')
|
||||
.map((n) => Number(n.trim()))
|
||||
.filter((n) => Number.isInteger(n) && n >= 1 && n <= 8);
|
||||
const { filename, buffer } =
|
||||
await this.bookingsService.customerTruckFreightOrderCopies(id);
|
||||
await this.bookingsService.customerTruckFreightOrderCopies(id, extraCopyIndexes);
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.send(buffer);
|
||||
@@ -480,6 +488,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>`;
|
||||
}
|
||||
@@ -635,12 +654,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 +762,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
|
||||
@@ -1068,9 +1076,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);
|
||||
@@ -1391,15 +1396,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
|
||||
|
||||
@@ -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;
|
||||
}>;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -26,7 +26,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";
|
||||
@@ -353,21 +352,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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -289,6 +289,7 @@ export class ContractBookingService {
|
||||
tradeDirection: contract.tradeDirection,
|
||||
freightType,
|
||||
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
||||
cargoFreeText: dto.cargoFreeText?.trim() || null,
|
||||
isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'),
|
||||
isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'),
|
||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||
@@ -320,6 +321,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.
|
||||
@@ -732,6 +739,7 @@ export class ContractBookingService {
|
||||
}
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
||||
cargoFreeText: dto.cargoFreeText?.trim() || null,
|
||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
|
||||
} as never);
|
||||
@@ -744,15 +752,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, {
|
||||
@@ -1896,7 +1909,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,
|
||||
|
||||
@@ -18,7 +18,10 @@ 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,
|
||||
canEditContractStep,
|
||||
} from '../../common/freight-permission.util';
|
||||
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';
|
||||
@@ -431,12 +434,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. */
|
||||
|
||||
@@ -196,7 +196,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 +276,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);
|
||||
}
|
||||
@@ -475,7 +479,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 +517,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 +576,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 +605,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 ?? []);
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Entity, Column, ManyToOne, JoinColumn, Index, Unique } from 'typeorm';
|
||||
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
|
||||
import { MaintenanceType } from './maintenance-schedule.entity';
|
||||
|
||||
/**
|
||||
* Maintenance interval configuration. Defines how often a vehicle/type needs maintenance.
|
||||
* Each vehicle can have different intervals for different maintenance types (e.g., oil every 10k km, tires every 50k km).
|
||||
*/
|
||||
@Entity({ name: 'maintenance_intervals', schema: 'freight' })
|
||||
@Index(['vehicleId', 'maintenanceType'])
|
||||
@Unique(['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;
|
||||
|
||||
/** 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;
|
||||
}
|
||||
@@ -62,4 +62,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,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,60 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { 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);
|
||||
}
|
||||
|
||||
async getByVehicleAndType(vehicleId: string, maintenanceType: MaintenanceType): Promise<MaintenanceInterval | null> {
|
||||
return this.intervalRepository.findOne({
|
||||
where: { vehicleId, maintenanceType, isActive: true },
|
||||
});
|
||||
}
|
||||
|
||||
async getActiveIntervals(vehicleId: string): Promise<MaintenanceInterval[]> {
|
||||
return this.intervalRepository.find({
|
||||
where: { vehicleId, isActive: true },
|
||||
order: { maintenanceType: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async upsertInterval(
|
||||
vehicleId: string,
|
||||
maintenanceType: MaintenanceType,
|
||||
intervalKm?: number | null,
|
||||
intervalDays?: number | null,
|
||||
description?: string | null,
|
||||
): Promise<MaintenanceInterval> {
|
||||
const existing = await this.getByVehicleAndType(vehicleId, maintenanceType);
|
||||
|
||||
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,
|
||||
intervalKm,
|
||||
intervalDays,
|
||||
description,
|
||||
isActive: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,13 @@ 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();
|
||||
}
|
||||
|
||||
@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,105 @@ 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;
|
||||
description: string;
|
||||
scheduledDate: Date;
|
||||
nextDueDate: Date | null;
|
||||
nextDueKm: number | null;
|
||||
currentKm: number | null;
|
||||
kmRemaining: number | null;
|
||||
daysRemaining: number | null;
|
||||
overdue: boolean;
|
||||
}>
|
||||
> {
|
||||
return this.scheduleRepository.manager.query(`
|
||||
SELECT DISTINCT ON (s.vehicle_id)
|
||||
s.id AS "scheduleId",
|
||||
s.vehicle_id AS "vehicleId",
|
||||
v.plate_number AS "plateNumber",
|
||||
s.maintenance_type AS "maintenanceType",
|
||||
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,23 @@
|
||||
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 { 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 +25,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
|
||||
@@ -78,6 +121,11 @@ export class MaintenanceService {
|
||||
) {
|
||||
// Maintenance finished/aborted → vehicle back in service.
|
||||
await this.setVehicleMaintenanceState(updated.vehicleId, false);
|
||||
|
||||
// If completed, schedule the next maintenance based on interval
|
||||
if (dto.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 +135,60 @@ export class MaintenanceService {
|
||||
return updated!;
|
||||
}
|
||||
|
||||
private async scheduleNextMaintenance(completed: MaintenanceSchedule): Promise<void> {
|
||||
try {
|
||||
// Get maintenance interval for this type
|
||||
const interval = await this.intervalRepository.getByVehicleAndType(
|
||||
completed.vehicleId,
|
||||
completed.maintenanceType as MaintenanceType,
|
||||
);
|
||||
|
||||
if (!interval) return; // No interval defined, skip auto-scheduling
|
||||
|
||||
const now = new Date();
|
||||
const completedKm = Number(completed.odometerReading ?? 0);
|
||||
|
||||
// Calculate next due based on KM interval
|
||||
if (interval.intervalKm && interval.intervalKm > 0) {
|
||||
const nextDueKm = completedKm + Number(interval.intervalKm);
|
||||
|
||||
// Create next scheduled maintenance
|
||||
const nextSchedule = this.scheduleRepository.create({
|
||||
vehicleId: completed.vehicleId,
|
||||
maintenanceType: completed.maintenanceType,
|
||||
description: `${interval.description || completed.description} (Next interval: ${nextDueKm} km)`,
|
||||
scheduledDate: now,
|
||||
nextDueKm,
|
||||
status: MaintenanceStatus.SCHEDULED,
|
||||
});
|
||||
await this.scheduleRepository.save(nextSchedule);
|
||||
}
|
||||
|
||||
// Calculate next due based on date interval
|
||||
if (interval.intervalDays && interval.intervalDays > 0) {
|
||||
const nextDueDate = new Date(now.getTime() + interval.intervalDays * 24 * 60 * 60 * 1000);
|
||||
|
||||
// If no KM-based next maintenance was created, use date-based
|
||||
if (!interval.intervalKm) {
|
||||
const nextSchedule = this.scheduleRepository.create({
|
||||
vehicleId: completed.vehicleId,
|
||||
maintenanceType: completed.maintenanceType,
|
||||
description: completed.description,
|
||||
scheduledDate: now,
|
||||
nextDueDate,
|
||||
status: MaintenanceStatus.SCHEDULED,
|
||||
});
|
||||
await this.scheduleRepository.save(nextSchedule);
|
||||
}
|
||||
}
|
||||
} 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.",
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -245,6 +249,48 @@ 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',
|
||||
},
|
||||
{
|
||||
trigger: 'WITH_RETURN',
|
||||
wanted:
|
||||
truthy(input.withReturn) ||
|
||||
input.containers.some((c) => Number(c.returnQuantity ?? 0) > 0),
|
||||
label: 'empty-container return',
|
||||
},
|
||||
];
|
||||
for (const svc of requestedServices) {
|
||||
if (svc.wanted && !surchargeRates.some((r) => r.trigger === svc.trigger)) {
|
||||
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) {
|
||||
const triggered = this.matchesTrigger(rate.trigger, {
|
||||
isHazardous: input.isHazardous,
|
||||
@@ -438,9 +484,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);
|
||||
|
||||
@@ -50,8 +50,11 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
|
||||
company: true,
|
||||
originYard: true,
|
||||
destinationYard: true,
|
||||
bookingContainers: { containerType: true },
|
||||
cargoType: true,
|
||||
// wagonTypes feed grossBookingWeightTons the REAL tare of the
|
||||
// wagon type the booking rides — without them it falls back to
|
||||
// default tares and the workspace gross drifts from the validator.
|
||||
bookingContainers: { containerType: { wagonTypes: true } },
|
||||
cargoType: { wagonTypes: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -570,6 +570,104 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('expireLeftoverExportDay — export day sweep', () => {
|
||||
const exportSchedule = {
|
||||
id: scheduleId,
|
||||
direction: 'EXPORT',
|
||||
originStationId: 'yard-origin',
|
||||
destinationStationId: 'yard-dest',
|
||||
scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'),
|
||||
windowPhase: 'DONE',
|
||||
bookingWindowStatus: 'CLOSED',
|
||||
};
|
||||
let unacceptedSpy: jest.SpyInstance;
|
||||
let poolSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
unacceptedSpy = jest
|
||||
.spyOn(service, 'expireUnacceptedForRouteDay')
|
||||
.mockResolvedValue(undefined);
|
||||
poolSpy = jest.spyOn(service, 'expireLeftoverDayPool').mockResolvedValue(0);
|
||||
});
|
||||
|
||||
it('ignores non-export schedules', async () => {
|
||||
trainSchedulesRepository.findById.mockResolvedValue({
|
||||
...exportSchedule,
|
||||
direction: 'IMPORT',
|
||||
});
|
||||
|
||||
await service.expireLeftoverExportDay(scheduleId);
|
||||
|
||||
expect(unacceptedSpy).not.toHaveBeenCalled();
|
||||
expect(poolSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('defers while another export train on the day can still take bookings', async () => {
|
||||
trainSchedulesRepository.findById.mockResolvedValue(exportSchedule);
|
||||
trainSchedulesRepository.findAll.mockResolvedValue([
|
||||
exportSchedule,
|
||||
{
|
||||
...exportSchedule,
|
||||
id: 'sched-2',
|
||||
windowPhase: 'OPEN',
|
||||
bookingWindowStatus: 'OPEN',
|
||||
},
|
||||
]);
|
||||
|
||||
await service.expireLeftoverExportDay(scheduleId);
|
||||
|
||||
expect(unacceptedSpy).not.toHaveBeenCalled();
|
||||
expect(poolSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('defers while a FULL train still has live pay windows', async () => {
|
||||
trainSchedulesRepository.findById.mockResolvedValue(exportSchedule);
|
||||
trainSchedulesRepository.findAll.mockResolvedValue([
|
||||
exportSchedule,
|
||||
{
|
||||
...exportSchedule,
|
||||
id: 'sched-2',
|
||||
windowPhase: 'OPEN',
|
||||
bookingWindowStatus: 'FULL',
|
||||
},
|
||||
]);
|
||||
bookingsRepository.findReservedForSchedule.mockResolvedValue([
|
||||
{
|
||||
paymentStatus: 'PENDING',
|
||||
status: 'AWAITING_PAYMENT',
|
||||
paymentDeadline: new Date(Date.now() + 60_000),
|
||||
},
|
||||
]);
|
||||
|
||||
await service.expireLeftoverExportDay(scheduleId);
|
||||
|
||||
expect(unacceptedSpy).not.toHaveBeenCalled();
|
||||
expect(poolSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sweeps un-accepted + waiting bookings once every train on the day is shut', async () => {
|
||||
trainSchedulesRepository.findById.mockResolvedValue(exportSchedule);
|
||||
trainSchedulesRepository.findAll.mockResolvedValue([
|
||||
exportSchedule,
|
||||
{
|
||||
...exportSchedule,
|
||||
id: 'sched-2',
|
||||
windowPhase: 'OPEN',
|
||||
bookingWindowStatus: 'FULL',
|
||||
},
|
||||
]);
|
||||
|
||||
await service.expireLeftoverExportDay(scheduleId);
|
||||
|
||||
expect(unacceptedSpy).toHaveBeenCalledWith({
|
||||
originYardId: 'yard-origin',
|
||||
destinationYardId: 'yard-dest',
|
||||
day: '2026-06-20',
|
||||
});
|
||||
expect(poolSpy).toHaveBeenCalledWith(scheduleId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('maybeOfferPartial — split-eligibility gate', () => {
|
||||
const importGeneral = {
|
||||
id: 'b1',
|
||||
@@ -817,6 +915,122 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('acceptIntercity — export pay window expires at window close', () => {
|
||||
const exportScheduleId = 'export-train';
|
||||
// Window closes in 30 minutes; the configured pay window is 60 minutes.
|
||||
const closesAt = new Date(Date.now() + 30 * 60_000);
|
||||
|
||||
const waiting = {
|
||||
id: 'ic-1',
|
||||
reference: 'IC-1',
|
||||
isGovernment: false,
|
||||
status: 'FULLY_EXECUTED',
|
||||
trainScheduleId: null,
|
||||
freightType: 'CONTAINER',
|
||||
cargoTotalWeightVgm: 10,
|
||||
bookingContainers: [],
|
||||
} as unknown as Booking;
|
||||
|
||||
let scheduleRepo: { findOne: jest.Mock };
|
||||
let bookingRepo: { findOne: jest.Mock; update: jest.Mock; find: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
bookingRepo = dataSource.getRepository();
|
||||
bookingRepo.findOne.mockResolvedValue(waiting);
|
||||
scheduleRepo = { findOne: jest.fn() };
|
||||
// reserve() reads the target schedule to clamp export deadlines — route
|
||||
// TrainSchedule reads to their own repo, everything else stays as before.
|
||||
dataSource.getRepository.mockImplementation((entity?: { name?: string }) =>
|
||||
entity?.name === 'TrainSchedule' ? scheduleRepo : bookingRepo,
|
||||
);
|
||||
});
|
||||
|
||||
it('clamps the intercity pay deadline to the export window close', async () => {
|
||||
scheduleRepo.findOne.mockResolvedValue({
|
||||
id: exportScheduleId,
|
||||
direction: 'EXPORT',
|
||||
windowClosesAt: closesAt,
|
||||
scheduledDepartureDate: new Date(closesAt.getTime() + 2 * 3_600_000),
|
||||
});
|
||||
|
||||
await service.acceptIntercity(waiting, exportScheduleId);
|
||||
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||
'ic-1',
|
||||
expect.objectContaining({
|
||||
status: 'SELECTED_FOR_BATCH',
|
||||
paymentDeadline: closesAt,
|
||||
}),
|
||||
);
|
||||
expect(notifier.payNow).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps the plain payment window on import trains', async () => {
|
||||
scheduleRepo.findOne.mockResolvedValue({
|
||||
id: 'import-train',
|
||||
direction: 'IMPORT',
|
||||
windowClosesAt: closesAt,
|
||||
});
|
||||
|
||||
await service.acceptIntercity(waiting, 'import-train');
|
||||
|
||||
const deadline = (
|
||||
bookingsRepository.update.mock.calls[0][1] as { paymentDeadline: Date }
|
||||
).paymentDeadline;
|
||||
// 60-minute pay window runs past the 30-minutes-out close: no clamp.
|
||||
expect(deadline.getTime()).toBeGreaterThan(closesAt.getTime());
|
||||
});
|
||||
|
||||
it('rejects an accept after the export window closed — no pay window opens', async () => {
|
||||
scheduleRepo.findOne.mockResolvedValue({
|
||||
id: exportScheduleId,
|
||||
direction: 'EXPORT',
|
||||
windowClosesAt: new Date(Date.now() - 60_000),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.acceptIntercity(waiting, exportScheduleId),
|
||||
).rejects.toThrow(/window has closed/);
|
||||
expect(bookingsRepository.update).not.toHaveBeenCalled();
|
||||
expect(notifier.payNow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('expires an unpaid export ride-along at close and frees the train', async () => {
|
||||
const lapsed = {
|
||||
...(waiting as unknown as Record<string, unknown>),
|
||||
status: 'SELECTED_FOR_BATCH',
|
||||
trainScheduleId: exportScheduleId,
|
||||
paymentDeadline: new Date(Date.now() - 1_000),
|
||||
originYardId: 'yard-a',
|
||||
destinationYardId: 'yard-b',
|
||||
priorityScore: 0,
|
||||
wagonsRequired: 1,
|
||||
} as unknown as Booking;
|
||||
bookingsRepository.findReservedForSchedule
|
||||
.mockResolvedValueOnce([lapsed])
|
||||
.mockResolvedValue([]);
|
||||
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([]);
|
||||
// expire()'s paid-guard re-reads the booking fresh — still unpaid.
|
||||
bookingRepo.findOne.mockResolvedValue(lapsed);
|
||||
trainSchedulesRepository.findById.mockResolvedValue({
|
||||
id: exportScheduleId,
|
||||
bookingWindowStatus: 'CLOSED',
|
||||
windowPhase: 'DONE',
|
||||
scheduledDepartureDate: new Date(Date.now() + 3_600_000),
|
||||
originStationId: 'yard-a',
|
||||
destinationStationId: 'yard-b',
|
||||
});
|
||||
|
||||
await service.settleDueReservations(exportScheduleId);
|
||||
|
||||
expect(notifier.expired).toHaveBeenCalledTimes(1);
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||
'ic-1',
|
||||
expect.objectContaining({ status: 'EXPIRED', trainScheduleId: null }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('BookingBatchService — wagonsFor', () => {
|
||||
@@ -973,6 +1187,7 @@ describe('BookingBatchService — built-train wagon capacity', () => {
|
||||
reserved: Booking[];
|
||||
maxWagons?: number;
|
||||
routeStops?: string[];
|
||||
yardCountries?: Record<string, string>;
|
||||
}) => {
|
||||
const schedule = {
|
||||
id: scheduleId,
|
||||
@@ -1004,10 +1219,21 @@ describe('BookingBatchService — built-train wagon capacity', () => {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const yardRepo = {
|
||||
find: jest
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
Object.entries(opts.yardCountries ?? {}).map(([id, country]) => ({
|
||||
id,
|
||||
country,
|
||||
})),
|
||||
),
|
||||
};
|
||||
const dataSource = {
|
||||
getRepository: jest.fn((entity: { name?: string }) => {
|
||||
if (entity?.name === 'Wagon') return wagonRepo;
|
||||
if (entity?.name === 'RouteMilestone') return milestoneRepo;
|
||||
if (entity?.name === 'Yard') return yardRepo;
|
||||
return genericRepo;
|
||||
}),
|
||||
transaction: jest.fn(),
|
||||
@@ -1050,11 +1276,11 @@ describe('BookingBatchService — built-train wagon capacity', () => {
|
||||
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('is FULL when sub-leg bookings hold every physical wagon of a milestone route', async () => {
|
||||
// Regression: 50 wagons sold Negad→Mojo on a Doraleh→…→Dire Dawa corridor
|
||||
// left the pass-through edges reading "free" in the per-edge budget, so the
|
||||
// full train's window cycled OPEN forever and the day pool never expired.
|
||||
// A wagon is committed for the whole trip — leg-free edges are not capacity.
|
||||
it('is NOT full when only a middle leg is sold and other edges run free (domestic route)', async () => {
|
||||
// Leg-aware allocation (planWagonsWithStock legs) made mid-leg wagons real
|
||||
// capacity on the edges they don't ride: a domestic corridor with cargo
|
||||
// only on m1→m2 still boards bookings on the free first/last edges, so the
|
||||
// window must stay open for them.
|
||||
const { service } = buildService({
|
||||
physicalWagons: 2,
|
||||
routeStops: ['yard-a', 'yard-m1', 'yard-m2', 'yard-b'],
|
||||
@@ -1063,9 +1289,48 @@ describe('BookingBatchService — built-train wagon capacity', () => {
|
||||
reservedBooking('b2', { origin: 'yard-m1', dest: 'yard-m2' }),
|
||||
],
|
||||
});
|
||||
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('is FULL for the trade direction once the border edge is sold out, even with home legs free', async () => {
|
||||
// Export b→c holds every wagon of the border crossing: no further export
|
||||
// can board anywhere (they all must ride that edge), so the window closes —
|
||||
// while intercity keeps booking the free a→b leg through the per-leg budget.
|
||||
const { service } = buildService({
|
||||
physicalWagons: 2,
|
||||
routeStops: ['yard-a', 'yard-b', 'yard-dj'],
|
||||
yardCountries: {
|
||||
'yard-a': 'ETHIOPIA',
|
||||
'yard-b': 'ETHIOPIA',
|
||||
'yard-dj': 'DJIBOUTI',
|
||||
},
|
||||
reserved: [
|
||||
reservedBooking('b1', { origin: 'yard-b', dest: 'yard-dj' }),
|
||||
reservedBooking('b2', { origin: 'yard-b', dest: 'yard-dj' }),
|
||||
],
|
||||
});
|
||||
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('is NOT full while the border edge still has room, even with a home leg sold out', async () => {
|
||||
// Intercity rode a→b on both wagons; the border edge b→dj is still free,
|
||||
// so exports can still board — the window stays open.
|
||||
const { service } = buildService({
|
||||
physicalWagons: 2,
|
||||
routeStops: ['yard-a', 'yard-b', 'yard-dj'],
|
||||
yardCountries: {
|
||||
'yard-a': 'ETHIOPIA',
|
||||
'yard-b': 'ETHIOPIA',
|
||||
'yard-dj': 'DJIBOUTI',
|
||||
},
|
||||
reserved: [
|
||||
reservedBooking('b1', { origin: 'yard-a', dest: 'yard-b' }),
|
||||
reservedBooking('b2', { origin: 'yard-a', dest: 'yard-b' }),
|
||||
],
|
||||
});
|
||||
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('reports over-allocation when the consist is trimmed below committed bookings', async () => {
|
||||
const { service } = buildService({
|
||||
physicalWagons: 1,
|
||||
|
||||
@@ -24,9 +24,9 @@ import {
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||
import { formatRouteLabel } from '../routes/entities/route.entity';
|
||||
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||
@@ -60,11 +60,14 @@ import {
|
||||
DEFAULT_WAGONS_PER_BOOKING,
|
||||
} from "./booking-batch.constants";
|
||||
import {
|
||||
LocomotiveLimits,
|
||||
WagonTypeDimensions,
|
||||
bookingCargoTons,
|
||||
bookingGrossWeightTons,
|
||||
deriveTrainCapacityFromLocomotive,
|
||||
sizePartialOfferWagons,
|
||||
trainHardCaps,
|
||||
trainSetLocomotiveLimits,
|
||||
wagonTypeDimensionsFromEntity,
|
||||
} from './train-capacity.util';
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
@@ -570,6 +573,10 @@ export class BookingBatchService implements OnModuleInit {
|
||||
);
|
||||
if (schedule && (await this.isTrainFull(schedule))) {
|
||||
await this.setWindow(booking.trainScheduleId, "FULL");
|
||||
// This payment may have been the last live pay window on a now-full
|
||||
// export day — the settle that normally re-runs the sweep finds nothing
|
||||
// left to settle, so trigger it here.
|
||||
void this.expireLeftoverExportDay(booking.trainScheduleId);
|
||||
}
|
||||
|
||||
const result = await this.trainSchedulingService.tryAutoWagonAllocation(
|
||||
@@ -673,7 +680,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||
candidate.id,
|
||||
);
|
||||
const locomotive = schedule?.trainSet?.locomotive;
|
||||
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
|
||||
if (!schedule || !locomotive) continue;
|
||||
const limits = await this.capacityLimits(locomotive);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||
@@ -821,7 +828,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||
candidate.id,
|
||||
);
|
||||
const locomotive = schedule?.trainSet?.locomotive;
|
||||
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
|
||||
if (!schedule || !locomotive) continue;
|
||||
const limits = await this.capacityLimits(locomotive);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||
@@ -891,7 +898,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||
candidate.id,
|
||||
);
|
||||
const locomotive = schedule?.trainSet?.locomotive;
|
||||
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
|
||||
if (!schedule || !locomotive) continue;
|
||||
const limits = await this.capacityLimits(locomotive);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||
@@ -933,7 +940,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||
target.scheduleId,
|
||||
);
|
||||
const locomotive = schedule?.trainSet?.locomotive;
|
||||
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
|
||||
if (!schedule || !locomotive) return false;
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const limits = await this.capacityLimits(locomotive);
|
||||
@@ -1030,7 +1037,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
const schedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
const locomotive = schedule?.trainSet?.locomotive;
|
||||
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
|
||||
if (!schedule || !locomotive) {
|
||||
throw new ConflictException(
|
||||
"Export train is no longer available for reservation",
|
||||
@@ -1347,7 +1354,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
};
|
||||
});
|
||||
|
||||
const loco = s.trainSet?.locomotive ?? null;
|
||||
const loco = trainSetLocomotiveLimits(s.trainSet);
|
||||
|
||||
// The board renders ONE booking window — the schedule's own frozen window
|
||||
// (windowOpensAt/windowClosesAt + phase deadlines returned below). Bookings
|
||||
@@ -1407,10 +1414,12 @@ export class BookingBatchService implements OnModuleInit {
|
||||
trainName: s.trainSet.train.trainName ?? null,
|
||||
}
|
||||
: null,
|
||||
// Identity from the primary (legacy) locomotive; limit figures from the
|
||||
// whole set's effective minimum — what the fill engine actually spends.
|
||||
locomotive: loco
|
||||
? {
|
||||
code: loco.code,
|
||||
name: loco.name ?? null,
|
||||
code: s.trainSet?.locomotive?.code ?? '',
|
||||
name: s.trainSet?.locomotive?.name ?? null,
|
||||
maxPullWeightTons: Number(loco.maxPullWeightTons),
|
||||
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
||||
}
|
||||
@@ -1460,7 +1469,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
weightTons: number;
|
||||
lengthMeters: number;
|
||||
}>,
|
||||
loco: Locomotive | null,
|
||||
loco: LocomotiveLimits | null,
|
||||
maxWagons: number | null,
|
||||
): BatchBoardSchedule["capacity"] {
|
||||
const allocated = items.filter((i) => i.state === "ALLOCATED");
|
||||
@@ -1495,7 +1504,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
s: TrainSchedule,
|
||||
items: BatchBoardBooking[],
|
||||
): BatchBoardSchedule {
|
||||
const loco = s.trainSet?.locomotive ?? null;
|
||||
const loco = trainSetLocomotiveLimits(s.trainSet);
|
||||
|
||||
return {
|
||||
scheduleId: s.id,
|
||||
@@ -1527,10 +1536,12 @@ export class BookingBatchService implements OnModuleInit {
|
||||
trainName: s.trainSet.train.trainName ?? null,
|
||||
}
|
||||
: null,
|
||||
// Identity from the primary (legacy) locomotive; limit figures from the
|
||||
// whole set's effective minimum — what the fill engine actually spends.
|
||||
locomotive: loco
|
||||
? {
|
||||
code: loco.code,
|
||||
name: loco.name ?? null,
|
||||
code: s.trainSet?.locomotive?.code ?? '',
|
||||
name: s.trainSet?.locomotive?.name ?? null,
|
||||
maxPullWeightTons: Number(loco.maxPullWeightTons),
|
||||
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
||||
}
|
||||
@@ -1596,7 +1607,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const schedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule || !this.isFillable(schedule)) return 0;
|
||||
const locomotive = schedule.trainSet?.locomotive;
|
||||
const locomotive = trainSetLocomotiveLimits(schedule.trainSet);
|
||||
if (!schedule.trainSetId || !locomotive) {
|
||||
this.logger.warn(
|
||||
`Schedule ${scheduleId} has no locomotive/train set — skipped.`,
|
||||
@@ -1823,7 +1834,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
for (const id of scheduleIds) {
|
||||
const schedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(id);
|
||||
const locomotive = schedule?.trainSet?.locomotive;
|
||||
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
|
||||
if (!schedule || !schedule.trainSetId || !locomotive) {
|
||||
this.logger.warn(
|
||||
`Schedule ${id} has no locomotive/train set — skipped.`,
|
||||
@@ -2254,6 +2265,11 @@ export class BookingBatchService implements OnModuleInit {
|
||||
`— payment phase extended for them`,
|
||||
);
|
||||
}
|
||||
// The settle may have resolved the last pay window on a full export day
|
||||
// (paid → allocated, and the top-up found nothing else that fits) — sweep
|
||||
// the date's leftover bookings. Self-guarded: no-op for import/domestic
|
||||
// and while any train on the day can still take bookings.
|
||||
await this.expireLeftoverExportDay(scheduleId);
|
||||
// Emitted here (not in settleDueReservations/settleBatch, which both wrap
|
||||
// this) so one settle produces one push, after every allocation/expiry/
|
||||
// top-up extension for this schedule has been persisted.
|
||||
@@ -2350,6 +2366,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
);
|
||||
if (schedule && (await this.isTrainFull(schedule))) {
|
||||
await this.setWindow(booking.trainScheduleId, "FULL");
|
||||
// Same as the webhook path: a staff mark-paid can settle the last live
|
||||
// pay window on a now-full export day — sweep the date's leftovers.
|
||||
void this.expireLeftoverExportDay(booking.trainScheduleId);
|
||||
}
|
||||
void this.triggerWagonAllocation(booking.trainScheduleId!);
|
||||
this.notifyBoardChanged(booking.trainScheduleId, "booking_marked_paid");
|
||||
@@ -2457,17 +2476,17 @@ export class BookingBatchService implements OnModuleInit {
|
||||
} | null> {
|
||||
const schedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
const locomotive = schedule?.trainSet?.locomotive;
|
||||
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
|
||||
if (!schedule || !locomotive) return null;
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const limits = await this.capacityLimits(locomotive);
|
||||
// Built trains: collapse to a single train-wide pool so the freed capacity of
|
||||
// a booking that alights mid-corridor is NOT re-offered on the pass-through
|
||||
// leg (see remainingBudget). Keeps intercity accept consistent with the
|
||||
// train-wide isTrainFull / committedWagons finalize signal.
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims, {
|
||||
collapseForBuiltTrain: true,
|
||||
});
|
||||
// Built trains use the leg-aware corridor budget too: the wagon planner
|
||||
// consumes stock PER EDGE (planWagonsWithStock legs), so a consist wagon
|
||||
// that runs empty Gelan→Adama genuinely can carry an intercity booking
|
||||
// there before its export cargo boards at Adama. A train full on one leg
|
||||
// still accepts ride-alongs on its empty legs — that is the whole point
|
||||
// of the ride-along flow.
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||
return { budget, needFor: (booking) => this.needFor(booking, wagonDims) };
|
||||
}
|
||||
|
||||
@@ -2526,7 +2545,26 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return;
|
||||
}
|
||||
const now = new Date();
|
||||
const deadline = new Date(now.getTime() + (await this.paymentWindowMs()));
|
||||
let deadline = new Date(now.getTime() + (await this.paymentWindowMs()));
|
||||
// EXPORT parity: pay windows on an export train never outlive its booking
|
||||
// window — export bookings expire at close, so anything reserved onto the
|
||||
// same train (FCFS export or an intercity ride-along) must too. Import
|
||||
// keeps the plain payment window; its cycles re-fill after settle.
|
||||
const targetSchedule = await this.dataSource
|
||||
.getRepository(TrainSchedule)
|
||||
.findOne({ where: { id: scheduleId } });
|
||||
if (targetSchedule?.direction === "EXPORT") {
|
||||
const cutoff =
|
||||
targetSchedule.windowClosesAt ?? targetSchedule.scheduledDepartureDate;
|
||||
if (cutoff && cutoff.getTime() <= now.getTime()) {
|
||||
throw new BadRequestException(
|
||||
"Export booking window has closed — cannot open a pay window on this train",
|
||||
);
|
||||
}
|
||||
if (cutoff && cutoff.getTime() < deadline.getTime()) {
|
||||
deadline = new Date(cutoff);
|
||||
}
|
||||
}
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
trainScheduleId: scheduleId,
|
||||
status: "SELECTED_FOR_BATCH",
|
||||
@@ -2822,6 +2860,60 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return leftovers.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* EXPORT counterpart of the conclude-time sweep. Export has no batch cycle,
|
||||
* so nothing ever concluded its day: bookings still waiting when the trains
|
||||
* filled up or the window closed stayed pending forever. Once every export
|
||||
* train on this route-day is shut — window DONE, or FULL with no pay window
|
||||
* still live that could lapse and free space — the date is dead: expire the
|
||||
* un-accepted bookings staff can no longer accept AND the ready
|
||||
* (FULLY_EXECUTED) bookings that never got a reservation (consolidation
|
||||
* waiters). Runs at export window close and whenever an export train's
|
||||
* fullness settles.
|
||||
*/
|
||||
async expireLeftoverExportDay(scheduleId: string): Promise<void> {
|
||||
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
|
||||
if (schedule?.direction !== "EXPORT" || !schedule.scheduledDepartureDate) {
|
||||
return;
|
||||
}
|
||||
const day = eatDay(schedule.scheduledDepartureDate);
|
||||
const trains = (
|
||||
await this.trainSchedulesRepository.findAll({
|
||||
where: [
|
||||
{
|
||||
originStationId: schedule.originStationId,
|
||||
destinationStationId: schedule.destinationStationId,
|
||||
status: TrainScheduleStatusEnum.Draft,
|
||||
},
|
||||
{
|
||||
originStationId: schedule.originStationId,
|
||||
destinationStationId: schedule.destinationStationId,
|
||||
status: TrainScheduleStatusEnum.Scheduled,
|
||||
},
|
||||
],
|
||||
})
|
||||
).filter(
|
||||
(s) =>
|
||||
s.scheduledDepartureDate != null &&
|
||||
eatDay(s.scheduledDepartureDate) === day,
|
||||
);
|
||||
for (const s of trains) {
|
||||
// Any train still taking bookings keeps the date alive.
|
||||
if (s.windowPhase !== "DONE" && s.bookingWindowStatus !== "FULL") return;
|
||||
// A FULL train whose reservations are still inside their pay windows can
|
||||
// reopen when one lapses unpaid — defer; the settle re-runs this sweep.
|
||||
if (s.windowPhase !== "DONE" && (await this.hasLiveReservations(s.id))) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await this.expireUnacceptedForRouteDay({
|
||||
originYardId: schedule.originStationId,
|
||||
destinationYardId: schedule.destinationStationId,
|
||||
day,
|
||||
});
|
||||
await this.expireLeftoverDayPool(scheduleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Union of stop yards across the day's fillable schedules on this corridor —
|
||||
* the same pool scope fillRouteDay uses, so full-route AND sub-corridor bookings
|
||||
@@ -3029,8 +3121,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const containers = (b: Booking): number =>
|
||||
(b.bookingContainers ?? []).reduce((sum, c) => sum + Number(c.quantity ?? 0), 0);
|
||||
const totalContainers = containers(primary) + containers(partner);
|
||||
const cargoTons =
|
||||
Number(primary.cargoTotalWeightVgm ?? 0) + Number(partner.cargoTotalWeightVgm ?? 0);
|
||||
const cargoTons = bookingCargoTons(primary) + bookingCargoTons(partner);
|
||||
|
||||
// Consolidation shares TEU slots, never rated payload: the pair still needs
|
||||
// enough wagons to carry its combined cargo, so the weight axis bounds the
|
||||
@@ -3137,7 +3228,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const byLength = containerWagonsForLines(booking.bookingContainers ?? []);
|
||||
|
||||
const capacityTons = this.dimsFor(booking, wagonDims).capacityTons;
|
||||
const cargoTons = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
const cargoTons = bookingCargoTons(booking);
|
||||
const byWeight =
|
||||
cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0;
|
||||
|
||||
@@ -3158,7 +3249,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return {
|
||||
wagons,
|
||||
weightTons: bookingGrossWeightTons(
|
||||
Number(booking.cargoTotalWeightVgm ?? 0),
|
||||
bookingCargoTons(booking),
|
||||
wagons,
|
||||
dims.tareWeightTons,
|
||||
),
|
||||
@@ -3185,7 +3276,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
* caps deliberately do not apply here (a mis-set global row once capped
|
||||
* every train at 14m and no export booking could board).
|
||||
*/
|
||||
private async capacityLimits(locomotive: Locomotive): Promise<TrainLimits> {
|
||||
private async capacityLimits(locomotive: LocomotiveLimits): Promise<TrainLimits> {
|
||||
const wagonTypes = await this.loadWagonTypeDimensions();
|
||||
const derived = deriveTrainCapacityFromLocomotive(
|
||||
{
|
||||
@@ -3219,7 +3310,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
*/
|
||||
private async syncScheduleMaxWagons(
|
||||
schedule: TrainSchedule,
|
||||
locomotive: Locomotive,
|
||||
locomotive: LocomotiveLimits,
|
||||
): Promise<void> {
|
||||
const physicalWagons = await this.builtTrainWagonCount(schedule);
|
||||
const maxWagons =
|
||||
@@ -3398,7 +3489,6 @@ export class BookingBatchService implements OnModuleInit {
|
||||
schedule: TrainSchedule,
|
||||
limits: TrainLimits,
|
||||
wagonDims: WagonDims,
|
||||
opts?: { collapseForBuiltTrain?: boolean },
|
||||
): Promise<CorridorBudget> {
|
||||
const physicalWagons = await this.builtTrainWagonCount(schedule);
|
||||
if (physicalWagons != null) {
|
||||
@@ -3411,21 +3501,10 @@ export class BookingBatchService implements OnModuleInit {
|
||||
tolerance: { weightTons: 0, lengthMeters: 0 },
|
||||
};
|
||||
}
|
||||
// A built train's wagons are coupled for the WHOLE trip, and the allocator
|
||||
// commits each booking to a wagon for the entire route — it never reloads a
|
||||
// wagon at a mid-corridor alight yard. So a built train has no leg concept:
|
||||
// its capacity is one train-wide pool, exactly as isTrainFull /
|
||||
// committedWagons already count it. When a caller opts in, collapse the
|
||||
// corridor to a single whole-route edge so every booking (full-route OR
|
||||
// mid-corridor) draws from that one pool — a train full of import-to-DireDawa
|
||||
// then correctly shows NO room for a DireDawa->Addis intercity booking on the
|
||||
// leg it merely passes through, instead of over-promising the freed slots.
|
||||
// Locomotive-derived schedules keep the leg-aware multi-edge corridor: their
|
||||
// abstract slot/weight/length budget genuinely frees past an alight yard.
|
||||
const stops =
|
||||
physicalWagons != null && opts?.collapseForBuiltTrain
|
||||
? [schedule.originStationId, schedule.destinationStationId]
|
||||
: await this.stopsForSchedule(schedule);
|
||||
// Built trains keep the leg-aware multi-edge corridor too: the wagon
|
||||
// planner consumes stock per edge (planWagonsWithStock legs), so a consist
|
||||
// wagon serves disjoint legs — capacity freed past an alight yard is real.
|
||||
const stops = await this.stopsForSchedule(schedule);
|
||||
const budget = new CorridorBudget(stops, limits.base, limits.tolerance);
|
||||
const allocated = (schedule.scheduleBookings ?? [])
|
||||
.map((sb) => sb.booking)
|
||||
@@ -3532,14 +3611,14 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
|
||||
/**
|
||||
* Built train: FULL when every physical wagon slot is taken — the consist is
|
||||
* the capacity, weight/length were settled at build time.
|
||||
* No built train: FULL on ANY capacity axis — out of wagon slots, or out of
|
||||
* pull weight / train length for even one more loaded wagon. The old
|
||||
* slot-only check let a weight-bound train (PW2: weight binds at 37 wagons =
|
||||
* 3522.4T of 3500+90T, slots bind at 44) cycle its booking window forever
|
||||
* instead of finalizing — 7 phantom slots kept it "not full" while nothing
|
||||
* could board.
|
||||
* FULL is DIRECTIONAL: the schedule's trade direction is full when the
|
||||
* border-crossing edge (which every export/import must ride) can't take one
|
||||
* more minimal wagon on any axis — slots for built trains (the consist is
|
||||
* the capacity, weight/length settled at build), all three axes otherwise
|
||||
* (PW2: weight binds at 37 wagons = 3522.4T of 3500+90T, slots bind at 44).
|
||||
* Home-side legs may still run empty; intercity ride-alongs keep filling
|
||||
* them via the per-leg budget and never consult this flag. Domestic routes
|
||||
* (no border) are full only when every edge is closed.
|
||||
*/
|
||||
async isScheduleFull(scheduleId: string): Promise<boolean> {
|
||||
const schedule =
|
||||
@@ -3590,48 +3669,69 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
/** See {@link isScheduleFull} — same check for callers that already hold the full graph. */
|
||||
private async isTrainFull(schedule: TrainSchedule): Promise<boolean> {
|
||||
// Built train: the physical consist is the only capacity axis, and a wagon
|
||||
// is committed to its booking for the WHOLE trip — wagon allocation has no
|
||||
// leg concept, so a wagon hauling Negad→Mojo cargo can never be re-sold for
|
||||
// the Doraleh→Negad edge it merely passes through. Count commitments
|
||||
// train-wide, not per corridor edge: the per-edge budget read "free slots"
|
||||
// on pass-through legs of a sold-out consist, so the window of a full train
|
||||
// cycled OPEN forever instead of concluding DONE (and the day pool's
|
||||
// leftover bookings were never expired).
|
||||
const physicalWagons = await this.builtTrainWagonCount(schedule);
|
||||
if (physicalWagons != null) {
|
||||
return (await this.committedWagons(schedule)) >= physicalWagons;
|
||||
}
|
||||
if ((await this.remainingWagons(schedule)) <= 0) return true;
|
||||
const locomotive = schedule.trainSet?.locomotive;
|
||||
if (!locomotive) return false; // no weight/length limits to bind against
|
||||
// "Full" means full FOR THE TRAIN'S TRADE DIRECTION. Every export and
|
||||
// every import must cross the ET↔DJ border edge, so once that edge can't
|
||||
// take one more minimal wagon the booking window may close — even while
|
||||
// home-side legs still run empty. Intercity ride-alongs never consult this
|
||||
// flag; they keep booking the free legs through the per-leg budget.
|
||||
// A single-country (domestic) corridor has no mandatory edge, so it is
|
||||
// full only when EVERY edge is closed on some axis.
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const limits = await this.capacityLimits(locomotive);
|
||||
const physicalWagons = await this.builtTrainWagonCount(schedule);
|
||||
let limits: TrainLimits;
|
||||
if (physicalWagons != null) {
|
||||
// The consist is the capacity; weight/length were settled at build time.
|
||||
// remainingBudget swaps in the physical wagon count per edge itself.
|
||||
limits = {
|
||||
base: {
|
||||
wagons: physicalWagons,
|
||||
weightTons: Number.POSITIVE_INFINITY,
|
||||
lengthMeters: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
tolerance: { weightTons: 0, lengthMeters: 0 },
|
||||
};
|
||||
} else {
|
||||
const locomotive = trainSetLocomotiveLimits(schedule.trainSet);
|
||||
// No loco, no built train: only the slot axis exists to bind against.
|
||||
if (!locomotive) return (await this.remainingWagons(schedule)) <= 0;
|
||||
limits = await this.capacityLimits(locomotive);
|
||||
}
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||
return budget.isExhausted(this.minPerWagonNeed(wagonDims));
|
||||
const minNeed = this.minPerWagonNeed(wagonDims);
|
||||
const border = await this.borderLeg(budget.stops);
|
||||
if (border) {
|
||||
return !budget.fits(
|
||||
{
|
||||
wagons: 1,
|
||||
weightTons: minNeed.grossWeightTons,
|
||||
lengthMeters: minNeed.lengthMeters,
|
||||
},
|
||||
border,
|
||||
);
|
||||
}
|
||||
return budget.isExhausted(minNeed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wagons the schedule's allocated + reserved bookings occupy train-wide,
|
||||
* regardless of which corridor leg each rides. Deduped by booking id — a
|
||||
* booking mid-settle can momentarily be both linked and reserved.
|
||||
* The corridor's single border-crossing edge (last home-country stop → first
|
||||
* far-country stop), or null when every stop is in one country. This is the
|
||||
* edge every EXPORT and IMPORT booking must ride, whichever sub-corridor it
|
||||
* books — which makes it the train's directional fullness gauge.
|
||||
*/
|
||||
private async committedWagons(schedule: TrainSchedule): Promise<number> {
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const allocated = (schedule.scheduleBookings ?? [])
|
||||
.map((sb) => sb.booking)
|
||||
.filter((b): b is Booking => Boolean(b));
|
||||
const reserved = await this.bookingsRepository.findReservedForSchedule(
|
||||
schedule.id,
|
||||
);
|
||||
const byId = new Map(
|
||||
[...allocated, ...reserved].map((b) => [b.id, b] as const),
|
||||
);
|
||||
let total = 0;
|
||||
for (const booking of byId.values()) {
|
||||
total += this.wagonsFor(booking, wagonDims);
|
||||
}
|
||||
return total;
|
||||
private async borderLeg(stops: string[]): Promise<CorridorLeg | null> {
|
||||
if (stops.length < 2) return null;
|
||||
const yards = await this.dataSource
|
||||
.getRepository(Yard)
|
||||
.find({ where: { id: In(stops) } });
|
||||
const countryOf = new Map(yards.map((y) => [y.id, y.country]));
|
||||
const first = countryOf.get(stops[0]);
|
||||
if (!first) return null;
|
||||
const crossIdx = stops.findIndex((id) => {
|
||||
const country = countryOf.get(id);
|
||||
return country != null && country !== first;
|
||||
});
|
||||
if (crossIdx <= 0) return null;
|
||||
return { fromEdge: crossIdx - 1, toEdge: crossIdx };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,6 +20,7 @@ describe('BookingWindowService — window state machine', () => {
|
||||
hasLiveReservations: jest.Mock;
|
||||
refreshWindowStatus: jest.Mock;
|
||||
expireLeftoverDayPool: jest.Mock;
|
||||
expireLeftoverExportDay: jest.Mock;
|
||||
fillFromWaitingList: jest.Mock;
|
||||
};
|
||||
let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock };
|
||||
@@ -75,6 +76,7 @@ describe('BookingWindowService — window state machine', () => {
|
||||
hasLiveReservations: jest.fn().mockResolvedValue(false),
|
||||
refreshWindowStatus: jest.fn().mockResolvedValue(undefined),
|
||||
expireLeftoverDayPool: jest.fn().mockResolvedValue(0),
|
||||
expireLeftoverExportDay: jest.fn().mockResolvedValue(undefined),
|
||||
// No waiting booking fits by default, so conclude proceeds to reopen/DONE.
|
||||
fillFromWaitingList: jest.fn().mockResolvedValue(0),
|
||||
};
|
||||
|
||||
@@ -229,6 +229,11 @@ export class BookingWindowService implements OnModuleInit {
|
||||
await this.bookingBatchService.setWindow(schedule.id, 'CLOSED');
|
||||
schedule.bookingWindowStatus = 'CLOSED';
|
||||
}
|
||||
// Export has no conclude step: this close is the last moment the day's
|
||||
// bookings could have boarded. Once every train on the route-day is
|
||||
// shut, expire what is still waiting for this date (the sweep defers
|
||||
// while a sibling train stays open).
|
||||
await this.bookingBatchService.expireLeftoverExportDay(schedule.id);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class MoveWagonLoadDto {
|
||||
/**
|
||||
* Where the source wagon's whole load goes: a train-set wagon slot (empty →
|
||||
* move, loaded → swap the two loads) or an empty consist-only physical wagon
|
||||
* of the built train (→ the slot repins onto it).
|
||||
*/
|
||||
@IsUUID()
|
||||
targetWagonId!: string;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { bookingCargoTons } from './train-capacity.util';
|
||||
import type { Booking } from '../bookings/entities/booking.entity';
|
||||
import type { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import {
|
||||
@@ -187,5 +188,5 @@ export function summarizeFleetWarnings(
|
||||
}
|
||||
|
||||
export function totalAssignedWeight(bookings: Booking[]): number {
|
||||
return roundTons(bookings.reduce((sum, b) => sum + Number(b.cargoTotalWeightVgm ?? 0), 0));
|
||||
return roundTons(bookings.reduce((sum, b) => sum + bookingCargoTons(b), 0));
|
||||
}
|
||||
|
||||
@@ -146,10 +146,8 @@ export class IntercityService {
|
||||
remaining: capacity?.budget.maxRemaining() ?? null,
|
||||
candidates: waiting.map((booking) => {
|
||||
const need = capacity?.needFor(booking) ?? null;
|
||||
// legForYards, not legOf: on a built train the budget is a single
|
||||
// whole-route edge (see intercityCapacity), so a mid-corridor booking
|
||||
// must draw from that one pool via the whole-route fallback. On a
|
||||
// locomotive-derived schedule it still resolves to the booking's own leg.
|
||||
// legForYards: the booking draws only from ITS OWN leg's edges, with a
|
||||
// whole-route fallback when its yards aren't on the budget's stop list.
|
||||
const leg = capacity?.budget.legForYards(
|
||||
booking.originYardId,
|
||||
booking.destinationYardId,
|
||||
@@ -215,11 +213,8 @@ export class IntercityService {
|
||||
continue;
|
||||
}
|
||||
const need = capacity.needFor(booking);
|
||||
// legForYards, not legOf: a built train's budget is a single whole-route
|
||||
// pool (mid-corridor wagons are committed for the whole trip and never
|
||||
// reloaded), so the booking draws from that pool via the whole-route
|
||||
// fallback; a locomotive-derived schedule still gets the booking's own
|
||||
// leg, so it can still board a train that is full only on other legs.
|
||||
// legForYards: charge only the edges this booking rides, so it can still
|
||||
// board a train that is full only on other legs.
|
||||
const leg = budget.legForYards(booking.originYardId, booking.destinationYardId);
|
||||
if (!budget.fits(need, leg)) {
|
||||
rejected.push({
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
grossWagonWeightTons,
|
||||
minLocomotiveLimits,
|
||||
sizePartialOfferWagons,
|
||||
trainSetLocomotiveLimits,
|
||||
} from './train-capacity.util';
|
||||
|
||||
describe('train-capacity.util', () => {
|
||||
@@ -205,6 +206,37 @@ describe('train-capacity.util', () => {
|
||||
expect(limits?.overageToleranceTons).toBe(20);
|
||||
});
|
||||
|
||||
it('ignores unconfigured (null) tolerances instead of zeroing the set (S-2026-00024)', () => {
|
||||
// LOCO-019 had 90T tolerance, LOCO-020 had none configured: the set must
|
||||
// keep the 90, not collapse to 0 and reject 3547.6T on a 3500T train.
|
||||
const limits = minLocomotiveLimits([
|
||||
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
|
||||
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: null },
|
||||
]);
|
||||
expect(limits?.overageToleranceTons).toBe(90);
|
||||
// All unconfigured → no tolerance.
|
||||
const none = minLocomotiveLimits([
|
||||
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760 },
|
||||
]);
|
||||
expect(none?.overageToleranceTons).toBe(0);
|
||||
});
|
||||
|
||||
it('trainSetLocomotiveLimits prefers link rows and falls back to the legacy single loco', () => {
|
||||
const l1 = { maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 };
|
||||
const l2 = { maxPullWeightTons: 3600, maxTrainLengthMeters: 700, overageToleranceTons: null };
|
||||
expect(
|
||||
trainSetLocomotiveLimits({ locomotive: null, locomotives: [{ locomotive: l1 }, { locomotive: l2 }] }),
|
||||
).toEqual({
|
||||
maxPullWeightTons: 3500,
|
||||
maxTrainLengthMeters: 700,
|
||||
overageToleranceTons: 90,
|
||||
overageToleranceMeters: 0,
|
||||
});
|
||||
expect(trainSetLocomotiveLimits({ locomotive: l1 })?.maxPullWeightTons).toBe(3500);
|
||||
expect(trainSetLocomotiveLimits(null)).toBeNull();
|
||||
expect(trainSetLocomotiveLimits({ locomotive: null, locomotives: [] })).toBeNull();
|
||||
});
|
||||
|
||||
describe('sizePartialOfferWagons', () => {
|
||||
it('sizes a bulk split by the WEIGHT axis when the pull limit binds, not wagon slots', () => {
|
||||
// The 3500T-train scenario: two 1000T bookings boarded gross (each 15 PW2
|
||||
|
||||
@@ -86,6 +86,27 @@ function num(value: unknown, fallback = 0): number {
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cargo tons of a booking: the stored VGM total when present, else the sum of
|
||||
* its container lines (quantity × VGM per unit). The portal's container flow
|
||||
* stores per-line VGM and leaves `cargoTotalWeightVgm` at 0 — reading the
|
||||
* total alone made every such booking weigh only its tare.
|
||||
*/
|
||||
export function bookingCargoTons(booking: {
|
||||
cargoTotalWeightVgm?: number | string | null;
|
||||
bookingContainers?: Array<{
|
||||
quantity?: number | null;
|
||||
vgmPerUnitTons?: number | string | null;
|
||||
}> | null;
|
||||
}): number {
|
||||
const total = num(booking.cargoTotalWeightVgm);
|
||||
if (total > 0) return total;
|
||||
return (booking.bookingContainers ?? []).reduce(
|
||||
(sum, line) => sum + num(line.quantity) * num(line.vgmPerUnitTons),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
/** Gross weight of one loaded wagon: it hauls itself plus its cargo. */
|
||||
export function grossWagonWeightTons(slot: Pick<ConsistSlot, 'tareWeightTons' | 'cargoTons'>): number {
|
||||
return num(slot.tareWeightTons) + num(slot.cargoTons);
|
||||
@@ -253,12 +274,41 @@ export function minLocomotiveLimits(
|
||||
maxTrainLengthMeters: Math.min(
|
||||
...locomotives.map((l) => num(l.maxTrainLengthMeters, Infinity) || Infinity),
|
||||
),
|
||||
// Weakest locomotive's tolerance governs the set, same as its caps.
|
||||
overageToleranceTons: Math.min(...locomotives.map((l) => num(l.overageToleranceTons))),
|
||||
overageToleranceMeters: Math.min(...locomotives.map((l) => num(l.overageToleranceMeters))),
|
||||
// Weakest CONFIGURED tolerance governs the set — a locomotive with no
|
||||
// tolerance set has no opinion, it does not zero out the others.
|
||||
overageToleranceTons: minConfigured(locomotives.map((l) => l.overageToleranceTons)),
|
||||
overageToleranceMeters: minConfigured(locomotives.map((l) => l.overageToleranceMeters)),
|
||||
};
|
||||
}
|
||||
|
||||
function minConfigured(values: Array<number | null | undefined>): number {
|
||||
const configured = values.filter((v) => v != null).map((v) => num(v));
|
||||
return configured.length ? Math.min(...configured) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective limits for a whole train set: min across its linked locomotives,
|
||||
* falling back to the legacy single `locomotive` column for sets created
|
||||
* before multi-loco support. Null when the set has no locomotive at all.
|
||||
*/
|
||||
export function trainSetLocomotiveLimits(
|
||||
trainSet?: {
|
||||
locomotive?: LocomotiveLimits | null;
|
||||
locomotives?: Array<{ locomotive?: LocomotiveLimits | null }> | null;
|
||||
} | null,
|
||||
): LocomotiveLimits | null {
|
||||
if (!trainSet) return null;
|
||||
const linked = (trainSet.locomotives ?? [])
|
||||
.map((link) => link.locomotive)
|
||||
.filter((l): l is LocomotiveLimits => Boolean(l));
|
||||
const pool = linked.length
|
||||
? linked
|
||||
: trainSet.locomotive
|
||||
? [trainSet.locomotive]
|
||||
: [];
|
||||
return minLocomotiveLimits(pool);
|
||||
}
|
||||
|
||||
/** Per-booking train length from wagon count and freight-specific wagon type length. */
|
||||
export function bookingTrainLengthMeters(
|
||||
freightType: string | null | undefined,
|
||||
|
||||
@@ -28,6 +28,7 @@ import { GetEligibleBookingsDto } from "./dto/get-eligible-bookings.dto";
|
||||
import { GetEligibleBulkBookingsDto } from "./dto/get-eligible-bulk-bookings.dto";
|
||||
import { GetEligibleContainerBookingsDto } from "./dto/get-eligible-container-bookings.dto";
|
||||
import { PinWagonsDto } from "./dto/pin-wagons.dto";
|
||||
import { MoveWagonLoadDto } from "./dto/move-wagon-load.dto";
|
||||
import { UpdateContainerItemDto } from "./dto/update-container-item.dto";
|
||||
import { UpdateImportLoadingStatusDto } from "./dto/update-import-loading-status.dto";
|
||||
import { PreviewBulkTrainScheduleDto } from "./dto/preview-bulk-train-schedule.dto";
|
||||
@@ -374,6 +375,20 @@ export class TrainSchedulingController {
|
||||
return this.trainSchedulingService.updateContainerItem(id, itemId, dto);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/wagons/:wagonId/move-load")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Move a wagon's whole load to another wagon (empty → move/repin, loaded → swap loads)",
|
||||
})
|
||||
moveWagonLoad(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("wagonId", ParseUUIDPipe) wagonId: string,
|
||||
@Body() dto: MoveWagonLoadDto,
|
||||
) {
|
||||
return this.trainSchedulingService.moveWagonLoad(id, wagonId, dto);
|
||||
}
|
||||
|
||||
@Get("schedules/:id/unassigned-bookings")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: "Get unassigned bookings for a schedule" })
|
||||
|
||||
@@ -80,7 +80,12 @@ const makeBooking = (
|
||||
|
||||
describe('TrainSchedulingService', () => {
|
||||
let service: TrainSchedulingService;
|
||||
let dataSource: { getRepository: jest.Mock; transaction: jest.Mock; query: jest.Mock };
|
||||
let dataSource: {
|
||||
getRepository: jest.Mock;
|
||||
transaction: jest.Mock;
|
||||
query: jest.Mock;
|
||||
manager: { getRepository: jest.Mock };
|
||||
};
|
||||
let bookingsRepository: Record<string, jest.Mock>;
|
||||
let locomotivesRepository: { findById: jest.Mock; findAll: jest.Mock };
|
||||
let wagonTypesRepository: { findAll: jest.Mock };
|
||||
@@ -91,11 +96,23 @@ describe('TrainSchedulingService', () => {
|
||||
let wagonAllocationBulkLoadsRepository: Record<string, jest.Mock>;
|
||||
|
||||
beforeEach(() => {
|
||||
// findGroupSiblings runs a query builder off dataSource.manager; default it
|
||||
// to "no sibling schedules" so isolated unit tests don't need to wire it.
|
||||
const emptySiblingQb = {
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
dataSource = {
|
||||
getRepository: jest.fn(),
|
||||
transaction: jest.fn(),
|
||||
// Raw-SQL helper lookups (e.g. builtTrainIdOfSchedule) default to "no rows".
|
||||
query: jest.fn().mockResolvedValue([]),
|
||||
manager: {
|
||||
getRepository: jest.fn(() => ({
|
||||
createQueryBuilder: jest.fn(() => emptySiblingQb),
|
||||
})),
|
||||
},
|
||||
};
|
||||
bookingsRepository = {
|
||||
findEligibleForScheduling: jest.fn(),
|
||||
@@ -110,9 +127,11 @@ describe('TrainSchedulingService', () => {
|
||||
findByIdWithFullGraph: jest.fn(),
|
||||
findAll: jest.fn(),
|
||||
updateStatus: jest.fn(),
|
||||
maxReferenceSequence: jest.fn().mockResolvedValue(0),
|
||||
};
|
||||
trainScheduleBookingsRepository = {
|
||||
findByBookingIds: jest.fn(),
|
||||
findByScheduleId: jest.fn().mockResolvedValue([]),
|
||||
createMany: jest.fn(),
|
||||
deleteByScheduleAndBooking: jest.fn(),
|
||||
};
|
||||
@@ -327,7 +346,11 @@ describe('TrainSchedulingService', () => {
|
||||
});
|
||||
|
||||
it('allows preview when bookings are already on the target schedule', async () => {
|
||||
const bookings = [makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20)];
|
||||
// A booking already pinned to the target schedule is exempt from the
|
||||
// corridor/day/status gates — mark it so on the entity, matching the link row.
|
||||
const bookings = [
|
||||
{ ...makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20), trainScheduleId: 'sched-target' },
|
||||
];
|
||||
|
||||
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
|
||||
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
|
||||
@@ -354,7 +377,10 @@ describe('TrainSchedulingService', () => {
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('allows preview when selected bookings are on different schedule dates', async () => {
|
||||
it('flags a booking scheduled for a different day than the train departure', async () => {
|
||||
// The old cross-booking "must share the same schedule date" rule is gone;
|
||||
// the live rule is that every booking must match the departure day. b2
|
||||
// departs a day later, so it's the one flagged.
|
||||
const bookings = [
|
||||
makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20, '2026-06-20T08:00:00.000Z'),
|
||||
makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT', 10, '2026-06-21T14:00:00.000Z'),
|
||||
@@ -375,7 +401,8 @@ describe('TrainSchedulingService', () => {
|
||||
expect(result.violations).not.toContain(
|
||||
'Selected bookings must share the same schedule date',
|
||||
);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.violations.some((v) => v.includes('different day'))).toBe(true);
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects bookings that are not in schedulable status', async () => {
|
||||
@@ -408,6 +435,8 @@ describe('TrainSchedulingService', () => {
|
||||
originYardId: 'yard-origin',
|
||||
destinationYardId: 'yard-destination',
|
||||
isActive: true,
|
||||
status: 'AVAILABLE',
|
||||
direction: 'IMPORT',
|
||||
};
|
||||
|
||||
const locomotive2 = { ...locomotive, id: 'loc-2', code: 'LOC-002' };
|
||||
@@ -421,6 +450,12 @@ describe('TrainSchedulingService', () => {
|
||||
const trainScheduleRepo = {
|
||||
create: jest.fn().mockImplementation((value) => value),
|
||||
save: jest.fn().mockResolvedValue({ id: 'schedule-1' }),
|
||||
// findGroupWindowAnchor looks for same-day sibling schedules; none here.
|
||||
createQueryBuilder: jest.fn(() => ({
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn().mockResolvedValue([]),
|
||||
})),
|
||||
};
|
||||
const trainSetRepo = {
|
||||
create: jest.fn().mockImplementation((value) => value),
|
||||
@@ -464,19 +499,21 @@ describe('TrainSchedulingService', () => {
|
||||
callback(manager),
|
||||
);
|
||||
|
||||
// Departure must clear the import lead window (≥ importWindowLeadDays ahead
|
||||
// of now), so use a comfortably-future date rather than a hardcoded one.
|
||||
const futureDeparture = new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const result = await service.createContainerTrainSchedule({
|
||||
routeId: 'route-1',
|
||||
scheduleDate: '2026-06-20T08:00:00.000Z',
|
||||
scheduleDate: futureDeparture,
|
||||
locomotiveIds: ['loc-1', 'loc-2'],
|
||||
});
|
||||
|
||||
expect(trainSetRepo.save).toHaveBeenCalled();
|
||||
expect(trainScheduleRepo.save).toHaveBeenCalled();
|
||||
expect(trainSetLocomotiveRepo.save).toHaveBeenCalled();
|
||||
expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith(
|
||||
{ id: expect.objectContaining({ _type: 'in', _value: ['loc-1', 'loc-2'] }) },
|
||||
{ status: 'ASSIGNED' },
|
||||
);
|
||||
// Advance scheduling locks locomotives but does NOT flip them to ASSIGNED —
|
||||
// one locomotive may sit on several future schedules.
|
||||
expect(lockedLocomotiveRepo.update).not.toHaveBeenCalled();
|
||||
expect(result.id).toBe('schedule-1');
|
||||
});
|
||||
|
||||
@@ -492,7 +529,7 @@ describe('TrainSchedulingService', () => {
|
||||
destinationYardId: 'yard-destination',
|
||||
status: 'PAID',
|
||||
bookingContainers: [],
|
||||
cargoType: { code: 'COFFEE' },
|
||||
cargoType: { id: 'cargo-coffee', code: 'COFFEE', wagonTypes: [cw3] },
|
||||
};
|
||||
|
||||
wagonTypesRepository.findAll.mockImplementation(async ({ where }: { where?: { code?: string } }) => {
|
||||
@@ -511,7 +548,9 @@ describe('TrainSchedulingService', () => {
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.summary.wagonType).toBe('MIXED');
|
||||
// Mixed freight now labels the summary by the concrete wagon type codes it uses.
|
||||
expect(result.summary.wagonType).toContain('NW5');
|
||||
expect(result.summary.wagonType).toContain('CW3');
|
||||
expect(result.wagonPlan.length).toBeGreaterThan(2);
|
||||
expect(result.containerUnits).toHaveLength(2);
|
||||
});
|
||||
@@ -536,9 +575,11 @@ describe('TrainSchedulingService', () => {
|
||||
});
|
||||
|
||||
it('rejects create when the locked locomotive is no longer available', async () => {
|
||||
// Advance scheduling only hard-blocks OUT_OF_SERVICE locomotives; other
|
||||
// non-AVAILABLE states (e.g. ASSIGNED) downgrade to a warning.
|
||||
const manager = {
|
||||
getRepository: jest.fn(() => ({
|
||||
findOne: jest.fn().mockResolvedValue({ ...locomotive, status: 'ASSIGNED' }),
|
||||
findOne: jest.fn().mockResolvedValue({ ...locomotive, status: 'OUT_OF_SERVICE' }),
|
||||
})),
|
||||
};
|
||||
|
||||
@@ -551,6 +592,8 @@ describe('TrainSchedulingService', () => {
|
||||
originYardId: 'yard-origin',
|
||||
destinationYardId: 'yard-destination',
|
||||
isActive: true,
|
||||
status: 'AVAILABLE',
|
||||
direction: 'IMPORT',
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -907,7 +950,7 @@ describe('TrainSchedulingService', () => {
|
||||
});
|
||||
|
||||
describe('getAvailableLocomotivesForRoute', () => {
|
||||
it('returns locomotives at the route origin yard', async () => {
|
||||
it('returns every in-service locomotive, annotated with origin-yard presence', async () => {
|
||||
const routeId = 'route-export';
|
||||
const originYardId = 'yard-addis';
|
||||
const routeRepo = {
|
||||
@@ -915,6 +958,7 @@ describe('TrainSchedulingService', () => {
|
||||
id: routeId,
|
||||
name: 'Addis → Djibouti',
|
||||
isActive: true,
|
||||
status: 'AVAILABLE',
|
||||
originYardId,
|
||||
originYard: { country: 'Ethiopia' },
|
||||
destinationYard: { country: 'Djibouti' },
|
||||
@@ -924,21 +968,21 @@ describe('TrainSchedulingService', () => {
|
||||
if ((entity as { name?: string })?.name === 'Route') return routeRepo;
|
||||
return { findOne: jest.fn(), update: jest.fn() };
|
||||
});
|
||||
// Advance-scheduling picker: nothing is filtered by yard — every in-service
|
||||
// locomotive is returned and annotated with whether it's at the origin yet.
|
||||
locomotivesRepository.findAll.mockResolvedValue([
|
||||
{ id: 'l2', code: 'EXP', status: 'AVAILABLE', currentYardId: originYardId },
|
||||
{ id: 'l3', code: 'FAR', status: 'ASSIGNED', currentYardId: 'yard-elsewhere' },
|
||||
]);
|
||||
|
||||
const result = await service.getAvailableLocomotivesForRoute(routeId);
|
||||
|
||||
expect(locomotivesRepository.findAll).toHaveBeenCalledWith({
|
||||
where: { status: 'AVAILABLE', currentYardId: originYardId },
|
||||
order: { code: 'ASC' },
|
||||
});
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].code).toBe('EXP');
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.find((l) => l.code === 'EXP')?.atOriginYard).toBe(true);
|
||||
expect(result.find((l) => l.code === 'FAR')?.atOriginYard).toBe(false);
|
||||
});
|
||||
|
||||
it('returns all locomotives returned by the repository for domestic routes', async () => {
|
||||
it('rejects intercity (domestic) routes — intercity scheduling is not offered', async () => {
|
||||
const routeId = 'route-domestic';
|
||||
const originYardId = 'yard-addis';
|
||||
const routeRepo = {
|
||||
@@ -946,6 +990,7 @@ describe('TrainSchedulingService', () => {
|
||||
id: routeId,
|
||||
name: 'Addis → Dire Dawa',
|
||||
isActive: true,
|
||||
status: 'AVAILABLE',
|
||||
originYardId,
|
||||
originYard: { country: 'Ethiopia' },
|
||||
destinationYard: { country: 'Ethiopia' },
|
||||
@@ -955,14 +1000,10 @@ describe('TrainSchedulingService', () => {
|
||||
if ((entity as { name?: string })?.name === 'Route') return routeRepo;
|
||||
return { findOne: jest.fn(), update: jest.fn() };
|
||||
});
|
||||
locomotivesRepository.findAll.mockResolvedValue([
|
||||
{ id: 'l1', code: 'IMP', status: 'AVAILABLE', currentYardId: originYardId },
|
||||
{ id: 'l2', code: 'EXP', status: 'AVAILABLE', currentYardId: originYardId },
|
||||
]);
|
||||
|
||||
const result = await service.getAvailableLocomotivesForRoute(routeId);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
await expect(
|
||||
service.getAvailableLocomotivesForRoute(routeId),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1081,4 +1122,172 @@ describe('TrainSchedulingService', () => {
|
||||
expect(html).not.toContain('EMPTY');
|
||||
});
|
||||
});
|
||||
|
||||
describe('moveWagonLoad — staff rearrange', () => {
|
||||
const containerType = {
|
||||
code: 'NX70',
|
||||
supportedLoadTypes: ['CONTAINER'],
|
||||
supportsContainer: true,
|
||||
};
|
||||
let slotA: Record<string, unknown>;
|
||||
let slotB: Record<string, unknown>;
|
||||
let allocsByWagon: Record<string, Array<Record<string, unknown>>>;
|
||||
let allocRepo: { find: jest.Mock; update: jest.Mock };
|
||||
let slotRepo: { update: jest.Mock };
|
||||
let wagonRepo: { findOne: jest.Mock };
|
||||
|
||||
const makeSchedule = (over: Record<string, unknown> = {}) => ({
|
||||
id: 'sched-1',
|
||||
status: 'SCHEDULED',
|
||||
trainSetId: 'ts-1',
|
||||
trainSet: { trainId: 'train-1', wagons: [slotA, slotB] },
|
||||
...over,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
slotA = {
|
||||
id: 'wA',
|
||||
sequenceNo: 1,
|
||||
capacityTons: 61,
|
||||
lengthMeters: 14,
|
||||
assignedWeightTons: 40,
|
||||
status: 'RESERVED',
|
||||
boardYardId: 'yard-1',
|
||||
alightYardId: null,
|
||||
wagonType: containerType,
|
||||
};
|
||||
slotB = {
|
||||
id: 'wB',
|
||||
sequenceNo: 2,
|
||||
capacityTons: 61,
|
||||
lengthMeters: 14,
|
||||
assignedWeightTons: 25,
|
||||
status: 'RESERVED',
|
||||
boardYardId: null,
|
||||
alightYardId: null,
|
||||
wagonType: containerType,
|
||||
};
|
||||
allocsByWagon = {
|
||||
// 20ft pair (two allocations sharing wagon A) — must travel together.
|
||||
wA: [
|
||||
{ id: 'alloc-a1', trainSetWagonId: 'wA', bookingId: 'b1', allocatedWeightTons: 20, loadType: 'CONTAINER' },
|
||||
{ id: 'alloc-a2', trainSetWagonId: 'wA', bookingId: 'b2', allocatedWeightTons: 20, loadType: 'CONTAINER' },
|
||||
],
|
||||
// one 40ft on wagon B.
|
||||
wB: [
|
||||
{ id: 'alloc-b1', trainSetWagonId: 'wB', bookingId: 'b3', allocatedWeightTons: 25, loadType: 'CONTAINER' },
|
||||
],
|
||||
};
|
||||
|
||||
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(makeSchedule());
|
||||
allocRepo = {
|
||||
find: jest.fn().mockImplementation(({ where }: { where: { trainSetWagonId: string } }) =>
|
||||
Promise.resolve(allocsByWagon[where.trainSetWagonId] ?? []),
|
||||
),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
slotRepo = { update: jest.fn().mockResolvedValue(undefined) };
|
||||
wagonRepo = { findOne: jest.fn().mockResolvedValue(null) };
|
||||
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||||
if (entity === WagonBookingAllocation) return allocRepo;
|
||||
if (entity === TrainSetWagon) return slotRepo;
|
||||
if (entity === Wagon) return wagonRepo;
|
||||
return { find: jest.fn().mockResolvedValue([]) };
|
||||
});
|
||||
dataSource.transaction.mockImplementation(
|
||||
async (fn: (m: unknown) => Promise<void>) =>
|
||||
fn({ getRepository: dataSource.getRepository }),
|
||||
);
|
||||
jest
|
||||
.spyOn(
|
||||
service as never as { getTrainScheduleById: (id: string) => Promise<unknown> },
|
||||
'getTrainScheduleById' as never,
|
||||
)
|
||||
.mockResolvedValue({ id: 'sched-1' } as never);
|
||||
});
|
||||
|
||||
it('rejects moves on a dispatched train', async () => {
|
||||
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(
|
||||
makeSchedule({ status: 'DISPATCHED' }),
|
||||
);
|
||||
await expect(
|
||||
service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'wB' }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('404s when the target is neither a slot nor a consist wagon of this train', async () => {
|
||||
await expect(
|
||||
service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'nope' }),
|
||||
).rejects.toThrow(/not part of this schedule/);
|
||||
});
|
||||
|
||||
it('swaps two loaded wagons: every allocation crosses over, load fields swap', async () => {
|
||||
await service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'wB' });
|
||||
|
||||
// The 20ft pair moved together onto wagon B…
|
||||
expect(allocRepo.update).toHaveBeenCalledWith('alloc-a1', { trainSetWagonId: 'wB' });
|
||||
expect(allocRepo.update).toHaveBeenCalledWith('alloc-a2', { trainSetWagonId: 'wB' });
|
||||
// …and the 40ft came back to wagon A.
|
||||
expect(allocRepo.update).toHaveBeenCalledWith('alloc-b1', { trainSetWagonId: 'wA' });
|
||||
// Load-coupled slot fields follow their loads.
|
||||
expect(slotRepo.update).toHaveBeenCalledWith('wB', {
|
||||
assignedWeightTons: 40,
|
||||
status: 'RESERVED',
|
||||
boardYardId: 'yard-1',
|
||||
alightYardId: null,
|
||||
});
|
||||
expect(slotRepo.update).toHaveBeenCalledWith('wA', {
|
||||
assignedWeightTons: 25,
|
||||
status: 'RESERVED',
|
||||
boardYardId: null,
|
||||
alightYardId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('repins the slot onto an empty consist-only wagon (the 404 case)', async () => {
|
||||
wagonRepo.findOne.mockResolvedValue({
|
||||
id: 'phys-9',
|
||||
wagonTypeId: 'wt-1',
|
||||
wagonNumber: 'WGN-9',
|
||||
wagonType: { ...containerType, capacityTons: 70, lengthMeters: 14 },
|
||||
});
|
||||
|
||||
await service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'phys-9' });
|
||||
|
||||
expect(wagonRepo.findOne).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: 'phys-9', trainId: 'train-1' } }),
|
||||
);
|
||||
// Repin: wagon identity moves onto the slot; allocations stay put.
|
||||
expect(slotRepo.update).toHaveBeenCalledWith('wA', {
|
||||
physicalWagonId: 'phys-9',
|
||||
wagonTypeId: 'wt-1',
|
||||
capacityTons: 70,
|
||||
lengthMeters: 14,
|
||||
});
|
||||
expect(allocRepo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a bulk load onto a wagon whose type only supports containers', async () => {
|
||||
allocsByWagon.wA = [
|
||||
{ id: 'alloc-bulk', trainSetWagonId: 'wA', bookingId: 'b9', allocatedWeightTons: 50, loadType: 'BULK' },
|
||||
];
|
||||
allocsByWagon.wB = [];
|
||||
|
||||
await expect(
|
||||
service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'wB' }),
|
||||
).rejects.toThrow(/cannot carry a bulk load/);
|
||||
});
|
||||
|
||||
it('rejects when the incoming load exceeds the receiving wagon payload', async () => {
|
||||
allocsByWagon.wA = [
|
||||
{ id: 'alloc-heavy', trainSetWagonId: 'wA', bookingId: 'b9', allocatedWeightTons: 70, loadType: 'CONTAINER' },
|
||||
];
|
||||
allocsByWagon.wB = [];
|
||||
|
||||
await expect(
|
||||
service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'wB' }),
|
||||
).rejects.toThrow(/over its/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,6 +77,7 @@ import {
|
||||
TrainScheduleFreightType,
|
||||
} from './dto/list-train-schedules-query.dto';
|
||||
import { PinWagonsDto } from './dto/pin-wagons.dto';
|
||||
import { MoveWagonLoadDto } from './dto/move-wagon-load.dto';
|
||||
import { UpdateContainerItemDto } from './dto/update-container-item.dto';
|
||||
import { UpdateImportLoadingStatusDto } from './dto/update-import-loading-status.dto';
|
||||
import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto';
|
||||
@@ -121,17 +122,21 @@ import {
|
||||
roundTons,
|
||||
sumWagonsRequired,
|
||||
type TrainLimitConfig,
|
||||
maxEdgeConsistUsage,
|
||||
validateContainerPlacements,
|
||||
validateMixedTrainLimits,
|
||||
validateMixedTrainLimitsPerEdge,
|
||||
type ContainerPlacementInput,
|
||||
type WagonPlanSlot,
|
||||
} from './wagon-plan.util';
|
||||
import { deriveScheduleDirection } from './derive-schedule-direction.util';
|
||||
import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util';
|
||||
import {
|
||||
bookingCargoTons,
|
||||
deriveTrainCapacityFromLocomotive,
|
||||
minLocomotiveLimits,
|
||||
trainSetLocomotiveLimits,
|
||||
wagonTypeDimensionsFromEntity,
|
||||
LocomotiveLimits,
|
||||
WagonTypeDimensions,
|
||||
} from './train-capacity.util';
|
||||
import {
|
||||
@@ -1538,8 +1543,21 @@ export class TrainSchedulingService {
|
||||
}
|
||||
}
|
||||
|
||||
// The rebuild below deletes EVERY schedule↔booking link row and recreates
|
||||
// only what makes the new plan. Ride-along (intercity) bookings are linked
|
||||
// OUTSIDE this flow — by acceptIntercity/allocate — and never appear in the
|
||||
// workspace's picked ids, so planning from dto.bookingIds alone silently
|
||||
// orphans them: PAID + SCHEDULED with no link and no wagon, invisible in
|
||||
// every list. Every (re)assignment therefore re-plans the WHOLE train:
|
||||
// the requested ids plus everything currently linked.
|
||||
const linkedRows =
|
||||
await this.trainScheduleBookingsRepository.findByScheduleId(scheduleId);
|
||||
const allBookingIds = [
|
||||
...new Set([...dto.bookingIds, ...linkedRows.map((row) => row.bookingId)]),
|
||||
];
|
||||
|
||||
const previewDto = {
|
||||
bookingIds: dto.bookingIds,
|
||||
bookingIds: allBookingIds,
|
||||
scheduleDate: schedule.scheduledDepartureDate.toISOString(),
|
||||
originStationId: schedule.originStationId,
|
||||
destinationStationId: schedule.destinationStationId,
|
||||
@@ -1562,8 +1580,13 @@ export class TrainSchedulingService {
|
||||
// preview the wagon plan first, then lay containers into the plan's slots.
|
||||
// Without this the placement validator rejects container bookings outright
|
||||
// ("Container placements are required for container bookings").
|
||||
// Callers hand-pick placements only for the bookings they know about; the
|
||||
// union above may have folded in linked ride-alongs those placements never
|
||||
// covered. Auto-fill whatever units are missing (all of them when no
|
||||
// placements were sent at all) so the placement validator doesn't reject
|
||||
// container bookings the caller couldn't have placed.
|
||||
let containerPlacements = dto.containerPlacements;
|
||||
if (!containerPlacements?.length) {
|
||||
{
|
||||
const preview = await this.validateBookingsForScheduling(
|
||||
previewDto,
|
||||
freightType ?? null,
|
||||
@@ -1578,18 +1601,28 @@ export class TrainSchedulingService {
|
||||
);
|
||||
if (containerBookings.length) {
|
||||
const units = expandBookingContainerUnits(containerBookings);
|
||||
const slots = getContainerSlotSequenceNos(preview.wagonPlan);
|
||||
const generated = autoFillPlacements(units, slots);
|
||||
const missing = findMissingContainerNumberIssues(units, generated);
|
||||
if (missing.length) {
|
||||
throw new BadRequestException({
|
||||
message: `Booking validation failed: ${missing
|
||||
.map((m) => m.issue)
|
||||
.join('; ')}`,
|
||||
violations: missing.map((m) => m.issue),
|
||||
});
|
||||
const providedKeys = new Set(
|
||||
(containerPlacements ?? []).map(
|
||||
(p) => `${p.bookingContainerId}:${p.unitIndex}`,
|
||||
),
|
||||
);
|
||||
const unplacedUnits = units.filter(
|
||||
(u) => !providedKeys.has(`${u.bookingContainerId}:${u.unitIndex}`),
|
||||
);
|
||||
if (unplacedUnits.length) {
|
||||
const slots = getContainerSlotSequenceNos(preview.wagonPlan);
|
||||
const generated = autoFillPlacements(unplacedUnits, slots);
|
||||
const missing = findMissingContainerNumberIssues(unplacedUnits, generated);
|
||||
if (missing.length) {
|
||||
throw new BadRequestException({
|
||||
message: `Booking validation failed: ${missing
|
||||
.map((m) => m.issue)
|
||||
.join('; ')}`,
|
||||
violations: missing.map((m) => m.issue),
|
||||
});
|
||||
}
|
||||
containerPlacements = [...(containerPlacements ?? []), ...generated];
|
||||
}
|
||||
containerPlacements = generated;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1632,8 +1665,10 @@ export class TrainSchedulingService {
|
||||
// NW5 free) — the caller saw HTTP 200 and a green toast over a no-op.
|
||||
// A stock shortage is a physical impossibility, so forceAssign cannot
|
||||
// override it either.
|
||||
// Linked ride-alongs count as requested too: silently dropping one here is
|
||||
// exactly the delete-and-recreate orphan this method must never produce.
|
||||
const plannedIds = new Set(validation.bookings.map((b) => b.id));
|
||||
const droppedRequested = dto.bookingIds.filter((id) => !plannedIds.has(id));
|
||||
const droppedRequested = allBookingIds.filter((id) => !plannedIds.has(id));
|
||||
if (droppedRequested.length) {
|
||||
const reasonById = new Map(
|
||||
validation.deferredBookings.map((d) => [d.id, `${d.reference}: ${d.reason}`]),
|
||||
@@ -1675,25 +1710,36 @@ export class TrainSchedulingService {
|
||||
relations: { wagonType: true },
|
||||
})
|
||||
: null;
|
||||
const planTareTons = consistWagons
|
||||
const planTareTons = roundTons(
|
||||
wagonPlan.reduce((sum, slot) => sum + Number(slot.tareWeightTons ?? 0), 0),
|
||||
);
|
||||
const consistTareTons = consistWagons
|
||||
? roundTons(
|
||||
consistWagons.reduce(
|
||||
(sum, wagon) => sum + Number(wagon.wagonType?.tareWeightTons ?? 0),
|
||||
0,
|
||||
),
|
||||
)
|
||||
: roundTons(
|
||||
wagonPlan.reduce((sum, slot) => sum + Number(slot.tareWeightTons ?? 0), 0),
|
||||
);
|
||||
const grossWeightTons = roundTons(totalWeightTons + planTareTons);
|
||||
: planTareTons;
|
||||
// The pull limit binds on the HEAVIEST LEG, not the whole-route sum —
|
||||
// disjoint legs (intercity Gelan→Adama + export Adama→Doraleh) are never
|
||||
// hauled at the same time. Coupled-but-unplanned wagons ride every edge,
|
||||
// so their tare rides on top of the binding edge.
|
||||
const emptyConsistTareTons = Math.max(0, consistTareTons - planTareTons);
|
||||
const edgeUsage = maxEdgeConsistUsage(
|
||||
wagonPlan,
|
||||
await this.stopYardsForSchedule(schedule),
|
||||
);
|
||||
const grossWeightTons = roundTons(edgeUsage.grossWeightTons + emptyConsistTareTons);
|
||||
if (!dto.forceAssign && weightCapWithOverage < grossWeightTons) {
|
||||
throw new BadRequestException(
|
||||
`Train set locomotives cannot pull ${grossWeightTons}T gross (${totalWeightTons}T cargo + ${planTareTons}T wagon tare)`,
|
||||
`Train set locomotives cannot pull ${grossWeightTons}T gross on the heaviest leg (limit ${roundTons(weightCapWithOverage)}T incl. tolerance)`,
|
||||
);
|
||||
}
|
||||
if (!dto.forceAssign && lengthCapWithOverage < totalLengthMeters) {
|
||||
const maxEdgeLengthMeters = roundTons(edgeUsage.lengthMeters);
|
||||
if (!dto.forceAssign && lengthCapWithOverage < maxEdgeLengthMeters) {
|
||||
throw new BadRequestException(
|
||||
`Train set locomotives cannot support ${totalLengthMeters}m`,
|
||||
`Train set locomotives cannot support ${maxEdgeLengthMeters}m`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2780,13 +2826,12 @@ export class TrainSchedulingService {
|
||||
return [
|
||||
`<tr class="empty">
|
||||
${wagonCells}
|
||||
<td colspan="6">EMPTY — no cargo allocated</td>
|
||||
<td colspan="4">EMPTY — no cargo allocated</td>
|
||||
</tr>`,
|
||||
];
|
||||
}
|
||||
return allocations.map((allocation) => {
|
||||
const booking = allocation.booking ?? bookingById.get(allocation.bookingId);
|
||||
const company = booking?.company as Record<string, unknown> | null | undefined;
|
||||
const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType;
|
||||
const containerItems = allocation.containerItems ?? [];
|
||||
const firstContainer = containerItems[0];
|
||||
@@ -2795,8 +2840,6 @@ export class TrainSchedulingService {
|
||||
const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', ');
|
||||
return `<tr>
|
||||
${wagonCells}
|
||||
<td>${esc(company?.name ?? company?.legalName ?? company?.tradeName ?? booking?.companyId)}</td>
|
||||
<td>${esc(booking?.companyId)}</td>
|
||||
<td>${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)}</td>
|
||||
<td>${esc(containerNumbers || firstContainer?.containerNumber)}</td>
|
||||
<td>${esc(chassisNumbers)}</td>
|
||||
@@ -2878,8 +2921,6 @@ export class TrainSchedulingService {
|
||||
<th class="num">Equated Length</th>
|
||||
<th class="num">Tare Weight</th>
|
||||
<th class="num">Load Capacity</th>
|
||||
<th>Customer Name</th>
|
||||
<th>Customer ID</th>
|
||||
<th>Cargo Type</th>
|
||||
<th>Container No</th>
|
||||
<th>Chassis No</th>
|
||||
@@ -2887,7 +2928,7 @@ export class TrainSchedulingService {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows || '<tr><td colspan="12">No wagons on this train set.</td></tr>'}
|
||||
${rows || '<tr><td colspan="10">No wagons on this train set.</td></tr>'}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -3852,25 +3893,24 @@ export class TrainSchedulingService {
|
||||
);
|
||||
}
|
||||
|
||||
// Corridor-aware: a booking belongs on this train when its origin and
|
||||
// destination lie on the schedule's stop list in order — sub-corridor
|
||||
// bookings (Dire→Djibouti on an Addis→…→Djibouti train) are valid. The
|
||||
// stop list is also what makes the wagon plan leg-aware below.
|
||||
let stops = [dto.originStationId, dto.destinationStationId];
|
||||
if (targetScheduleId) {
|
||||
const target = await this.trainSchedulesRepository.findById(targetScheduleId);
|
||||
if (target) stops = await this.stopYardsForSchedule(target);
|
||||
}
|
||||
if (
|
||||
await (async () => {
|
||||
// Corridor-aware: a booking belongs on this train when its origin and
|
||||
// destination lie on the schedule's stop list in order — sub-corridor
|
||||
// bookings (Dire→Djibouti on an Addis→…→Djibouti train) are valid.
|
||||
let stops = [dto.originStationId, dto.destinationStationId];
|
||||
if (targetScheduleId) {
|
||||
const target = await this.trainSchedulesRepository.findById(targetScheduleId);
|
||||
if (target) stops = await this.stopYardsForSchedule(target);
|
||||
bookings.some((b) => {
|
||||
if (targetScheduleId && b.trainScheduleId === targetScheduleId) {
|
||||
return false;
|
||||
}
|
||||
return bookings.some((b) => {
|
||||
if (targetScheduleId && b.trainScheduleId === targetScheduleId) {
|
||||
return false;
|
||||
}
|
||||
const fromIdx = stops.indexOf(b.originYardId);
|
||||
const toIdx = stops.indexOf(b.destinationYardId);
|
||||
return fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx;
|
||||
});
|
||||
})()
|
||||
const fromIdx = stops.indexOf(b.originYardId);
|
||||
const toIdx = stops.indexOf(b.destinationYardId);
|
||||
return fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx;
|
||||
})
|
||||
) {
|
||||
violations.push('Selected bookings must lie on the schedule route (origin before destination)');
|
||||
}
|
||||
@@ -3956,7 +3996,22 @@ export class TrainSchedulingService {
|
||||
stock = { mode: 'YARD', remainingByTypeId, codesByTypeId };
|
||||
}
|
||||
|
||||
const planned = planWagonsWithStock({ bookings, allowed, stock });
|
||||
// Leg-aware stock: each booking consumes wagons only on the edges it rides,
|
||||
// so a ride-along on an empty leg never competes with cargo on a full one.
|
||||
const legByBookingId = new Map(
|
||||
bookings.flatMap((b) => {
|
||||
const from = stops.indexOf(b.originYardId);
|
||||
const to = stops.indexOf(b.destinationYardId);
|
||||
return from >= 0 && to > from ? [[b.id, { from, to }] as const] : [];
|
||||
}),
|
||||
);
|
||||
const planned = planWagonsWithStock({
|
||||
bookings,
|
||||
allowed,
|
||||
stock,
|
||||
legs: legByBookingId,
|
||||
edgeCount: Math.max(1, stops.length - 1),
|
||||
});
|
||||
violations.push(...planned.configIssues);
|
||||
const fittingBookings = planned.fitting;
|
||||
const deferredBookings: DeferredBookingRow[] = planned.deferred;
|
||||
@@ -4018,10 +4073,11 @@ export class TrainSchedulingService {
|
||||
).values(),
|
||||
];
|
||||
pushLimit(
|
||||
validateMixedTrainLimits(
|
||||
validateMixedTrainLimitsPerEdge(
|
||||
wagonPlan,
|
||||
plannedWagonTypes.length ? plannedWagonTypes : [{ lengthMeters: 14 }],
|
||||
trainLimits,
|
||||
stops,
|
||||
),
|
||||
);
|
||||
if (requireContainerPlacements && resolvedMode !== 'BULK') {
|
||||
@@ -4040,9 +4096,6 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
const totalWeightTons = totalAssignedWeight(fittingBookings);
|
||||
// Every weight limit below (global max, loco pull) is a GROSS axis, so the
|
||||
// figure spent against it must be gross too — cargo alone under-reports the
|
||||
// train by the full consist tare and disagrees with the assign path.
|
||||
const totalTareTons = roundTons(
|
||||
wagonPlan.reduce((sum, w) => sum + (Number(w.tareWeightTons) || 0), 0),
|
||||
);
|
||||
@@ -4050,12 +4103,13 @@ export class TrainSchedulingService {
|
||||
const totalLengthMeters = roundTons(
|
||||
wagonPlan.reduce((sum, w) => sum + w.lengthMeters, 0),
|
||||
);
|
||||
if (grossWeightTons > trainLimits.maxWeightTons) {
|
||||
const message = `Total gross weight ${grossWeightTons}T (${totalWeightTons}T cargo + ${totalTareTons}T wagon tare) exceeds max train weight ${trainLimits.maxWeightTons}T`;
|
||||
if (!violations.includes(message) && !warnings.includes(message)) {
|
||||
pushLimit([message]);
|
||||
}
|
||||
}
|
||||
// Weight/length limits are enforced PER EDGE by validateMixedTrainLimitsPerEdge
|
||||
// above — the whole-route totals here are informational (summary) only. The
|
||||
// locomotive checks below also compare the heaviest single edge: a train is
|
||||
// never heavier than its heaviest leg, so disjoint legs must not be summed.
|
||||
const edgeUsage = maxEdgeConsistUsage(wagonPlan, stops);
|
||||
const maxEdgeGrossTons = roundTons(edgeUsage.grossWeightTons);
|
||||
const maxEdgeLengthMeters = roundTons(edgeUsage.lengthMeters);
|
||||
|
||||
let assignedLocomotives: Locomotive[] = [];
|
||||
if (targetScheduleId) {
|
||||
@@ -4078,9 +4132,9 @@ export class TrainSchedulingService {
|
||||
if (
|
||||
setLimits &&
|
||||
(setLimits.maxPullWeightTons + (Number(setLimits.overageToleranceTons) || 0) <
|
||||
grossWeightTons ||
|
||||
maxEdgeGrossTons ||
|
||||
setLimits.maxTrainLengthMeters + (Number(setLimits.overageToleranceMeters) || 0) <
|
||||
totalLengthMeters)
|
||||
maxEdgeLengthMeters)
|
||||
) {
|
||||
pushLimit([
|
||||
'Assigned locomotives cannot support the total train weight and length',
|
||||
@@ -4099,9 +4153,9 @@ export class TrainSchedulingService {
|
||||
!inServiceLocomotives.some(
|
||||
(l) =>
|
||||
Number(l.maxPullWeightTons) + (Number(l.overageToleranceTons) || 0) >=
|
||||
grossWeightTons &&
|
||||
maxEdgeGrossTons &&
|
||||
Number(l.maxTrainLengthMeters) + (Number(l.overageToleranceMeters) || 0) >=
|
||||
totalLengthMeters,
|
||||
maxEdgeLengthMeters,
|
||||
)
|
||||
) {
|
||||
pushLimit(['No locomotive can support the total train weight and length']);
|
||||
@@ -4160,10 +4214,7 @@ export class TrainSchedulingService {
|
||||
maxTrainLengthMeters?: number;
|
||||
maxWagonsPerTrain?: number;
|
||||
},
|
||||
locomotive?: Pick<
|
||||
Locomotive,
|
||||
'maxPullWeightTons' | 'maxTrainLengthMeters' | 'overageToleranceTons' | 'overageToleranceMeters'
|
||||
>,
|
||||
locomotive?: LocomotiveLimits | null,
|
||||
): Promise<Required<TrainLimitConfig>> {
|
||||
const row = await this.loadGlobalRulesRow();
|
||||
const configured = this.configService?.get<{
|
||||
@@ -6471,7 +6522,7 @@ export class TrainSchedulingService {
|
||||
>,
|
||||
tareDims: Awaited<ReturnType<TrainSchedulingService['loadWagonTareDims']>>,
|
||||
): number {
|
||||
const cargo = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
const cargo = bookingCargoTons(booking);
|
||||
const fallback =
|
||||
booking.freightType === 'BULK' ? tareDims.bulk : tareDims.container;
|
||||
// Same first-configured-type resolution the batch engine's dimsFor uses.
|
||||
@@ -6810,12 +6861,45 @@ export class TrainSchedulingService {
|
||||
status: sb.booking?.status ?? null,
|
||||
schedulingStatus: sb.booking?.schedulingStatus ?? null,
|
||||
freightType: sb.booking?.freightType ?? null,
|
||||
// Which leg of the corridor this booking rides — the workspace can't
|
||||
// tell a ride-along (intercity) or sub-corridor booking from through
|
||||
// cargo without it.
|
||||
tradeDirection: sb.booking?.tradeDirection ?? null,
|
||||
originYardId: sb.booking?.originYardId ?? null,
|
||||
destinationYardId: sb.booking?.destinationYardId ?? null,
|
||||
origin:
|
||||
sb.booking?.originYard?.label ?? sb.booking?.originYard?.code ?? null,
|
||||
destination:
|
||||
sb.booking?.destinationYard?.label ??
|
||||
sb.booking?.destinationYard?.code ??
|
||||
null,
|
||||
wagonsRequired:
|
||||
sb.booking?.wagonsRequired != null
|
||||
? Number(sb.booking.wagonsRequired)
|
||||
: null,
|
||||
loadedAt: sb.booking?.loadedAt?.toISOString() ?? null,
|
||||
arrivedAt: sb.booking?.arrivedAt?.toISOString() ?? null,
|
||||
// Loaded/unloaded is tracked on the schedule↔booking link, not the
|
||||
// booking itself — staff flip it per booking in the workspace before
|
||||
// dispatch. Defaults UNLOADED for links written before the column.
|
||||
loadingStatus: sb.loadingStatus ?? LoadingStatus.Unloaded,
|
||||
wagonAssigned: allocatedBookingIds.has(sb.booking?.id ?? sb.bookingId),
|
||||
})) ?? [],
|
||||
// Ordered corridor stops (route milestones; falls back to the two
|
||||
// endpoints) — lets the UI draw per-segment occupancy and label legs.
|
||||
stops: this.mapScheduleStops(schedule),
|
||||
// Gross ceiling the validator holds each leg to: the set's weakest
|
||||
// locomotive pull limit plus its overage tolerance. Booking weightTons
|
||||
// above are gross too, so the strip can sum them per leg against this.
|
||||
maxGrossWeightTons: (() => {
|
||||
const setLimits = trainSetLocomotiveLimits(schedule.trainSet);
|
||||
return setLimits
|
||||
? roundTons(
|
||||
Number(setLimits.maxPullWeightTons) +
|
||||
(Number(setLimits.overageToleranceTons) || 0),
|
||||
)
|
||||
: null;
|
||||
})(),
|
||||
// True when the wagon plan above is served from the frozen snapshot (schedule
|
||||
// is dispatched/arrived/cancelled) rather than the live joins — the UI can badge
|
||||
// it "historical" and skip re-pin affordances.
|
||||
@@ -6824,6 +6908,42 @@ export class TrainSchedulingService {
|
||||
};
|
||||
}
|
||||
|
||||
/** Ordered corridor stops with labels, from the loaded route graph (no extra query). */
|
||||
private mapScheduleStops(
|
||||
schedule: TrainSchedule,
|
||||
): Array<{ yardId: string; label: string }> {
|
||||
const milestones = [...(schedule.route?.milestones ?? [])].sort(
|
||||
(a, b) => a.sequenceNo - b.sequenceNo,
|
||||
);
|
||||
const raw = milestones.length >= 2
|
||||
? milestones.map((m) => ({
|
||||
yardId: m.yardId,
|
||||
label: m.yard?.label ?? m.yard?.code ?? m.yardId,
|
||||
}))
|
||||
: [
|
||||
{
|
||||
yardId: schedule.originStationId,
|
||||
label:
|
||||
schedule.originStation?.label ??
|
||||
schedule.originStation?.code ??
|
||||
schedule.originStationId,
|
||||
},
|
||||
{
|
||||
yardId: schedule.destinationStationId,
|
||||
label:
|
||||
schedule.destinationStation?.label ??
|
||||
schedule.destinationStation?.code ??
|
||||
schedule.destinationStationId,
|
||||
},
|
||||
];
|
||||
const seen = new Set<string>();
|
||||
return raw.filter((stop) => {
|
||||
if (!stop.yardId || seen.has(stop.yardId)) return false;
|
||||
seen.add(stop.yardId);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private isHoldActive(booking: Booking): boolean {
|
||||
if (!booking.holdExpiresAt) return false;
|
||||
return booking.holdExpiresAt.getTime() > Date.now();
|
||||
@@ -6875,7 +6995,10 @@ export class TrainSchedulingService {
|
||||
originStationId: schedule.originStationId,
|
||||
destinationStationId: schedule.destinationStationId,
|
||||
};
|
||||
const limits = await this.resolveTrainLimitConfig(undefined, schedule.trainSet.locomotive);
|
||||
const limits = await this.resolveTrainLimitConfig(
|
||||
undefined,
|
||||
trainSetLocomotiveLimits(schedule.trainSet),
|
||||
);
|
||||
|
||||
const validation = await this.validateBookingsForScheduling(
|
||||
previewDto,
|
||||
@@ -7001,7 +7124,7 @@ export class TrainSchedulingService {
|
||||
};
|
||||
const limits = await this.resolveTrainLimitConfig(
|
||||
undefined,
|
||||
schedule.trainSet.locomotive,
|
||||
trainSetLocomotiveLimits(schedule.trainSet),
|
||||
);
|
||||
|
||||
let validation: Awaited<ReturnType<TrainSchedulingService['validateBookingsForScheduling']>>;
|
||||
@@ -7232,6 +7355,171 @@ export class TrainSchedulingService {
|
||||
return { id: itemId, containerNumber: dto.containerNumber ?? null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff rearrange: relocate a wagon's ENTIRE load (all its allocations —
|
||||
* a 40ft, a 20ft pair, or a bulk load) to another wagon of the same train.
|
||||
* Whole-load moves keep every packing rule intact by construction (a valid
|
||||
* load stays valid on any wagon whose type supports it), which is what lets
|
||||
* a 20ft pair travel together and swap places with a 40ft, and lets bulk
|
||||
* swap with containers.
|
||||
*
|
||||
* Three shapes, picked from the target:
|
||||
* - target is an empty consist-only wagon (coupled on the built train, no
|
||||
* slot row): REPIN — the source slot simply points at that physical wagon
|
||||
* (type/capacity/length follow), and the wagon it left shows as empty.
|
||||
* - target is an empty slot: allocations repoint to it and the load-coupled
|
||||
* slot fields (assigned weight, status, board/alight leg) move across.
|
||||
* - target is a loaded slot: the two loads swap wagons the same way.
|
||||
*
|
||||
* Validated per direction: the receiving wagon's type must support the
|
||||
* incoming load type, and the incoming cargo must fit its rated payload.
|
||||
*/
|
||||
async moveWagonLoad(
|
||||
scheduleId: string,
|
||||
sourceWagonId: string,
|
||||
dto: MoveWagonLoadDto,
|
||||
): Promise<any> {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
}
|
||||
if (['DISPATCHED', 'ARRIVED'].includes(schedule.status)) {
|
||||
throw new BadRequestException('Cannot rearrange loads on a dispatched train');
|
||||
}
|
||||
if (sourceWagonId === dto.targetWagonId) {
|
||||
return this.getTrainScheduleById(scheduleId);
|
||||
}
|
||||
|
||||
const slots = schedule.trainSet?.wagons ?? [];
|
||||
const source = slots.find((w) => w.id === sourceWagonId);
|
||||
if (!source) {
|
||||
throw new NotFoundException('Source wagon is not part of this schedule');
|
||||
}
|
||||
|
||||
const allocRepo = this.dataSource.getRepository(WagonBookingAllocation);
|
||||
const loadAllocations = (trainSetWagonId: string) =>
|
||||
allocRepo.find({ where: { trainSetWagonId } });
|
||||
const sourceAllocs = await loadAllocations(source.id);
|
||||
if (!sourceAllocs.length) {
|
||||
throw new BadRequestException('Source wagon has no load to move');
|
||||
}
|
||||
|
||||
// Target: a slot of this train set, or an empty consist-only wagon of the
|
||||
// built train (physical wagon with no slot row yet).
|
||||
const targetSlot = slots.find((w) => w.id === dto.targetWagonId) ?? null;
|
||||
const consistWagon = targetSlot
|
||||
? null
|
||||
: schedule.trainSet?.trainId
|
||||
? await this.dataSource.getRepository(Wagon).findOne({
|
||||
where: { id: dto.targetWagonId, trainId: schedule.trainSet.trainId },
|
||||
relations: { wagonType: true },
|
||||
})
|
||||
: null;
|
||||
if (!targetSlot && !consistWagon) {
|
||||
throw new NotFoundException('Target wagon is not part of this schedule');
|
||||
}
|
||||
const targetAllocs = targetSlot ? await loadAllocations(targetSlot.id) : [];
|
||||
|
||||
const loadTypesOf = (allocs: WagonBookingAllocation[]) => [
|
||||
...new Set(allocs.map((a) => (a.loadType ?? 'CONTAINER').toUpperCase())),
|
||||
];
|
||||
const cargoOf = (allocs: WagonBookingAllocation[]) =>
|
||||
allocs.reduce((sum, a) => sum + Number(a.allocatedWeightTons || 0), 0);
|
||||
const wagonLabel = (slot: { sequenceNo: number } | null, wagon: Wagon | null) =>
|
||||
slot ? `#${slot.sequenceNo}` : (wagon?.wagonNumber ?? 'the target wagon');
|
||||
const checkReceives = (
|
||||
allocs: WagonBookingAllocation[],
|
||||
label: string,
|
||||
wagonType: { code?: string; supportedLoadTypes?: string[]; supportsContainer?: boolean } | null | undefined,
|
||||
capacityTons: number,
|
||||
) => {
|
||||
const incoming = loadTypesOf(allocs);
|
||||
// Unknown type or no declared support list → staff decides; don't block.
|
||||
if (wagonType) {
|
||||
const supported = (wagonType.supportedLoadTypes ?? []).map((t) => t.toUpperCase());
|
||||
for (const loadType of incoming) {
|
||||
const ok =
|
||||
supported.includes(loadType) ||
|
||||
(loadType === 'CONTAINER' && wagonType.supportsContainer) ||
|
||||
supported.length === 0;
|
||||
if (!ok) {
|
||||
throw new BadRequestException(
|
||||
`Wagon ${label} (${wagonType.code ?? 'unknown type'}) cannot carry a ${loadType.toLowerCase()} load`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const cargo = cargoOf(allocs);
|
||||
if (capacityTons > 0 && cargo > capacityTons + 0.001) {
|
||||
throw new BadRequestException(
|
||||
`Wagon ${label} would carry ${roundTons(cargo)}T — over its ${roundTons(capacityTons)}T payload`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// What the target must be able to receive…
|
||||
checkReceives(
|
||||
sourceAllocs,
|
||||
wagonLabel(targetSlot, consistWagon),
|
||||
targetSlot ? targetSlot.wagonType : consistWagon?.wagonType,
|
||||
Number(targetSlot ? targetSlot.capacityTons : (consistWagon?.wagonType?.capacityTons ?? 0)),
|
||||
);
|
||||
// …and, on a swap, what comes back to the source.
|
||||
if (targetAllocs.length) {
|
||||
checkReceives(
|
||||
targetAllocs,
|
||||
`#${source.sequenceNo}`,
|
||||
source.wagonType,
|
||||
Number(source.capacityTons),
|
||||
);
|
||||
}
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const slotRepo = manager.getRepository(TrainSetWagon);
|
||||
const allocs = manager.getRepository(WagonBookingAllocation);
|
||||
|
||||
// Empty consist wagon: repin the loaded slot onto that physical wagon.
|
||||
// Allocations and load fields stay put; only the wagon identity changes.
|
||||
if (consistWagon) {
|
||||
await slotRepo.update(source.id, {
|
||||
physicalWagonId: consistWagon.id,
|
||||
wagonTypeId: consistWagon.wagonTypeId,
|
||||
capacityTons: roundTons(Number(consistWagon.wagonType?.capacityTons ?? source.capacityTons)),
|
||||
lengthMeters: roundTons(Number(consistWagon.wagonType?.lengthMeters ?? source.lengthMeters)),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const target = targetSlot as TrainSetWagon;
|
||||
// Load-coupled slot fields travel with the load; wagon identity stays.
|
||||
const loadFieldsOf = (slot: TrainSetWagon) => ({
|
||||
assignedWeightTons: slot.assignedWeightTons,
|
||||
status: slot.status,
|
||||
boardYardId: slot.boardYardId ?? null,
|
||||
alightYardId: slot.alightYardId ?? null,
|
||||
});
|
||||
const emptyLoadFields = {
|
||||
assignedWeightTons: 0,
|
||||
status: 'PLANNED',
|
||||
boardYardId: null,
|
||||
alightYardId: null,
|
||||
};
|
||||
const sourceLoadFields = loadFieldsOf(source);
|
||||
const targetLoadFields = targetAllocs.length ? loadFieldsOf(target) : emptyLoadFields;
|
||||
|
||||
for (const alloc of sourceAllocs) {
|
||||
await allocs.update(alloc.id, { trainSetWagonId: target.id });
|
||||
}
|
||||
for (const alloc of targetAllocs) {
|
||||
await allocs.update(alloc.id, { trainSetWagonId: source.id });
|
||||
}
|
||||
await slotRepo.update(target.id, sourceLoadFields);
|
||||
await slotRepo.update(source.id, targetLoadFields);
|
||||
});
|
||||
|
||||
return this.getTrainScheduleById(scheduleId);
|
||||
}
|
||||
|
||||
async getUnassignedBookings(scheduleId: string): Promise<UnassignedBookingsResponse> {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) {
|
||||
@@ -7380,7 +7668,7 @@ export class TrainSchedulingService {
|
||||
};
|
||||
const limits = await this.resolveTrainLimitConfig(
|
||||
undefined,
|
||||
schedule.trainSet.locomotive,
|
||||
trainSetLocomotiveLimits(schedule.trainSet),
|
||||
);
|
||||
|
||||
let validation: Awaited<ReturnType<TrainSchedulingService['validateBookingsForScheduling']>>;
|
||||
|
||||
@@ -187,3 +187,115 @@ describe('applyWagonOrderReversal', () => {
|
||||
expect(plan.map((s) => s.wagonTypeId)).toEqual(['wt-a', 'wt-b', 'wt-c']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('planWagonsWithStock — leg-aware stock (intercity ride-along)', () => {
|
||||
const allowed = {
|
||||
byContainerTypeId: new Map([['ct-1', [nw6]]]),
|
||||
byCargoTypeId: new Map(),
|
||||
};
|
||||
// Corridor Gelan(0) → Adama(1) → Doraleh(2): edges 0 and 1.
|
||||
const legs = (entries: Array<[string, { from: number; to: number }]>) =>
|
||||
new Map(entries);
|
||||
|
||||
it('lets an intercity booking ride the empty leg of a train that is full on the other leg', () => {
|
||||
// 1 wagon in stock. Export rides edge 1 only; intercity rides edge 0 only.
|
||||
const result = planWagonsWithStock({
|
||||
bookings: [
|
||||
containerBooking('EXPORT-1', 1, 1),
|
||||
containerBooking('INTERCITY-1', 1, 1),
|
||||
],
|
||||
allowed,
|
||||
stock: {
|
||||
mode: 'TRAIN',
|
||||
remainingByTypeId: new Map([[nw6.id, 1]]),
|
||||
codesByTypeId: new Map([[nw6.id, nw6.code]]),
|
||||
},
|
||||
legs: legs([
|
||||
['EXPORT-1', { from: 1, to: 2 }],
|
||||
['INTERCITY-1', { from: 0, to: 1 }],
|
||||
]),
|
||||
edgeCount: 2,
|
||||
});
|
||||
|
||||
expect(result.deferred).toHaveLength(0);
|
||||
expect(result.fitting.map((b) => b.id).sort()).toEqual([
|
||||
'EXPORT-1',
|
||||
'INTERCITY-1',
|
||||
]);
|
||||
// Two slots planned, but both drawn from the single physical wagon.
|
||||
expect(result.plan).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('still defers when the legs overlap and stock is exhausted', () => {
|
||||
const result = planWagonsWithStock({
|
||||
bookings: [
|
||||
containerBooking('EXPORT-1', 1, 1),
|
||||
containerBooking('INTERCITY-1', 1, 1),
|
||||
],
|
||||
allowed,
|
||||
stock: {
|
||||
mode: 'TRAIN',
|
||||
remainingByTypeId: new Map([[nw6.id, 1]]),
|
||||
codesByTypeId: new Map([[nw6.id, nw6.code]]),
|
||||
},
|
||||
legs: legs([
|
||||
// Both ride edge 0 — they compete for the one wagon.
|
||||
['EXPORT-1', { from: 0, to: 2 }],
|
||||
['INTERCITY-1', { from: 0, to: 1 }],
|
||||
]),
|
||||
edgeCount: 2,
|
||||
});
|
||||
|
||||
expect(result.fitting.map((b) => b.id)).toEqual(['EXPORT-1']);
|
||||
expect(result.deferred).toHaveLength(1);
|
||||
expect(result.deferred[0]!.reference).toBe('INTERCITY-1');
|
||||
expect(result.deferred[0]!.reason).toContain('Train has no free NW6 wagon left');
|
||||
});
|
||||
|
||||
it('never packs bookings with different legs into the same wagon slot', () => {
|
||||
// Two 20ft units with room to share one wagon by TEU — but disjoint legs
|
||||
// must open separate slots (each with its own leg), not one mixed slot.
|
||||
const result = planWagonsWithStock({
|
||||
bookings: [
|
||||
containerBooking('EXPORT-1', 1, 1),
|
||||
containerBooking('INTERCITY-1', 1, 1),
|
||||
],
|
||||
allowed,
|
||||
stock: {
|
||||
mode: 'TRAIN',
|
||||
remainingByTypeId: new Map([[nw6.id, 2]]),
|
||||
codesByTypeId: new Map([[nw6.id, nw6.code]]),
|
||||
},
|
||||
legs: legs([
|
||||
['EXPORT-1', { from: 1, to: 2 }],
|
||||
['INTERCITY-1', { from: 0, to: 1 }],
|
||||
]),
|
||||
edgeCount: 2,
|
||||
});
|
||||
|
||||
expect(result.plan).toHaveLength(2);
|
||||
const bookingsPerSlot = result.plan.map((s) =>
|
||||
[...new Set(s.allocations.map((a) => a.bookingId))].sort(),
|
||||
);
|
||||
expect(bookingsPerSlot).toEqual([['EXPORT-1'], ['INTERCITY-1']]);
|
||||
});
|
||||
|
||||
it('behaves exactly like the whole-route planner when no legs are given', () => {
|
||||
const result = planWagonsWithStock({
|
||||
bookings: [
|
||||
containerBooking('EXPORT-1', 1, 1),
|
||||
containerBooking('INTERCITY-1', 1, 1),
|
||||
],
|
||||
allowed,
|
||||
stock: {
|
||||
mode: 'TRAIN',
|
||||
remainingByTypeId: new Map([[nw6.id, 1]]),
|
||||
codesByTypeId: new Map([[nw6.id, nw6.code]]),
|
||||
},
|
||||
});
|
||||
|
||||
// One wagon, two 20ft bookings: they TEU-share the single slot (legacy).
|
||||
expect(result.deferred).toHaveLength(0);
|
||||
expect(result.plan).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,8 +58,18 @@ type OpenSlot = {
|
||||
/** Kind purity: a bulk wagon carries ONE cargo type at a time. */
|
||||
cargoTypeId: string | null;
|
||||
freeCapacityTons: number;
|
||||
/**
|
||||
* Corridor leg this slot rides (`"from-to"` stop indexes). Bookings only
|
||||
* share a slot when their legs are identical — mixing corridors in one slot
|
||||
* would degrade it to a whole-route slot (see stampSlotLegs) and silently
|
||||
* re-occupy edges the cargo never rides.
|
||||
*/
|
||||
legKey: string;
|
||||
};
|
||||
|
||||
/** Stop-index range a booking occupies: edges `from..to-1` of the corridor. */
|
||||
export type BookingLeg = { from: number; to: number };
|
||||
|
||||
type PlacementProblem = {
|
||||
kind: 'config' | 'stock';
|
||||
message: string;
|
||||
@@ -87,7 +97,7 @@ const slotFromWagonType = (wagonType: WagonType, kind: SlotLoadType): WagonPlanS
|
||||
const shortageFor = (
|
||||
booking: Booking,
|
||||
candidates: WagonType[],
|
||||
remaining: Map<string, number>,
|
||||
availableOf: (wagonTypeId: string) => number,
|
||||
): BookingWagonShortage => {
|
||||
const wagonsNeeded =
|
||||
booking.freightType === 'BULK'
|
||||
@@ -100,7 +110,7 @@ const shortageFor = (
|
||||
)
|
||||
: Math.max(1, containerWagonsForLines(booking.bookingContainers ?? []));
|
||||
const wagonsAvailable = candidates.reduce(
|
||||
(sum, wt) => sum + (remaining.get(wt.id) ?? 0),
|
||||
(sum, wt) => sum + availableOf(wt.id),
|
||||
0,
|
||||
);
|
||||
return {
|
||||
@@ -140,14 +150,53 @@ export function planWagonsWithStock(params: {
|
||||
bookings: Booking[];
|
||||
allowed: AllowedWagonTypeMap;
|
||||
stock: WagonStock;
|
||||
/**
|
||||
* Leg-aware stock: booking id → the stop-index range it rides. When given
|
||||
* (with `edgeCount`), a wagon type's stock is consumed PER CORRIDOR EDGE, so
|
||||
* the same physical wagon can serve an intercity booking on Gelan→Adama and
|
||||
* an export booking on Adama→Doraleh — disjoint legs never compete for
|
||||
* stock. Omitted → one edge, byte-identical to the old whole-route behavior.
|
||||
*/
|
||||
legs?: Map<string, BookingLeg>;
|
||||
edgeCount?: number;
|
||||
}): FlexPlanResult {
|
||||
const { bookings, allowed, stock } = params;
|
||||
const remaining = new Map(stock.remainingByTypeId);
|
||||
const { bookings, allowed, stock, legs } = params;
|
||||
const edgeCount = Math.max(1, params.edgeCount ?? 1);
|
||||
const openSlots: OpenSlot[] = [];
|
||||
const fitting: Booking[] = [];
|
||||
const deferred: DeferredBookingRow[] = [];
|
||||
const configIssues = new Set<string>();
|
||||
|
||||
const legFor = (booking: Booking): BookingLeg => {
|
||||
const leg = legs?.get(booking.id);
|
||||
if (!leg || leg.from < 0 || leg.to > edgeCount || leg.from >= leg.to) {
|
||||
return { from: 0, to: edgeCount };
|
||||
}
|
||||
return leg;
|
||||
};
|
||||
const legKeyOf = (leg: BookingLeg) => `${leg.from}-${leg.to}`;
|
||||
|
||||
// Wagons of a type in use per corridor edge. A type is available for a leg
|
||||
// when its busiest edge WITHIN that leg still has stock spare — the max over
|
||||
// edges is the number of physical wagons the type needs simultaneously.
|
||||
const usedPerEdge = new Map<string, number[]>();
|
||||
const usedRow = (wagonTypeId: string): number[] => {
|
||||
let row = usedPerEdge.get(wagonTypeId);
|
||||
if (!row) {
|
||||
row = new Array<number>(edgeCount).fill(0);
|
||||
usedPerEdge.set(wagonTypeId, row);
|
||||
}
|
||||
return row;
|
||||
};
|
||||
const availableFor = (wagonTypeId: string, leg: BookingLeg): number => {
|
||||
const total = stock.remainingByTypeId.get(wagonTypeId) ?? 0;
|
||||
const row = usedPerEdge.get(wagonTypeId);
|
||||
if (!row) return total;
|
||||
let busiest = 0;
|
||||
for (let e = leg.from; e < leg.to; e += 1) busiest = Math.max(busiest, row[e] ?? 0);
|
||||
return total - busiest;
|
||||
};
|
||||
|
||||
const noStockMessage = (candidates: WagonType[]): string => {
|
||||
const codes = candidates.map((wt) => wt.code).join('/');
|
||||
return stock.mode === 'TRAIN'
|
||||
@@ -155,13 +204,14 @@ export function planWagonsWithStock(params: {
|
||||
: `No available ${codes} wagon at the yard`;
|
||||
};
|
||||
|
||||
/** Open a new wagon of one of the candidate types, consuming stock. */
|
||||
/** Open a new wagon of one of the candidate types, consuming stock on the leg's edges. */
|
||||
const openSlot = (
|
||||
candidates: WagonType[],
|
||||
kind: SlotLoadType,
|
||||
cargoTypeId: string | null,
|
||||
leg: BookingLeg,
|
||||
): OpenSlot | PlacementProblem => {
|
||||
const inStock = candidates.filter((wt) => (remaining.get(wt.id) ?? 0) > 0);
|
||||
const inStock = candidates.filter((wt) => availableFor(wt.id, leg) > 0);
|
||||
if (!inStock.length) {
|
||||
return { kind: 'stock', message: noStockMessage(candidates), candidates };
|
||||
}
|
||||
@@ -170,22 +220,26 @@ export function planWagonsWithStock(params: {
|
||||
const chosen = [...inStock].sort((a, b) =>
|
||||
kind === 'BULK'
|
||||
? Number(b.capacityTons) - Number(a.capacityTons) ||
|
||||
(remaining.get(b.id) ?? 0) - (remaining.get(a.id) ?? 0)
|
||||
: (remaining.get(b.id) ?? 0) - (remaining.get(a.id) ?? 0),
|
||||
availableFor(b.id, leg) - availableFor(a.id, leg)
|
||||
: availableFor(b.id, leg) - availableFor(a.id, leg),
|
||||
)[0];
|
||||
remaining.set(chosen.id, (remaining.get(chosen.id) ?? 0) - 1);
|
||||
const row = usedRow(chosen.id);
|
||||
for (let e = leg.from; e < leg.to; e += 1) row[e] = (row[e] ?? 0) + 1;
|
||||
const open: OpenSlot = {
|
||||
slot: slotFromWagonType(chosen, kind),
|
||||
teuUsed: 0,
|
||||
kind,
|
||||
cargoTypeId,
|
||||
freeCapacityTons: Number(chosen.capacityTons),
|
||||
legKey: legKeyOf(leg),
|
||||
};
|
||||
openSlots.push(open);
|
||||
return open;
|
||||
};
|
||||
|
||||
const tryPlaceBooking = (booking: Booking): PlacementProblem | null => {
|
||||
const leg = legFor(booking);
|
||||
const legKey = legKeyOf(leg);
|
||||
if (booking.freightType === 'CONTAINER') {
|
||||
const units = expandBookingContainerUnits([booking]);
|
||||
if (!units.length) {
|
||||
@@ -209,11 +263,12 @@ export function planWagonsWithStock(params: {
|
||||
let target = openSlots.find(
|
||||
(open) =>
|
||||
open.kind === 'CONTAINER' &&
|
||||
open.legKey === legKey &&
|
||||
allowedIds.has(open.slot.wagonTypeId) &&
|
||||
open.teuUsed + teu <= MAX_TEU_SLOTS_PER_WAGON,
|
||||
);
|
||||
if (!target) {
|
||||
const openedSlot = openSlot(candidates, 'CONTAINER', null);
|
||||
const openedSlot = openSlot(candidates, 'CONTAINER', null, leg);
|
||||
if ('message' in openedSlot) return openedSlot;
|
||||
target = openedSlot;
|
||||
}
|
||||
@@ -246,6 +301,7 @@ export function planWagonsWithStock(params: {
|
||||
for (const open of openSlots) {
|
||||
if (remainingWeight <= 0) break;
|
||||
if (open.kind !== 'BULK') continue;
|
||||
if (open.legKey !== legKey) continue;
|
||||
if (open.cargoTypeId !== cargoTypeId) continue;
|
||||
if (!allowedIds.has(open.slot.wagonTypeId)) continue;
|
||||
if (open.freeCapacityTons <= 0) continue;
|
||||
@@ -263,7 +319,7 @@ export function planWagonsWithStock(params: {
|
||||
}
|
||||
|
||||
while (remainingWeight > 0 || !placedAnywhere) {
|
||||
const openedSlot = openSlot(candidates, 'BULK', cargoTypeId);
|
||||
const openedSlot = openSlot(candidates, 'BULK', cargoTypeId, leg);
|
||||
if ('message' in openedSlot) return openedSlot;
|
||||
const take = roundTons(Math.min(openedSlot.freeCapacityTons, remainingWeight));
|
||||
addAllocation(
|
||||
@@ -282,7 +338,9 @@ export function planWagonsWithStock(params: {
|
||||
|
||||
for (const booking of sortBookingsForScheduling(bookings)) {
|
||||
// Snapshot so a booking that doesn't fully fit leaves no half-placed wagons.
|
||||
const remainingSnapshot = new Map(remaining);
|
||||
const usedSnapshot = new Map(
|
||||
[...usedPerEdge.entries()].map(([typeId, row]) => [typeId, [...row]]),
|
||||
);
|
||||
const slotCountSnapshot = openSlots.length;
|
||||
const slotStateSnapshot = openSlots.map((open) => ({
|
||||
teuUsed: open.teuUsed,
|
||||
@@ -299,8 +357,8 @@ export function planWagonsWithStock(params: {
|
||||
}
|
||||
|
||||
// Roll back this booking's partial placements.
|
||||
remaining.clear();
|
||||
for (const [key, value] of remainingSnapshot) remaining.set(key, value);
|
||||
usedPerEdge.clear();
|
||||
for (const [key, value] of usedSnapshot) usedPerEdge.set(key, value);
|
||||
openSlots.length = slotCountSnapshot;
|
||||
openSlots.forEach((open, index) => {
|
||||
const snap = slotStateSnapshot[index];
|
||||
@@ -315,11 +373,14 @@ export function planWagonsWithStock(params: {
|
||||
});
|
||||
|
||||
if (problem.kind === 'config') configIssues.add(problem.message);
|
||||
// remaining is rolled back here, so the shortage counts the stock this
|
||||
// Usage is rolled back here, so the shortage counts the stock this
|
||||
// booking actually saw — not what its own partial placement consumed.
|
||||
const bookingLeg = legFor(booking);
|
||||
const shortage =
|
||||
problem.kind === 'stock' && problem.candidates?.length
|
||||
? shortageFor(booking, problem.candidates, remaining)
|
||||
? shortageFor(booking, problem.candidates, (wagonTypeId) =>
|
||||
Math.max(0, availableFor(wagonTypeId, bookingLeg)),
|
||||
)
|
||||
: null;
|
||||
deferred.push({
|
||||
id: booking.id,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
containerWagonsForLines,
|
||||
expandBookingContainerUnits,
|
||||
expandContainerItems,
|
||||
maxEdgeConsistUsage,
|
||||
roundTons,
|
||||
sumWagonsRequired,
|
||||
validate20ftContainerRules,
|
||||
@@ -279,3 +280,54 @@ describe('containerWagonsForLines — TEU-aware, ceil booking total once', () =>
|
||||
expect(containerWagonsForLines([])).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('maxEdgeConsistUsage — the binding edge, not the whole-route sum', () => {
|
||||
const slot = (
|
||||
tare: number,
|
||||
cargo: number,
|
||||
length: number,
|
||||
board?: string | null,
|
||||
alight?: string | null,
|
||||
) =>
|
||||
({
|
||||
tareWeightTons: tare,
|
||||
assignedWeightTons: cargo,
|
||||
lengthMeters: length,
|
||||
boardYardId: board ?? null,
|
||||
alightYardId: alight ?? null,
|
||||
}) as never;
|
||||
|
||||
const stops = ['a', 'b', 'c'];
|
||||
|
||||
it('does not sum disjoint legs: intercity a→b + export b→c', () => {
|
||||
const plan = [
|
||||
slot(24, 65, 14, null, 'b'), // intercity, rides a→b only
|
||||
slot(24, 65, 14, 'b', null), // export, rides b→c only
|
||||
];
|
||||
// Each edge carries one slot: 89T gross / 14m — never 178T.
|
||||
expect(maxEdgeConsistUsage(plan, stops)).toEqual({
|
||||
grossWeightTons: 89,
|
||||
lengthMeters: 14,
|
||||
});
|
||||
});
|
||||
|
||||
it('sums overlapping legs on their shared edge (the S-2026-00024 shape)', () => {
|
||||
// 20 intercity a→b wagons + 20 export a→c wagons, 23.94T tare, 64.75T cargo:
|
||||
// shared edge a→b carries all 40 slots = 3547.6T gross.
|
||||
const plan = [
|
||||
...Array.from({ length: 20 }, () => slot(23.94, 64.75, 14, null, 'b')),
|
||||
...Array.from({ length: 20 }, () => slot(23.94, 64.75, 14, null, null)),
|
||||
];
|
||||
const usage = maxEdgeConsistUsage(plan, stops);
|
||||
expect(usage.grossWeightTons).toBeCloseTo(3547.6, 1);
|
||||
expect(usage.lengthMeters).toBe(560);
|
||||
});
|
||||
|
||||
it('degrades to whole-train totals on a two-stop route', () => {
|
||||
const plan = [slot(24, 65, 14), slot(24, 65, 14)];
|
||||
expect(maxEdgeConsistUsage(plan, ['a', 'b'])).toEqual({
|
||||
grossWeightTons: 178,
|
||||
lengthMeters: 28,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -525,6 +525,80 @@ export function validateMixedTrainLimits(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Leg-aware limit check: with a real stop list, a slot only counts on the
|
||||
* edges it actually rides (boardYardId→alightYardId; null = the schedule's
|
||||
* own endpoint). Each edge is validated as its own consist, so an intercity
|
||||
* wagon on Gelan→Adama never counts against a train that is full only on
|
||||
* Adama→Doraleh. Two stops (or fewer) degrade to the whole-train check.
|
||||
*/
|
||||
export function validateMixedTrainLimitsPerEdge(
|
||||
wagonPlan: WagonPlanSlot[],
|
||||
wagonTypes: Array<Pick<WagonType, 'lengthMeters'>>,
|
||||
limits: TrainLimitConfig | undefined,
|
||||
stops: string[],
|
||||
): string[] {
|
||||
if (stops.length <= 2) return validateMixedTrainLimits(wagonPlan, wagonTypes, limits);
|
||||
const spans = slotSpans(wagonPlan, stops);
|
||||
const violations = new Set<string>();
|
||||
for (let edge = 0; edge < stops.length - 1; edge += 1) {
|
||||
const active = wagonPlan.filter(
|
||||
(_, i) => spans[i].from <= edge && edge < spans[i].to,
|
||||
);
|
||||
if (!active.length) continue;
|
||||
for (const violation of validateMixedTrainLimits(active, wagonTypes, limits)) {
|
||||
violations.add(violation);
|
||||
}
|
||||
}
|
||||
return [...violations];
|
||||
}
|
||||
|
||||
/** Per-slot stop-index spans; a yard missing from the stop list keeps the slot on the whole route. */
|
||||
function slotSpans(
|
||||
wagonPlan: WagonPlanSlot[],
|
||||
stops: string[],
|
||||
): Array<{ from: number; to: number }> {
|
||||
const lastIdx = stops.length - 1;
|
||||
return wagonPlan.map((slot) => {
|
||||
const from = slot.boardYardId ? stops.indexOf(slot.boardYardId) : 0;
|
||||
const to = slot.alightYardId ? stops.indexOf(slot.alightYardId) : lastIdx;
|
||||
return { from: from >= 0 ? from : 0, to: to > 0 ? to : lastIdx };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The corridor's binding edge: gross tons (tare + assigned cargo) and length
|
||||
* summed over only the slots riding each edge, maxed across edges. This is the
|
||||
* figure a locomotive pull/length limit must be compared against — a train is
|
||||
* never heavier than its heaviest single leg, so summing disjoint legs
|
||||
* (intercity Gelan→Adama + export Adama→Doraleh) over-reports the train.
|
||||
* Two stops or fewer degrade to the whole-train totals.
|
||||
*/
|
||||
export function maxEdgeConsistUsage(
|
||||
wagonPlan: WagonPlanSlot[],
|
||||
stops: string[],
|
||||
): { grossWeightTons: number; lengthMeters: number } {
|
||||
const totals = (slots: WagonPlanSlot[]) => ({
|
||||
grossWeightTons: slots.reduce(
|
||||
(sum, w) =>
|
||||
sum + Number(w.tareWeightTons ?? 0) + Number(w.assignedWeightTons ?? 0),
|
||||
0,
|
||||
),
|
||||
lengthMeters: slots.reduce((sum, w) => sum + Number(w.lengthMeters ?? 0), 0),
|
||||
});
|
||||
if (stops.length <= 2) return totals(wagonPlan);
|
||||
const spans = slotSpans(wagonPlan, stops);
|
||||
const usage = { grossWeightTons: 0, lengthMeters: 0 };
|
||||
for (let edge = 0; edge < stops.length - 1; edge += 1) {
|
||||
const active = totals(
|
||||
wagonPlan.filter((_, i) => spans[i].from <= edge && edge < spans[i].to),
|
||||
);
|
||||
usage.grossWeightTons = Math.max(usage.grossWeightTons, active.grossWeightTons);
|
||||
usage.lengthMeters = Math.max(usage.lengthMeters, active.lengthMeters);
|
||||
}
|
||||
return usage;
|
||||
}
|
||||
|
||||
export function validate20ftContainerRules(
|
||||
units: ContainerUnitRow[],
|
||||
placements: ContainerPlacementInput[],
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
|
||||
import { BuildTrainDto } from './dto/build-train.dto';
|
||||
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
|
||||
@@ -27,12 +28,12 @@ import { TrainBuilderService } from './train-builder.service';
|
||||
@ApiTags('train-builder')
|
||||
@ApiBearerAuth()
|
||||
@Controller('train-builder')
|
||||
@FleetView()
|
||||
@FleetView(FREIGHT_PERMS.trains.view)
|
||||
export class TrainBuilderController {
|
||||
constructor(private readonly trainBuilderService: TrainBuilderService) {}
|
||||
|
||||
@Post()
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.trains.create)
|
||||
@ApiOperation({ summary: 'Build a train: code + yard + 2+ locomotives (+ optional wagons)' })
|
||||
build(@Body() dto: BuildTrainDto) {
|
||||
return this.trainBuilderService.buildTrain(dto);
|
||||
@@ -60,7 +61,7 @@ export class TrainBuilderController {
|
||||
}
|
||||
|
||||
@Put(':id/locomotives')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.trains.update)
|
||||
@ApiOperation({ summary: 'Replace the locomotive set (minimum 2, same yard)' })
|
||||
setLocomotives(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -70,7 +71,7 @@ export class TrainBuilderController {
|
||||
}
|
||||
|
||||
@Patch(':id/details')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.trains.update)
|
||||
@ApiOperation({
|
||||
summary: "Edit the train's name and fixed import/export run numbers",
|
||||
})
|
||||
@@ -82,7 +83,7 @@ export class TrainBuilderController {
|
||||
}
|
||||
|
||||
@Patch(':id/yard')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.trains.update)
|
||||
@ApiOperation({
|
||||
summary: 'Relocate the train — its locomotives and wagons move to the new yard with it',
|
||||
})
|
||||
@@ -91,14 +92,14 @@ export class TrainBuilderController {
|
||||
}
|
||||
|
||||
@Post(':id/wagons')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||
@ApiOperation({ summary: "Append AVAILABLE wagons from the train's yard to the consist" })
|
||||
assignWagons(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignTrainWagonsDto) {
|
||||
return this.trainBuilderService.assignWagons(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id/wagons/:wagonId')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||
@ApiOperation({ summary: 'Detach one wagon from the consist' })
|
||||
removeWagon(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -108,7 +109,7 @@ export class TrainBuilderController {
|
||||
}
|
||||
|
||||
@Post(':id/wagons/:wagonId/maintenance')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||
@ApiOperation({ summary: 'Detach one wagon and move it to MAINTENANCE status' })
|
||||
sendWagonToMaintenance(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -118,14 +119,14 @@ export class TrainBuilderController {
|
||||
}
|
||||
|
||||
@Post(':id/reorder-wagons')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||
@ApiOperation({ summary: 'Persist a drag-reorder of the full consist' })
|
||||
reorderWagons(@Param('id', ParseUUIDPipe) id: string, @Body() dto: ReorderTrainWagonsDto) {
|
||||
return this.trainBuilderService.reorderWagons(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/deactivate')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.trains.update)
|
||||
@ApiOperation({
|
||||
summary: 'Deactivate the train (park it) — only allowed with no active schedule',
|
||||
})
|
||||
@@ -134,14 +135,14 @@ export class TrainBuilderController {
|
||||
}
|
||||
|
||||
@Post(':id/activate')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.trains.update)
|
||||
@ApiOperation({ summary: 'Reactivate a deactivated train back to AVAILABLE' })
|
||||
activate(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.trainBuilderService.activate(id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.trains.delete)
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Disband the train (release wagons and locomotives)' })
|
||||
disband(@Param('id', ParseUUIDPipe) id: string) {
|
||||
|
||||
@@ -12,18 +12,19 @@ import {
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { FleetManage, FleetView } from "../../common/booking-guards";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { CreateTrainDto } from "./dto/create-train.dto";
|
||||
import { UpdateTrainDto } from "./dto/update-train.dto";
|
||||
import { TrainsService } from "./trains.service";
|
||||
|
||||
@ApiTags("trains")
|
||||
@Controller("trains")
|
||||
@FleetView()
|
||||
@FleetView(FREIGHT_PERMS.trains.view)
|
||||
export class TrainsController {
|
||||
constructor(private readonly trainsService: TrainsService) {}
|
||||
|
||||
@Post()
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.trains.create)
|
||||
@ApiOperation({ summary: "Register a new train" })
|
||||
create(@Body() dto: CreateTrainDto) {
|
||||
return this.trainsService.create(dto);
|
||||
@@ -42,14 +43,14 @@ export class TrainsController {
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.trains.update)
|
||||
@ApiOperation({ summary: "Update a train" })
|
||||
update(@Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateTrainDto) {
|
||||
return this.trainsService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.trains.delete)
|
||||
@ApiOperation({ summary: "Delete a train" })
|
||||
remove(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.trainsService.remove(id);
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
WagonTransferHistoryAll,
|
||||
WagonTransferRequest,
|
||||
} from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { BulkFulfillTransferRequestsDto } from './dto/bulk-fulfill-transfer-requests.dto';
|
||||
import { CreateTransferRequestDto } from './dto/create-transfer-request.dto';
|
||||
import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto';
|
||||
@@ -31,7 +32,7 @@ import { WagonTransferRequestsService } from './wagon-transfer-requests.service'
|
||||
*/
|
||||
@ApiTags('wagon-transfer-requests')
|
||||
@Controller('wagon-transfer-requests')
|
||||
@FleetView()
|
||||
@FleetView(FREIGHT_PERMS.wagons.view)
|
||||
export class WagonTransferRequestsController {
|
||||
constructor(private readonly service: WagonTransferRequestsService) {}
|
||||
|
||||
@@ -110,7 +111,7 @@ export class WagonTransferRequestsController {
|
||||
}
|
||||
|
||||
@Post(':id/cancel')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.wagons.transferRequest)
|
||||
@ApiOperation({ summary: 'Withdraw a pending transfer request' })
|
||||
cancel(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.cancelRequest(id);
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||
import { FleetManage, FleetView, StaffReference } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { CreateWagonDto } from './dto/create-wagon.dto';
|
||||
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
|
||||
import { UpdateWagonDto } from './dto/update-wagon.dto';
|
||||
@@ -23,31 +24,36 @@ import { BulkSetWagonStatusDto } from './dto/bulk-set-wagon-status.dto';
|
||||
import { WagonsService } from './wagons.service';
|
||||
|
||||
@ApiTags('wagons')
|
||||
// No class-level guard: reads (list, by-id, movements) are login-only reference
|
||||
// data — any staff can fetch wagon data for a cross-flow view without the
|
||||
// fleet:view that drives the Fleet sidebar. Every mutation has its @FleetManage().
|
||||
@Controller('wagons')
|
||||
@FleetView()
|
||||
export class WagonsController {
|
||||
constructor(private readonly wagonsService: WagonsService) {}
|
||||
|
||||
@Post()
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.wagons.create)
|
||||
@ApiOperation({ summary: 'Create a new wagon' })
|
||||
create(@Body() dto: CreateWagonDto) {
|
||||
return this.wagonsService.create(dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'List all wagons' })
|
||||
findAll(@Query() query: ListWagonsQueryDto) {
|
||||
return this.wagonsService.findAll(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'Get a wagon by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.wagonsService.findById(id);
|
||||
}
|
||||
|
||||
@Get(':id/movements')
|
||||
@StaffReference()
|
||||
@ApiOperation({
|
||||
summary: "Wagon movement ledger (loaded legs, empty repositions, manual moves), newest first",
|
||||
})
|
||||
@@ -56,42 +62,42 @@ export class WagonsController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.wagons.update)
|
||||
@ApiOperation({ summary: 'Update a wagon' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWagonDto) {
|
||||
return this.wagonsService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.wagons.delete)
|
||||
@ApiOperation({ summary: 'Delete a wagon' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.wagonsService.remove(id);
|
||||
}
|
||||
|
||||
@Post(':id/assign-train')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||
@ApiOperation({ summary: 'Assign wagon to a train' })
|
||||
assignToTrain(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignWagonToTrainDto) {
|
||||
return this.wagonsService.assignToTrain(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/unassign-train')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||
@ApiOperation({ summary: 'Unassign wagon from train' })
|
||||
unassignFromTrain(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.wagonsService.unassignFromTrain(id);
|
||||
}
|
||||
|
||||
@Post('bulk-transfer')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.wagons.update)
|
||||
@ApiOperation({ summary: 'Transfer multiple wagons to a destination yard' })
|
||||
bulkTransfer(@Body() dto: BulkTransferWagonsDto, @CurrentUser() user: TCurrentUser) {
|
||||
return this.wagonsService.bulkTransfer(dto, user?.id);
|
||||
}
|
||||
|
||||
@Post('bulk-status')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.wagons.update)
|
||||
@ApiOperation({ summary: 'Set the status of multiple wagons' })
|
||||
bulkSetStatus(@Body() dto: BulkSetWagonStatusDto) {
|
||||
return this.wagonsService.bulkSetStatus(dto);
|
||||
@@ -100,12 +106,12 @@ export class WagonsController {
|
||||
|
||||
// Separate controller for train‑specific reorder (registered in module)
|
||||
@Controller('trains/:trainId/reorder-wagons')
|
||||
@FleetView()
|
||||
@FleetView(FREIGHT_PERMS.trains.view)
|
||||
export class TrainWagonsReorderController {
|
||||
constructor(private readonly wagonsService: WagonsService) {}
|
||||
|
||||
@Post()
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||
@ApiOperation({ summary: 'Reorder wagons of a train' })
|
||||
reorder(@Param('trainId', ParseUUIDPipe) trainId: string, @Body() dto: ReorderWagonsDto) {
|
||||
return this.wagonsService.reorderWagons(trainId, dto);
|
||||
|
||||
@@ -52,4 +52,8 @@ export class BookingHandover extends BaseEntity {
|
||||
/** EDR last-mile: when the goods were delivered to the customer. */
|
||||
@Column({ name: 'delivered_at', type: 'timestamptz', nullable: true })
|
||||
deliveredAt?: Date | null;
|
||||
|
||||
/** URL to the signer's saved signature image, if available at sign time. */
|
||||
@Column({ name: 'signature_image_url', type: 'text', nullable: true })
|
||||
signatureImageUrl?: string | null;
|
||||
}
|
||||
|
||||
@@ -287,6 +287,7 @@ export class HandoverService {
|
||||
handoverId: string,
|
||||
userId?: string | null,
|
||||
signerName?: string | null,
|
||||
signatureImageUrl?: string | null,
|
||||
): Promise<BookingHandover> {
|
||||
const repo = this.dataSource.getRepository(BookingHandover);
|
||||
const handover = await repo.findOne({ where: { id: handoverId } });
|
||||
@@ -297,6 +298,7 @@ export class HandoverService {
|
||||
handover.signedAt = new Date();
|
||||
handover.signedByUserId = userId ?? null;
|
||||
handover.signerName = signerName?.trim() || null;
|
||||
handover.signatureImageUrl = signatureImageUrl ?? null;
|
||||
return repo.save(handover);
|
||||
}
|
||||
|
||||
@@ -305,6 +307,7 @@ export class HandoverService {
|
||||
bookingId: string,
|
||||
userId?: string | null,
|
||||
signerName?: string | null,
|
||||
signatureImageUrl?: string | null,
|
||||
): Promise<void> {
|
||||
await this.dataSource
|
||||
.getRepository(BookingHandover)
|
||||
@@ -314,6 +317,7 @@ export class HandoverService {
|
||||
signedAt: new Date(),
|
||||
signedByUserId: userId ?? null,
|
||||
signerName: signerName?.trim() || null,
|
||||
signatureImageUrl: signatureImageUrl ?? null,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1578,6 +1578,14 @@ export class WarehouseInventoryService {
|
||||
notes: `Bulk received (${dto.direction})`,
|
||||
truckEntrance,
|
||||
});
|
||||
|
||||
// Validate capacity before saving
|
||||
const weight = Number(booking.weight) || 0;
|
||||
const containerCount = booking.freightType === 'CONTAINER' ? containerQuantity : 0;
|
||||
this.assertCapacity('Warehouse', warehouse, weight, 0, containerCount);
|
||||
this.assertCapacity('Yard', yard, weight, 0, containerCount);
|
||||
this.assertCapacity('Zone', zone, weight, 0, containerCount);
|
||||
|
||||
const saved = await manager.getRepository(WarehouseInventory).save(
|
||||
manager.getRepository(WarehouseInventory).create({
|
||||
warehouseId: dto.warehouseId,
|
||||
@@ -1585,7 +1593,7 @@ export class WarehouseInventoryService {
|
||||
zoneId: dto.zoneId,
|
||||
bookingId,
|
||||
quantity: booking.freightType === 'CONTAINER' ? containerQuantity : 1,
|
||||
weight: Number(booking.weight) || 0,
|
||||
weight,
|
||||
grnNumber,
|
||||
status: 'RECEIVED',
|
||||
arrivedAt: now,
|
||||
@@ -1593,6 +1601,9 @@ export class WarehouseInventoryService {
|
||||
}),
|
||||
);
|
||||
|
||||
// Update warehouse/yard/zone capacity counters
|
||||
await this.applyCapacityDelta(manager, dto, weight, 0, containerCount);
|
||||
|
||||
// Receiving the booking flags every container unit as received into the
|
||||
// port (self-haul export: the delivering truck's goods are now in) so
|
||||
// staff can raise the per-container GRN over what's received.
|
||||
@@ -3011,6 +3022,29 @@ export class WarehouseInventoryService {
|
||||
);
|
||||
}
|
||||
|
||||
// A truck leaves with the containers ASSIGNED to it — never another
|
||||
// truck's. Enforced whenever the truck has an assigned load on file
|
||||
// (customer self-haul or EDR last-mile).
|
||||
if (dto.containerNumber && dto.truckPlateNumber?.trim()) {
|
||||
const selectedNumbers = dto.containerNumber
|
||||
.split(/[,;\n]+/)
|
||||
.map((n) => n.trim().toUpperCase())
|
||||
.filter(Boolean);
|
||||
const assigned = await this.truckAssignedContainers(
|
||||
item.bookingId,
|
||||
dto.truckPlateNumber.trim(),
|
||||
);
|
||||
if (assigned.length && selectedNumbers.length) {
|
||||
const foreign = selectedNumbers.filter((n) => !assigned.includes(n));
|
||||
if (foreign.length) {
|
||||
throw new BadRequestException(
|
||||
`Container${foreign.length > 1 ? 's' : ''} ${foreign.join(', ')} ` +
|
||||
`not assigned to truck ${dto.truckPlateNumber.trim()} — each truck may only carry out its own assigned containers`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Authoritative weight match: the truck's net (gross − tare) must equal the
|
||||
// total VGM cargo weight of the containers selected as loaded on it.
|
||||
// Skipped when the operator chose not to weigh (containers only).
|
||||
@@ -3347,6 +3381,17 @@ export class WarehouseInventoryService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* customer_truck_assignments.gross_weight_kg holds TONNES for gate-out
|
||||
* recorded exits but real KG for legacy departTruck rows. Exit papers always
|
||||
* print tonnes — normalise on read.
|
||||
*/
|
||||
// ponytail: >1000 heuristic (no truck hauls 1000+ t, no weighbridge reads <1000 kg);
|
||||
// migrate the column to tonnes if it ever bites.
|
||||
private grossAsTons(value: number): number {
|
||||
return value > 1000 ? Math.round(value) / 1000 : value;
|
||||
}
|
||||
|
||||
async releaseDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT inv.id,
|
||||
@@ -3406,7 +3451,40 @@ export class WarehouseInventoryService {
|
||||
grossWeightKg: string | number | null;
|
||||
departedAt: string | null;
|
||||
} | null = null;
|
||||
if (row?.tradeDirection === 'IMPORT' && row?.containerNumber && row?.bookingId) {
|
||||
// The exit-inspection note written at gate-out names the truck doing THIS
|
||||
// exit — resolve by its plate first. The item's own container may not be on
|
||||
// the departing truck at all (trucks pick containers freely per trip).
|
||||
const notePlates = [...String(row?.notes ?? '').matchAll(/Truck Plate:\s*(\S+)/gi)];
|
||||
const exitPlate = notePlates.length ? notePlates[notePlates.length - 1][1] : null;
|
||||
if (row?.tradeDirection === 'IMPORT' && row?.bookingId && exitPlate) {
|
||||
const [truckRow] = await this.dataSource.query(
|
||||
`SELECT a.plate_number AS "plateNumber",
|
||||
a.driver_name AS "driverName",
|
||||
a.truck_type AS "truckType",
|
||||
a.gross_weight_kg AS "grossWeightKg",
|
||||
a.departed_at AS "departedAt",
|
||||
string_agg(DISTINCT c.container_number, ', ' ORDER BY c.container_number) AS "containerNumbers",
|
||||
COALESCE((
|
||||
SELECT SUM(bcu.vgm_tons)
|
||||
FROM freight.customer_truck_containers cc
|
||||
JOIN freight.booking_container_units bcu
|
||||
ON bcu.container_number = cc.container_number AND bcu.deleted_at IS NULL
|
||||
JOIN freight.booking_container bc
|
||||
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||
AND bc.booking_id = a.booking_id
|
||||
WHERE cc.assignment_id = a.id AND cc.deleted_at IS NULL
|
||||
), 0) AS "truckWeightTons"
|
||||
FROM freight.customer_truck_assignments a
|
||||
LEFT JOIN freight.customer_truck_containers c
|
||||
ON c.assignment_id = a.id AND c.deleted_at IS NULL
|
||||
WHERE a.booking_id = $1 AND UPPER(a.plate_number) = UPPER($2) AND a.deleted_at IS NULL
|
||||
GROUP BY a.id, a.plate_number, a.driver_name, a.truck_type, a.gross_weight_kg, a.departed_at
|
||||
LIMIT 1`,
|
||||
[row.bookingId, exitPlate],
|
||||
);
|
||||
truck = truckRow ?? null;
|
||||
}
|
||||
if (!truck && row?.tradeDirection === 'IMPORT' && row?.containerNumber && row?.bookingId) {
|
||||
const [truckRow] = await this.dataSource.query(
|
||||
`SELECT a.plate_number AS "plateNumber",
|
||||
a.driver_name AS "driverName",
|
||||
@@ -3436,6 +3514,26 @@ export class WarehouseInventoryService {
|
||||
);
|
||||
truck = truckRow ?? null;
|
||||
}
|
||||
// Bulk self-haul (no container to match) or an unmatched container: the exit
|
||||
// paper is still PER TRUCK — use the latest departed customer truck and its
|
||||
// weighed gross, never the booking's declared total.
|
||||
if (!truck && row?.tradeDirection === 'IMPORT' && row?.bookingId) {
|
||||
const [truckRow] = await this.dataSource.query(
|
||||
`SELECT a.plate_number AS "plateNumber",
|
||||
a.driver_name AS "driverName",
|
||||
a.truck_type AS "truckType",
|
||||
a.gross_weight_kg AS "grossWeightKg",
|
||||
a.departed_at AS "departedAt",
|
||||
NULL AS "containerNumbers",
|
||||
a.net_weight_tons AS "truckWeightTons"
|
||||
FROM freight.customer_truck_assignments a
|
||||
WHERE a.booking_id = $1 AND a.deleted_at IS NULL AND a.departed_at IS NOT NULL
|
||||
ORDER BY a.departed_at DESC
|
||||
LIMIT 1`,
|
||||
[row.bookingId],
|
||||
);
|
||||
truck = truckRow ?? null;
|
||||
}
|
||||
|
||||
const bookingReference = row?.bookingReference || 'N/A';
|
||||
const reference =
|
||||
@@ -3450,7 +3548,9 @@ export class WarehouseInventoryService {
|
||||
customerName: row?.customerName ?? null,
|
||||
freightType: row?.freightType ?? null,
|
||||
tradeDirection: row?.tradeDirection ?? null,
|
||||
containerNumber: row?.containerNumber ?? null,
|
||||
// Per-truck exit: list every container leaving on THIS truck, not just
|
||||
// the inventory item's own container.
|
||||
containerNumber: truck?.containerNumbers ?? row?.containerNumber ?? null,
|
||||
cargoDescription: row?.cargoDescription ?? null,
|
||||
quantity: Number(row?.quantity ?? 0),
|
||||
weight: Number(row?.weight ?? 0),
|
||||
@@ -3464,12 +3564,12 @@ export class WarehouseInventoryService {
|
||||
truckDriverName: truck?.driverName ?? null,
|
||||
truckType: truck?.truckType ?? null,
|
||||
truckGateOut: truck?.departedAt ?? null,
|
||||
// Prefer the weighed gross captured on departure; fall back to the summed
|
||||
// container VGM when the truck hasn't been weighed yet.
|
||||
truckWeightKg: truck
|
||||
? Number(truck.grossWeightKg ?? 0) > 0
|
||||
? Number(truck.grossWeightKg)
|
||||
: Number(truck.truckWeightTons ?? 0) * 1000
|
||||
// Per-truck load in tonnes: the summed VGM of the containers on this truck
|
||||
// (recorded net for bulk); the weighed gross only as fallback.
|
||||
truckWeightTons: truck
|
||||
? Number(truck.truckWeightTons ?? 0) > 0
|
||||
? Number(truck.truckWeightTons)
|
||||
: this.grossAsTons(Number(truck.grossWeightKg ?? 0))
|
||||
: null,
|
||||
});
|
||||
|
||||
@@ -3587,6 +3687,32 @@ export class WarehouseInventoryService {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Container numbers assigned to a truck (by plate) on this booking, from both
|
||||
* haulage paths: customer self-haul (customer_truck_containers) and EDR
|
||||
* last-mile (last_mile_vehicle_containers / legacy scalar). Uppercased.
|
||||
*/
|
||||
private async truckAssignedContainers(bookingId: string, plate: string): Promise<string[]> {
|
||||
const rows: Array<{ cn: string | null }> = await this.dataSource.query(
|
||||
`SELECT UPPER(cc.container_number) AS cn
|
||||
FROM freight.customer_truck_assignments a
|
||||
JOIN freight.customer_truck_containers cc
|
||||
ON cc.assignment_id = a.id AND cc.deleted_at IS NULL
|
||||
WHERE a.booking_id = $1 AND UPPER(a.plate_number) = UPPER($2) AND a.deleted_at IS NULL
|
||||
UNION
|
||||
SELECT UPPER(COALESCE(vc.container_number, va.container_number)) AS cn
|
||||
FROM freight.last_mile_vehicle_assignments va
|
||||
JOIN freight.last_mile l ON l.id = va.last_mile_id AND l.deleted_at IS NULL
|
||||
LEFT JOIN freight.last_mile_vehicle_containers vc
|
||||
ON vc.assignment_id = va.id AND vc.deleted_at IS NULL
|
||||
JOIN freight.vehicles v ON v.id = va.vehicle_id
|
||||
WHERE l.booking_id = $1 AND va.deleted_at IS NULL
|
||||
AND (UPPER(v.power_plate_no) = UPPER($2) OR UPPER(v.plate_number) = UPPER($2))`,
|
||||
[bookingId, plate],
|
||||
);
|
||||
return rows.map((r) => r.cn).filter((n): n is string => Boolean(n));
|
||||
}
|
||||
|
||||
/**
|
||||
* The booking's containers with their VGM cargo weight (tonnes), keyed by
|
||||
* container number. Drives the truck-leaving exit weighing: the selected
|
||||
@@ -3642,10 +3768,17 @@ export class WarehouseInventoryService {
|
||||
);
|
||||
if (inv?.id) await this.invoices.assertClearanceAllowed(inv.id);
|
||||
|
||||
const containers: Array<{ containerNumber: string; goods: string | null }> =
|
||||
const containers: Array<{ containerNumber: string; goods: string | null; vgmTons: string | null }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT c.container_number AS "containerNumber",
|
||||
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods
|
||||
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods,
|
||||
(SELECT SUM(u.vgm_tons)
|
||||
FROM freight.booking_container_units u
|
||||
JOIN freight.booking_container bc
|
||||
ON bc.id = u.booking_container_id AND bc.deleted_at IS NULL
|
||||
WHERE u.container_number = c.container_number
|
||||
AND bc.booking_id = c.booking_id
|
||||
AND u.deleted_at IS NULL) AS "vgmTons"
|
||||
FROM freight.customer_truck_containers c
|
||||
JOIN freight.bookings b ON b.id = c.booking_id
|
||||
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
|
||||
@@ -3654,6 +3787,9 @@ export class WarehouseInventoryService {
|
||||
[assignmentId],
|
||||
);
|
||||
|
||||
// Truck load in tonnes: summed container VGM; the weighed gross only as
|
||||
// fallback (bulk trucks carry no containers).
|
||||
const vgmSum = containers.reduce((s, c) => s + (Number(c.vgmTons) || 0), 0);
|
||||
const html = this.buildTruckExitPaperHtml({
|
||||
reference: `REL-${String(truck.bookingReference).replace(/^BK-?/i, '')}-${truck.plateNumber}`,
|
||||
bookingReference: truck.bookingReference,
|
||||
@@ -3661,7 +3797,7 @@ export class WarehouseInventoryService {
|
||||
plateNumber: truck.plateNumber,
|
||||
driverName: truck.driverName,
|
||||
truckType: truck.truckType,
|
||||
grossWeightKg: Number(truck.grossWeightKg ?? 0),
|
||||
grossWeightKg: vgmSum > 0 ? vgmSum : this.grossAsTons(Number(truck.grossWeightKg ?? 0)),
|
||||
gateOut: truck.departedAt,
|
||||
containers,
|
||||
});
|
||||
@@ -3951,10 +4087,11 @@ export class WarehouseInventoryService {
|
||||
await this.invoices.assertClearanceAllowed(item.id);
|
||||
|
||||
const approvedAt = new Date();
|
||||
const signatureImageUrl = signature?.signatureImageUrl ?? null;
|
||||
const approval = {
|
||||
approvedAt: approvedAt.toISOString(),
|
||||
signerDisplayName: name,
|
||||
signatureImageUrl: signature?.signatureImageUrl ?? null,
|
||||
signatureImageUrl,
|
||||
userId,
|
||||
};
|
||||
const existingNotes = this.stripCustomerDeliveryApproval(item.notes);
|
||||
@@ -3978,7 +4115,7 @@ export class WarehouseInventoryService {
|
||||
|
||||
// Sign the structured handover record(s) for this booking (self-haul: before
|
||||
// the truck leaves). Kept alongside the legacy approval note.
|
||||
await this.handover.signForBooking(bookingId, userId, name);
|
||||
await this.handover.signForBooking(bookingId, userId, name, signatureImageUrl);
|
||||
|
||||
return {
|
||||
bookingId,
|
||||
@@ -4013,6 +4150,8 @@ export class WarehouseInventoryService {
|
||||
throw new BadRequestException('Please enter your full name to sign the handover');
|
||||
}
|
||||
|
||||
const signature = await this.signatures.getForUser(userId).catch(() => null);
|
||||
|
||||
const [h]: Array<{
|
||||
bookingId: string;
|
||||
reference: string;
|
||||
@@ -4045,7 +4184,7 @@ export class WarehouseInventoryService {
|
||||
);
|
||||
if (inv) await this.invoices.assertClearanceAllowed(inv.id);
|
||||
|
||||
const signed = await this.handover.sign(handoverId, userId, name);
|
||||
const signed = await this.handover.sign(handoverId, userId, name, signature?.signatureImageUrl ?? null);
|
||||
const allSigned = await this.handover.isFullySigned(h.bookingId);
|
||||
|
||||
if (inv) {
|
||||
@@ -5168,7 +5307,7 @@ export class WarehouseInventoryService {
|
||||
truckDriverName?: string | null;
|
||||
truckType?: string | null;
|
||||
truckGateOut?: string | null;
|
||||
truckWeightKg?: number | null;
|
||||
truckWeightTons?: number | null;
|
||||
}): string {
|
||||
const esc = (value: unknown) =>
|
||||
String(value ?? '-')
|
||||
@@ -5195,8 +5334,8 @@ export class WarehouseInventoryService {
|
||||
['Quantity', data.quantity],
|
||||
[
|
||||
data.truckPlateNumber ? 'Gross Weight (Loaded on Truck)' : 'Declared Weight',
|
||||
`${(data.truckPlateNumber && data.truckWeightKg
|
||||
? data.truckWeightKg
|
||||
`${(data.truckPlateNumber && data.truckWeightTons
|
||||
? data.truckWeightTons
|
||||
: data.weight
|
||||
).toLocaleString()} t`,
|
||||
],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { BookingStaff, StaffReference } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto';
|
||||
import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto';
|
||||
@@ -10,8 +10,9 @@ import { WarehouseZonesService } from './warehouse-zones.service';
|
||||
|
||||
@ApiTags('warehouse-yards')
|
||||
@ApiBearerAuth()
|
||||
// No class-level guard: the two reference GETs are open to any signed-in
|
||||
// staff (StaffReference), every other route carries its own permission.
|
||||
@Controller('warehouse-yards')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseYards.view)
|
||||
export class WarehouseYardsController {
|
||||
constructor(
|
||||
private readonly yardsService: WarehouseYardsService,
|
||||
@@ -19,12 +20,14 @@ export class WarehouseYardsController {
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'List all warehouse yards' })
|
||||
findAll() {
|
||||
return this.yardsService.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'Get warehouse yard by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.yardsService.findById(id);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto';
|
||||
import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto';
|
||||
@@ -44,6 +44,7 @@ export class WarehouseYardsService {
|
||||
// Ensure the parent warehouse exists.
|
||||
await this.warehousesService.findById(warehouseId);
|
||||
await this.assertCodeUnique(warehouseId, dto.code.trim());
|
||||
await this.assertCapacityWithinWarehouse(warehouseId, dto.capacityWeight ?? null, dto.capacityContainers ?? null);
|
||||
|
||||
return this.yardsRepository.create({
|
||||
warehouseId,
|
||||
@@ -69,14 +70,22 @@ export class WarehouseYardsService {
|
||||
await this.assertCodeUnique(existing.warehouseId, dto.code.trim(), id);
|
||||
}
|
||||
|
||||
const newCapacityWeight = dto.capacityWeight ?? existing.capacityWeight ?? null;
|
||||
const newCapacityContainers = dto.capacityContainers ?? existing.capacityContainers ?? null;
|
||||
|
||||
// Validate updated capacity doesn't exceed warehouse limits
|
||||
if (newCapacityWeight !== (existing.capacityWeight ?? null) || newCapacityContainers !== (existing.capacityContainers ?? null)) {
|
||||
await this.assertCapacityWithinWarehouse(existing.warehouseId, newCapacityWeight, newCapacityContainers, id);
|
||||
}
|
||||
|
||||
const status = dto.status ?? existing.status;
|
||||
|
||||
const updated = await this.yardsRepository.update(id, {
|
||||
name: dto.name?.trim() ?? existing.name,
|
||||
code: dto.code?.trim() ?? existing.code,
|
||||
type: dto.type ?? existing.type,
|
||||
capacityWeight: dto.capacityWeight ?? existing.capacityWeight,
|
||||
capacityContainers: dto.capacityContainers ?? existing.capacityContainers,
|
||||
capacityWeight: newCapacityWeight,
|
||||
capacityContainers: newCapacityContainers,
|
||||
maxWeight: dto.maxWeight ?? existing.maxWeight,
|
||||
maxVolume: dto.maxVolume ?? existing.maxVolume,
|
||||
status,
|
||||
@@ -97,4 +106,39 @@ export class WarehouseYardsService {
|
||||
throw new ConflictException(`Yard code ${code} already exists in this warehouse`);
|
||||
}
|
||||
}
|
||||
|
||||
private async assertCapacityWithinWarehouse(
|
||||
warehouseId: string,
|
||||
newCapacityWeight: number | null,
|
||||
newCapacityContainers: number | null,
|
||||
excludeYardId?: string,
|
||||
): Promise<void> {
|
||||
const warehouse = await this.warehousesService.findById(warehouseId);
|
||||
const yards = await this.findByWarehouse(warehouseId);
|
||||
|
||||
// Sum existing yard capacities, excluding the yard being updated if provided
|
||||
const otherYards = excludeYardId ? yards.filter((y) => y.id !== excludeYardId) : yards;
|
||||
const totalExistingWeight = otherYards.reduce((sum, y) => sum + (y.capacityWeight ?? 0), 0);
|
||||
const totalExistingContainers = otherYards.reduce((sum, y) => sum + (y.capacityContainers ?? 0), 0);
|
||||
|
||||
// Check weight capacity
|
||||
if (newCapacityWeight !== null && warehouse.capacityWeight != null) {
|
||||
const totalWeight = totalExistingWeight + newCapacityWeight;
|
||||
if (totalWeight > warehouse.capacityWeight) {
|
||||
throw new BadRequestException(
|
||||
`Total yard weight capacity (${totalWeight}t) exceeds warehouse limit (${warehouse.capacityWeight}t)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check container capacity
|
||||
if (newCapacityContainers !== null && warehouse.capacityContainers != null) {
|
||||
const totalContainers = totalExistingContainers + newCapacityContainers;
|
||||
if (totalContainers > warehouse.capacityContainers) {
|
||||
throw new BadRequestException(
|
||||
`Total yard container capacity (${totalContainers}) exceeds warehouse limit (${warehouse.capacityContainers})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto';
|
||||
import { UpdateWarehouseZoneDto } from './dto/update-warehouse-zone.dto';
|
||||
@@ -43,6 +43,7 @@ export class WarehouseZonesService {
|
||||
// Ensure the parent yard exists.
|
||||
await this.yardsService.findById(yardId);
|
||||
await this.assertCodeUnique(yardId, dto.code.trim());
|
||||
await this.assertCapacityWithinYard(yardId, dto.capacityWeight ?? null, dto.capacityContainers ?? null);
|
||||
|
||||
return this.zonesRepository.create({
|
||||
yardId,
|
||||
@@ -68,14 +69,22 @@ export class WarehouseZonesService {
|
||||
await this.assertCodeUnique(existing.yardId, dto.code.trim(), id);
|
||||
}
|
||||
|
||||
const newCapacityWeight = dto.capacityWeight ?? existing.capacityWeight ?? null;
|
||||
const newCapacityContainers = dto.capacityContainers ?? existing.capacityContainers ?? null;
|
||||
|
||||
// Validate updated capacity doesn't exceed yard limits
|
||||
if (newCapacityWeight !== (existing.capacityWeight ?? null) || newCapacityContainers !== (existing.capacityContainers ?? null)) {
|
||||
await this.assertCapacityWithinYard(existing.yardId, newCapacityWeight, newCapacityContainers, id);
|
||||
}
|
||||
|
||||
const status = dto.status ?? existing.status;
|
||||
|
||||
const updated = await this.zonesRepository.update(id, {
|
||||
name: dto.name?.trim() ?? existing.name,
|
||||
code: dto.code?.trim() ?? existing.code,
|
||||
type: dto.type ?? existing.type,
|
||||
capacityWeight: dto.capacityWeight ?? existing.capacityWeight,
|
||||
capacityContainers: dto.capacityContainers ?? existing.capacityContainers,
|
||||
capacityWeight: newCapacityWeight,
|
||||
capacityContainers: newCapacityContainers,
|
||||
maxWeight: dto.maxWeight ?? existing.maxWeight,
|
||||
maxVolume: dto.maxVolume ?? existing.maxVolume,
|
||||
status,
|
||||
@@ -96,4 +105,39 @@ export class WarehouseZonesService {
|
||||
throw new ConflictException(`Zone code ${code} already exists in this yard`);
|
||||
}
|
||||
}
|
||||
|
||||
private async assertCapacityWithinYard(
|
||||
yardId: string,
|
||||
newCapacityWeight: number | null,
|
||||
newCapacityContainers: number | null,
|
||||
excludeZoneId?: string,
|
||||
): Promise<void> {
|
||||
const yard = await this.yardsService.findById(yardId);
|
||||
const zones = await this.findByYard(yardId);
|
||||
|
||||
// Sum existing zone capacities, excluding the zone being updated if provided
|
||||
const otherZones = excludeZoneId ? zones.filter((z) => z.id !== excludeZoneId) : zones;
|
||||
const totalExistingWeight = otherZones.reduce((sum, z) => sum + (z.capacityWeight ?? 0), 0);
|
||||
const totalExistingContainers = otherZones.reduce((sum, z) => sum + (z.capacityContainers ?? 0), 0);
|
||||
|
||||
// Check weight capacity
|
||||
if (newCapacityWeight !== null && yard.capacityWeight != null) {
|
||||
const totalWeight = totalExistingWeight + newCapacityWeight;
|
||||
if (totalWeight > yard.capacityWeight) {
|
||||
throw new BadRequestException(
|
||||
`Total zone weight capacity (${totalWeight}t) exceeds yard limit (${yard.capacityWeight}t)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check container capacity
|
||||
if (newCapacityContainers !== null && yard.capacityContainers != null) {
|
||||
const totalContainers = totalExistingContainers + newCapacityContainers;
|
||||
if (totalContainers > yard.capacityContainers) {
|
||||
throw new BadRequestException(
|
||||
`Total zone container capacity (${totalContainers}) exceeds yard limit (${yard.capacityContainers})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,4 +304,6 @@ export const EDR_FREIGHT_POSITIONS: FreightSeedPosition[] = [
|
||||
{ key: "djibouti_gl", name: { en: "Djibouti GL" }, rank: 3, permissionKeys: [...POSITION_PERMISSION_PRESETS.djiboutiGl] },
|
||||
{ key: "marketer", name: { en: "Marketer" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.marketer] },
|
||||
{ key: "operation", name: { en: "Operation" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.operation] },
|
||||
{ key: "operations_chief", name: { en: "Operations Chief" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.operationsChief] },
|
||||
{ key: "dispatcher", name: { en: "Dispatcher" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.dispatcher] },
|
||||
];
|
||||
|
||||
@@ -212,6 +212,10 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm('e1f00001-0001-4000-8000-000000000002', 'edr_freight_app:cargoes:create', 'Create cargo'),
|
||||
perm('e1f00001-0001-4000-8000-000000000003', 'edr_freight_app:cargoes:update', 'Update cargo'),
|
||||
perm('e1f00001-0001-4000-8000-000000000004', 'edr_freight_app:cargoes:delete', 'Delete cargo'),
|
||||
// NB: id prefixes must stay hex — 'e1g…' once crashed the boot seeder
|
||||
// (postgres: invalid input syntax for type uuid).
|
||||
perm('e1900001-0001-4000-8000-000000000001', 'edr_freight_app:consignments:view', 'View consignments'),
|
||||
perm('e1900001-0001-4000-8000-000000000002', 'edr_freight_app:consignments:create', 'Create consignment'),
|
||||
];
|
||||
|
||||
// G. Fleet — road & telemetry
|
||||
@@ -302,8 +306,6 @@ export const SCHEDULING_EXTRA_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
|
||||
// L. Administration & settings (split from the coarse admin umbrella)
|
||||
export const CONFIG_SETTINGS_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm('b3a00001-0001-4000-8000-000000000001', 'edr_freight_app:config:contract_validity:view', 'View contract validity periods'),
|
||||
perm('b3a00001-0001-4000-8000-000000000002', 'edr_freight_app:config:contract_validity:manage', 'Manage contract validity periods'),
|
||||
perm('b4a00001-0001-4000-8000-000000000001', 'edr_freight_app:settings:file_upload:view', 'View file-upload settings'),
|
||||
perm('b4a00001-0001-4000-8000-000000000002', 'edr_freight_app:settings:file_upload:manage', 'Manage file-upload settings'),
|
||||
perm('b4b00001-0001-4000-8000-000000000001', 'edr_freight_app:settings:dropdown:view', 'View dropdown settings'),
|
||||
@@ -495,6 +497,10 @@ export const FREIGHT_PERMS = {
|
||||
update: 'edr_freight_app:cargoes:update',
|
||||
delete: 'edr_freight_app:cargoes:delete',
|
||||
},
|
||||
consignments: {
|
||||
view: 'edr_freight_app:consignments:view',
|
||||
create: 'edr_freight_app:consignments:create',
|
||||
},
|
||||
vehicles: {
|
||||
view: 'edr_freight_app:vehicles:view',
|
||||
create: 'edr_freight_app:vehicles:create',
|
||||
@@ -594,12 +600,6 @@ export const FREIGHT_PERMS = {
|
||||
cancel: 'edr_freight_app:warehouse_fee_invoices:cancel',
|
||||
pay: 'edr_freight_app:warehouse_fee_invoices:pay',
|
||||
},
|
||||
config: {
|
||||
contractValidity: {
|
||||
view: 'edr_freight_app:config:contract_validity:view',
|
||||
manage: 'edr_freight_app:config:contract_validity:manage',
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
fileUpload: {
|
||||
view: 'edr_freight_app:settings:file_upload:view',
|
||||
@@ -664,6 +664,41 @@ export const FREIGHT_PERMS = {
|
||||
const allRuleEngineViewKeys = () =>
|
||||
RULE_ENGINE_RESOURCE_SLUGS.map((s) => FREIGHT_PERMS.ruleEngine.view(s));
|
||||
|
||||
/**
|
||||
* Granular equivalents of the legacy fleet:view + fleet:manage pair.
|
||||
* Deliberately excludes the wagon-transfer keys — those were always separate
|
||||
* grants (requester vs OCC vs admin history), not part of fleet:manage.
|
||||
*/
|
||||
const FLEET_GRANULAR_KEYS: string[] = [
|
||||
FREIGHT_PERMS.locomotives.view,
|
||||
FREIGHT_PERMS.locomotives.create,
|
||||
FREIGHT_PERMS.locomotives.update,
|
||||
FREIGHT_PERMS.locomotives.delete,
|
||||
FREIGHT_PERMS.wagons.view,
|
||||
FREIGHT_PERMS.wagons.create,
|
||||
FREIGHT_PERMS.wagons.update,
|
||||
FREIGHT_PERMS.wagons.delete,
|
||||
FREIGHT_PERMS.trains.view,
|
||||
FREIGHT_PERMS.trains.create,
|
||||
FREIGHT_PERMS.trains.update,
|
||||
FREIGHT_PERMS.trains.delete,
|
||||
FREIGHT_PERMS.trains.assignWagons,
|
||||
FREIGHT_PERMS.routes.view,
|
||||
FREIGHT_PERMS.routes.create,
|
||||
FREIGHT_PERMS.routes.update,
|
||||
FREIGHT_PERMS.routes.delete,
|
||||
FREIGHT_PERMS.containers.view,
|
||||
FREIGHT_PERMS.containers.create,
|
||||
FREIGHT_PERMS.containers.update,
|
||||
FREIGHT_PERMS.containers.delete,
|
||||
FREIGHT_PERMS.cargoes.view,
|
||||
FREIGHT_PERMS.cargoes.create,
|
||||
FREIGHT_PERMS.cargoes.update,
|
||||
FREIGHT_PERMS.cargoes.delete,
|
||||
FREIGHT_PERMS.consignments.view,
|
||||
FREIGHT_PERMS.consignments.create,
|
||||
];
|
||||
|
||||
export const ROLE_PERMISSION_PRESETS = {
|
||||
// Marketing / line staff: drives a booking from intake through line-staff
|
||||
// approval and contract generation/signing — i.e. until the contract is ready
|
||||
@@ -692,6 +727,7 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
FREIGHT_PERMS.trainScheduling.manage,
|
||||
FREIGHT_PERMS.fleet.view,
|
||||
FREIGHT_PERMS.fleet.manage,
|
||||
...FLEET_GRANULAR_KEYS,
|
||||
// Path A (no customs): Operations reviews the customer's self-clearance docs
|
||||
// — on the contract for ONE_TIME contracts, and PER BOOKING for GENERAL
|
||||
// contracts (booking-level document review → finalize → CLEARANCE_READY).
|
||||
@@ -804,6 +840,48 @@ export const POSITION_PERMISSION_PRESETS = {
|
||||
...ROLE_PERMISSION_PRESETS.operationsOfficer,
|
||||
FREIGHT_PERMS.allocation.manage,
|
||||
]),
|
||||
// Operations Chief: full operational authority — the entire freight
|
||||
// permission catalog (all CRUD across bookings, contracts, scheduling,
|
||||
// fleet, warehouse, mile, finance, settings, staff).
|
||||
operationsChief: dedupe([...BOOKING_RULE_ENGINE_PERMISSION_KEYS]),
|
||||
// Dispatcher: full CRUD on warehouse management (incl. import/export/intercity
|
||||
// inventory flows) and fleet management, plus truck dispatch on the mile legs
|
||||
// and operational context. The ONE carve-out: allocation & fee rules stay
|
||||
// VIEW-ONLY — a dispatcher never creates/updates/deletes those rules.
|
||||
dispatcher: dedupe([
|
||||
// Warehouse management — full CRUD.
|
||||
FREIGHT_PERMS.warehouseDashboard.view,
|
||||
...Object.values(FREIGHT_PERMS.warehouses),
|
||||
...Object.values(FREIGHT_PERMS.warehouseYards),
|
||||
...Object.values(FREIGHT_PERMS.warehouseZones),
|
||||
...Object.values(FREIGHT_PERMS.warehouseInventory),
|
||||
...Object.values(FREIGHT_PERMS.warehouseInspectionReports),
|
||||
...Object.values(FREIGHT_PERMS.interchangeDocuments),
|
||||
...Object.values(FREIGHT_PERMS.warehouseFeeInvoices),
|
||||
// View-only on the rules that govern allocation and fees.
|
||||
FREIGHT_PERMS.warehouseAllocationRules.view,
|
||||
FREIGHT_PERMS.warehouseFeeRules.view,
|
||||
// Fleet management — full CRUD.
|
||||
...Object.values(FREIGHT_PERMS.fleet),
|
||||
FREIGHT_PERMS.fleetDashboard.view,
|
||||
...Object.values(FREIGHT_PERMS.fleetReports),
|
||||
...Object.values(FREIGHT_PERMS.vehicles),
|
||||
...Object.values(FREIGHT_PERMS.drivers),
|
||||
...Object.values(FREIGHT_PERMS.tracking),
|
||||
...Object.values(FREIGHT_PERMS.fuel),
|
||||
...Object.values(FREIGHT_PERMS.maintenance),
|
||||
...Object.values(FREIGHT_PERMS.locomotives),
|
||||
...Object.values(FREIGHT_PERMS.wagons),
|
||||
...Object.values(FREIGHT_PERMS.trains),
|
||||
...Object.values(FREIGHT_PERMS.routes),
|
||||
...Object.values(FREIGHT_PERMS.containers),
|
||||
...Object.values(FREIGHT_PERMS.cargoes),
|
||||
// Truck dispatch on the EDR mile legs + operational context.
|
||||
...Object.values(FREIGHT_PERMS.firstMile),
|
||||
...Object.values(FREIGHT_PERMS.lastMile),
|
||||
FREIGHT_PERMS.trainScheduling.view,
|
||||
FREIGHT_PERMS.bookings.operations,
|
||||
]),
|
||||
} as const;
|
||||
|
||||
/** Derive the module bucket from the resource segment of a permission key. */
|
||||
|
||||
@@ -146,11 +146,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "Overview",
|
||||
href: "/dashboard/overview",
|
||||
icon: <LayoutDashboard />,
|
||||
permission: FREIGHT_PERMS.overview.view,
|
||||
},
|
||||
{
|
||||
label: "Customers",
|
||||
href: "/dashboard/customers",
|
||||
icon: <Building2 />,
|
||||
permission: FREIGHT_PERMS.customers.view,
|
||||
},
|
||||
{
|
||||
label: "Contracts",
|
||||
@@ -162,6 +164,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "Bookings",
|
||||
href: "/dashboard/booking-requests",
|
||||
icon: <FileText />,
|
||||
permission: FREIGHT_PERMS.bookings.view,
|
||||
},
|
||||
// Operations hub: clearance-document review for contracts WITHOUT
|
||||
// customs clearing (contract-level for one-time, per-booking for general).
|
||||
@@ -187,6 +190,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "Support",
|
||||
href: "/dashboard/support",
|
||||
icon: <LifeBuoy />,
|
||||
permission: FREIGHT_PERMS.support.view,
|
||||
},
|
||||
...demoItems,
|
||||
],
|
||||
@@ -267,19 +271,19 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "Routes",
|
||||
href: "/dashboard/routes",
|
||||
icon: <Network />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
permission: [FREIGHT_PERMS.routes.view, FREIGHT_PERMS.fleet.view],
|
||||
},
|
||||
{
|
||||
label: "Locomotives",
|
||||
href: "/dashboard/locomotives",
|
||||
icon: <Train />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
permission: [FREIGHT_PERMS.locomotives.view, FREIGHT_PERMS.fleet.view],
|
||||
},
|
||||
{
|
||||
label: "Train Builder",
|
||||
href: "/dashboard/train-builder",
|
||||
icon: <Hammer />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
permission: [FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view],
|
||||
},
|
||||
|
||||
// {
|
||||
@@ -291,7 +295,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "Wagons",
|
||||
href: "/dashboard/wagons",
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
permission: [FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view],
|
||||
},
|
||||
{
|
||||
label: "Vehicles",
|
||||
@@ -375,31 +379,37 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "Imports",
|
||||
href: "/dashboard/import-warehouse",
|
||||
icon: <PackageOpen />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
children: [
|
||||
{
|
||||
label: "Import Overview",
|
||||
href: "/dashboard/import-warehouse",
|
||||
icon: <PackageOpen />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
},
|
||||
{
|
||||
label: "Arrival Queue",
|
||||
href: "/dashboard/arrival-queue",
|
||||
icon: <PackageOpen />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
},
|
||||
{
|
||||
label: "Dispatch Queue",
|
||||
href: "/dashboard/dispatch-queue",
|
||||
icon: <Send />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
},
|
||||
{
|
||||
label: "Terminal Inventory",
|
||||
href: "/dashboard/warehouse-inventory?direction=IMPORT",
|
||||
icon: <Package />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
},
|
||||
{
|
||||
label: "Inventory Inquiry",
|
||||
href: "/dashboard/inventory-inquiry",
|
||||
icon: <Boxes />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -407,41 +417,49 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "Exports",
|
||||
href: "/dashboard/export-warehouse",
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
children: [
|
||||
{
|
||||
label: "Export Overview",
|
||||
href: "/dashboard/export-warehouse",
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
},
|
||||
{
|
||||
label: "Loading Queue",
|
||||
href: "/dashboard/loading-queue",
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
},
|
||||
{
|
||||
label: "Loaded Inventory",
|
||||
href: "/dashboard/loaded-inventory",
|
||||
icon: <PackageCheck />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
},
|
||||
{
|
||||
label: "Dispatch Queue",
|
||||
href: "/dashboard/dispatch-queue",
|
||||
icon: <Send />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
},
|
||||
{
|
||||
label: "Djibouti Unloading",
|
||||
href: "/dashboard/export-djibouti-unloading",
|
||||
icon: <PackageOpen />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
},
|
||||
{
|
||||
label: "Interchange Documents",
|
||||
href: "/dashboard/interchange-documents",
|
||||
icon: <FileText />,
|
||||
permission: FREIGHT_PERMS.interchangeDocuments.view,
|
||||
},
|
||||
{
|
||||
label: "Terminal Inventory",
|
||||
href: "/dashboard/warehouse-inventory?direction=EXPORT",
|
||||
icon: <Package />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -449,11 +467,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "Intercity",
|
||||
href: "/dashboard/intercity",
|
||||
icon: <TrainFront />,
|
||||
permission: FREIGHT_PERMS.trainScheduling.view,
|
||||
children: [
|
||||
{
|
||||
label: "Intercity Cargo",
|
||||
href: "/dashboard/intercity",
|
||||
icon: <TrainFront />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -465,6 +485,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "Warehouse Dashboard",
|
||||
href: "/dashboard/warehouse-dashboard",
|
||||
icon: <LayoutDashboard />,
|
||||
permission: FREIGHT_PERMS.warehouseDashboard.view,
|
||||
},
|
||||
{
|
||||
// Yard-wide, not per-direction: the gate sees import and export
|
||||
@@ -472,21 +493,28 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "Trucks on Site",
|
||||
href: "/dashboard/trucks-on-site",
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
},
|
||||
{
|
||||
label: "Warehouses",
|
||||
href: "/dashboard/warehouses",
|
||||
icon: <Container />,
|
||||
permission: FREIGHT_PERMS.warehouses.view,
|
||||
},
|
||||
{
|
||||
label: "Allocation & Fees",
|
||||
href: "/dashboard/warehouse-rules",
|
||||
icon: <SlidersHorizontal />,
|
||||
permission: [
|
||||
FREIGHT_PERMS.warehouseAllocationRules.view,
|
||||
FREIGHT_PERMS.warehouseFeeRules.view,
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Fee Invoices",
|
||||
href: "/dashboard/warehouse-fee-invoices",
|
||||
icon: <Wallet />,
|
||||
permission: FREIGHT_PERMS.warehouseFeeInvoices.view,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -520,13 +548,10 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <Boxes />,
|
||||
children: [
|
||||
...getCategorySidebarChildren("configuration"),
|
||||
{
|
||||
label: "Contract validity",
|
||||
href: "/dashboard/configuration/contract-validity-periods",
|
||||
},
|
||||
{
|
||||
label: "Train scheduling rules",
|
||||
href: "/dashboard/configuration/train-scheduling-rules",
|
||||
permission: FREIGHT_PERMS.trainScheduling.rulesManage,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -541,6 +566,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "Staff",
|
||||
href: "/user-management",
|
||||
icon: <Users />,
|
||||
permission: [
|
||||
FREIGHT_PERMS.admin,
|
||||
FREIGHT_PERMS.staff.roles.view,
|
||||
FREIGHT_PERMS.staff.employeeRegistration.view,
|
||||
FREIGHT_PERMS.staff.roleAssignment.view,
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -550,6 +581,16 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
const ET_CLEARANCE_HREF = "/dashboard/contracts/clearance";
|
||||
const DJ_CLEARANCE_HREF = "/dashboard/gl-djibouti/clearance";
|
||||
|
||||
// Routes a GL officer may reach beyond their clearance hub. Path B booking is
|
||||
// part of their job (create/rebook under a cleared contract, then view that
|
||||
// booking's clearance), but those routes live outside the clearance prefix —
|
||||
// without this allowlist the single-prefix lock bounces them out of their own
|
||||
// workflow. Matched against location.pathname (no query string).
|
||||
const GL_WORKFLOW_PATH_PATTERNS: RegExp[] = [
|
||||
/^\/dashboard\/contracts\/[^/]+\/create-booking(\/|$)/,
|
||||
/^\/dashboard\/bookings\/[^/]+\/clearance(\/|$)/,
|
||||
];
|
||||
|
||||
const isEtClearanceItem = (item: SidebarItem): boolean =>
|
||||
item.href === ET_CLEARANCE_HREF;
|
||||
const isDjClearanceItem = (item: SidebarItem): boolean =>
|
||||
@@ -585,21 +626,34 @@ const filterSidebarByPermission = (
|
||||
return keys.some((key) => hasFreightPermission(user, key));
|
||||
};
|
||||
|
||||
const itemAllowed = (item: SidebarItem): boolean => {
|
||||
// GL positions are locked to their single clearance page.
|
||||
if (etGl) return isEtClearanceItem(item);
|
||||
if (djGl) return isDjClearanceItem(item);
|
||||
|
||||
// Everyone else: hide the GL-only clearance pages entirely.
|
||||
if (isClearanceItem(item)) return false;
|
||||
|
||||
return permissionAllowed(item);
|
||||
};
|
||||
// Recursive: children are filtered first; a group (item with children) stays
|
||||
// only while it still has visible children — so parents without their own
|
||||
// permission key never leak a whole subtree the user cannot open.
|
||||
const filterItems = (items: SidebarItem[]): SidebarItem[] =>
|
||||
items
|
||||
.map((item) =>
|
||||
item.children
|
||||
? { ...item, children: filterItems(item.children) }
|
||||
: item,
|
||||
)
|
||||
.filter((item) => {
|
||||
if (etGl || djGl) {
|
||||
// GL positions are locked to their single clearance page (parents
|
||||
// survive only as the path to that page).
|
||||
const isTarget = etGl ? isEtClearanceItem : isDjClearanceItem;
|
||||
return isTarget(item) || (item.children?.length ?? 0) > 0;
|
||||
}
|
||||
// Everyone else: hide the GL-only clearance pages entirely.
|
||||
if (isClearanceItem(item)) return false;
|
||||
if (!permissionAllowed(item)) return false;
|
||||
if (item.children) return item.children.length > 0;
|
||||
return true;
|
||||
});
|
||||
|
||||
return sections
|
||||
.map((section) => ({
|
||||
...section,
|
||||
items: section.items.filter(itemAllowed),
|
||||
items: filterItems(section.items),
|
||||
}))
|
||||
.filter((section) => section.items.length > 0);
|
||||
};
|
||||
@@ -672,7 +726,11 @@ const DashboardShell = () => {
|
||||
document.title = activeLabel ? `${activeLabel} | ${APP_TITLE}` : APP_TITLE;
|
||||
}, [location.pathname, sidebarSections]);
|
||||
|
||||
if (glClearanceHome && !location.pathname.startsWith(glClearanceHome)) {
|
||||
if (
|
||||
glClearanceHome &&
|
||||
!location.pathname.startsWith(glClearanceHome) &&
|
||||
!GL_WORKFLOW_PATH_PATTERNS.some((re) => re.test(location.pathname))
|
||||
) {
|
||||
return <Navigate to={glClearanceHome} replace />;
|
||||
}
|
||||
|
||||
@@ -1055,7 +1113,7 @@ const App = () => {
|
||||
<Route
|
||||
path="routes"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.routes.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<RoutesPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1063,7 +1121,7 @@ const App = () => {
|
||||
<Route
|
||||
path="locomotives"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.locomotives.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1071,7 +1129,7 @@ const App = () => {
|
||||
<Route
|
||||
path="trains"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1079,7 +1137,7 @@ const App = () => {
|
||||
<Route
|
||||
path="trains/:id"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<TrainDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1087,7 +1145,7 @@ const App = () => {
|
||||
<Route
|
||||
path="train-builder"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<TrainBuilderListPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1095,7 +1153,7 @@ const App = () => {
|
||||
<Route
|
||||
path="train-builder/:id"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<TrainBuilderDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1103,7 +1161,7 @@ const App = () => {
|
||||
<Route
|
||||
path="wagons"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1111,7 +1169,7 @@ const App = () => {
|
||||
<Route
|
||||
path="containers"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.containers.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1119,7 +1177,7 @@ const App = () => {
|
||||
<Route
|
||||
path="cargoes"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.cargoes.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1205,7 +1263,7 @@ const App = () => {
|
||||
<Route
|
||||
path="routes"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.routes.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<RoutesPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1293,7 +1351,7 @@ const App = () => {
|
||||
<Route
|
||||
path="locomotives"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.locomotives.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1301,7 +1359,7 @@ const App = () => {
|
||||
<Route
|
||||
path="trains"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1309,7 +1367,7 @@ const App = () => {
|
||||
<Route
|
||||
path="trains/:id"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<TrainDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1317,7 +1375,7 @@ const App = () => {
|
||||
<Route
|
||||
path="train-builder"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<TrainBuilderListPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1325,7 +1383,7 @@ const App = () => {
|
||||
<Route
|
||||
path="train-builder/:id"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<TrainBuilderDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1333,7 +1391,7 @@ const App = () => {
|
||||
<Route
|
||||
path="wagons"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1341,7 +1399,7 @@ const App = () => {
|
||||
<Route
|
||||
path="containers"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.containers.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1349,7 +1407,7 @@ const App = () => {
|
||||
<Route
|
||||
path="cargoes"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.cargoes.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1416,14 +1474,14 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
{/* <Route
|
||||
path="configuration/contract-validity-periods"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.admin}>
|
||||
<ContractValidityPeriodsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
/> */}
|
||||
<Route path="configuration/cargo-types" element={<CargoTypesPage />} />
|
||||
<Route
|
||||
path="configuration/cargo-types/:id"
|
||||
|
||||
@@ -23,6 +23,7 @@ interface AuthEmployeePosition {
|
||||
permissions?: AuthPermission[];
|
||||
/** Some IAM payloads nest the position record instead of flattening its key. */
|
||||
position?: { id?: string; key?: string; name?: LocaleText };
|
||||
positionType?: { id?: string; key?: string; name?: LocaleText } | null;
|
||||
}
|
||||
|
||||
interface AuthEmployeeRecord {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { api } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { isBackdated, nowLocalDateTimeInput } from '@/lib/no-backdate';
|
||||
|
||||
/**
|
||||
* Customer Pickup + Proof of Delivery capture for a LOADED cargo.
|
||||
@@ -27,6 +28,10 @@ export function DeliverCargoDialog({ cargoId, onSuccess }: { cargoId: string; on
|
||||
toast({ title: 'Receiver name is required', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
if (isBackdated(pickupDate)) {
|
||||
toast({ title: 'Pickup date cannot be in the past', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deliver.mutateAsync({
|
||||
id: cargoId,
|
||||
@@ -75,6 +80,7 @@ export function DeliverCargoDialog({ cargoId, onSuccess }: { cargoId: string; on
|
||||
<Label>Pickup date</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
min={nowLocalDateTimeInput()}
|
||||
value={pickupDate}
|
||||
onChange={(e) => setPickupDate(e.target.value)}
|
||||
/>
|
||||
|
||||
@@ -16,6 +16,8 @@ import type { Freight } from "@edr/types";
|
||||
import { formatContractApprovalProgress } from "@/features/contracts/contract-approval-progress";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import type { useContractMutations } from "@/hooks/contracts/useContracts";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { canApproveContractStep } from "@/lib/permissions";
|
||||
|
||||
type Mutations = ReturnType<typeof useContractMutations>;
|
||||
|
||||
@@ -29,6 +31,7 @@ export function ContractApprovalStepsCard({
|
||||
contract,
|
||||
mutations,
|
||||
}: ContractApprovalStepsCardProps) {
|
||||
const { user } = useAuth();
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [pendingStep, setPendingStep] =
|
||||
useState<Freight.IContractApprovalStep | null>(null);
|
||||
@@ -166,6 +169,10 @@ export function ContractApprovalStepsCard({
|
||||
key={step.id}
|
||||
step={step}
|
||||
isNext={actionable && nextPending?.id === step.id}
|
||||
// Buttons show only to the step's actual approver (matching
|
||||
// position type): a chief step never offers Approve/Reject to a
|
||||
// marketing officer. Everyone still sees the "next" highlight.
|
||||
canAct={canApproveContractStep(user, step.requiredRole)}
|
||||
isPending={
|
||||
mutations.approveStep.isPending ||
|
||||
mutations.rejectStep.isPending
|
||||
@@ -306,12 +313,14 @@ export function ContractApprovalStepsCard({
|
||||
function StepRow({
|
||||
step,
|
||||
isNext,
|
||||
canAct,
|
||||
isPending,
|
||||
onApprove,
|
||||
onReject,
|
||||
}: {
|
||||
step: Freight.IContractApprovalStep;
|
||||
isNext: boolean;
|
||||
canAct: boolean;
|
||||
isPending: boolean;
|
||||
onApprove: () => void;
|
||||
onReject: () => void;
|
||||
@@ -372,8 +381,11 @@ function StepRow({
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
{isNext && step.status === "PENDING" && (
|
||||
{/* One element type per row: action buttons on the active step (they
|
||||
already imply "pending & actionable"), a status badge otherwise.
|
||||
Mixing compact buttons + a badge here made them read as misaligned. */}
|
||||
<Group gap="xs" wrap="nowrap" align="center" style={{ flexShrink: 0 }}>
|
||||
{isNext && canAct && step.status === "PENDING" ? (
|
||||
<>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
@@ -395,16 +407,17 @@ function StepRow({
|
||||
Reject
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Badge
|
||||
variant="light"
|
||||
color={statusColor}
|
||||
size="sm"
|
||||
radius="sm"
|
||||
tt="uppercase"
|
||||
>
|
||||
{step.status}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge
|
||||
variant="light"
|
||||
color={statusColor}
|
||||
size="sm"
|
||||
radius="sm"
|
||||
tt="uppercase"
|
||||
>
|
||||
{step.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
|
||||
@@ -269,6 +269,8 @@ export default function GlCreateBookingForm() {
|
||||
const [scheduledDate, setScheduledDate] = useState("");
|
||||
const [contractRouteId, setContractRouteId] = useState<string | null>(null);
|
||||
const [notes, setNotes] = useState("");
|
||||
// What the containers carry — captured per booking (moved off the contract).
|
||||
const [cargoDescription, setCargoDescription] = useState("");
|
||||
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
|
||||
const [bulk, setBulk] = useState<BulkDraft>({
|
||||
cargoWeightTons: "",
|
||||
@@ -459,6 +461,10 @@ export default function GlCreateBookingForm() {
|
||||
);
|
||||
};
|
||||
setPrefilled(true);
|
||||
// Rebook carries the expired booking's cargo description forward.
|
||||
if (copyFromBooking.cargoFreeText) {
|
||||
setCargoDescription(copyFromBooking.cargoFreeText);
|
||||
}
|
||||
setContainerLines(
|
||||
lines.map((c) => {
|
||||
const qty = Math.max(1, c.quantity);
|
||||
@@ -705,14 +711,23 @@ export default function GlCreateBookingForm() {
|
||||
|
||||
const lineErrors = useMemo<LineErrors[]>(() => {
|
||||
if (!isContainer || !contract) return [];
|
||||
return containerLines.map((line) => {
|
||||
// A line can be 0 (the contract covers both sizes; a booking may only need
|
||||
// one) but the booking as a whole needs at least one container — anchor
|
||||
// that error on the first line's quantity so it renders in the field.
|
||||
const totalQty = containerLines.reduce(
|
||||
(sum, l) => sum + Math.max(0, Number(l.quantity) || 0),
|
||||
0,
|
||||
);
|
||||
return containerLines.map((line, idx) => {
|
||||
const errs: LineErrors = {};
|
||||
const qty = Number(line.quantity || 0);
|
||||
if (line.quantity.trim() === "") {
|
||||
errs.quantity = "Quantity is required.";
|
||||
} else if (Number.isNaN(qty) || qty < 1) {
|
||||
errs.quantity = "At least 1.";
|
||||
} else if (line.units.length < qty) {
|
||||
} else if (Number.isNaN(qty) || qty < 0) {
|
||||
errs.quantity = "Enter 0 or more.";
|
||||
} else if (idx === 0 && totalQty < 1) {
|
||||
errs.quantity = "Book at least one container (either size).";
|
||||
} else if (qty >= 1 && line.units.length < qty) {
|
||||
errs.units = `Enter details for all ${qty} container(s).`;
|
||||
}
|
||||
if (contract.isHazardous) {
|
||||
@@ -789,6 +804,11 @@ export default function GlCreateBookingForm() {
|
||||
const routeError =
|
||||
multiRoute && !contractRouteId ? "Select a route." : undefined;
|
||||
|
||||
const cargoDescriptionError =
|
||||
isContainer && !cargoDescription.trim()
|
||||
? "Describe the cargo carried in the containers."
|
||||
: undefined;
|
||||
|
||||
const cargoValid = isContainer
|
||||
? lineErrors.every(
|
||||
(e) =>
|
||||
@@ -800,7 +820,8 @@ export default function GlCreateBookingForm() {
|
||||
) &&
|
||||
unitErrors.every((line) =>
|
||||
line.every((e) => !e.containerNumber && !e.vgmTons),
|
||||
)
|
||||
) &&
|
||||
!cargoDescriptionError
|
||||
: !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer;
|
||||
|
||||
const formValid = cargoValid && !hasOdd20ft && !dateError && !routeError;
|
||||
@@ -827,6 +848,8 @@ export default function GlCreateBookingForm() {
|
||||
};
|
||||
|
||||
if (isContainer) {
|
||||
// What the containers carry — captured per booking, not on the contract.
|
||||
if (cargoDescription.trim()) payload.cargoFreeText = cargoDescription.trim();
|
||||
payload.containers = containerLines
|
||||
.filter((l) => Number(l.quantity) >= 1)
|
||||
.map((l) => ({
|
||||
@@ -1245,6 +1268,19 @@ export default function GlCreateBookingForm() {
|
||||
)}
|
||||
{remainderNotice}
|
||||
<ContractCapacityNotice contractId={contract.id} isContainer />
|
||||
<Textarea
|
||||
label="Cargo description *"
|
||||
description="What do the containers carry on this shipment?"
|
||||
placeholder="e.g. Electronics, garments, machinery spare parts…"
|
||||
value={cargoDescription}
|
||||
onChange={(e) => setCargoDescription(e.currentTarget.value)}
|
||||
error={showErrors ? cargoDescriptionError : undefined}
|
||||
radius={10}
|
||||
autosize
|
||||
minRows={2}
|
||||
maxRows={4}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
{containerLines.length === 0 ? (
|
||||
<Text fz="sm" c="dimmed">
|
||||
This contract has no container sizes in scope.
|
||||
@@ -1264,7 +1300,7 @@ export default function GlCreateBookingForm() {
|
||||
type="number"
|
||||
onKeyDown={blockNegative}
|
||||
label="Quantity *"
|
||||
min={1}
|
||||
min={0}
|
||||
value={line.quantity}
|
||||
error={
|
||||
showErrors
|
||||
|
||||
@@ -23,6 +23,7 @@ const INCIDENT_OPTIONS: { value: Freight.IncidentType; label: string }[] = [
|
||||
{ value: "CONTAINER_OPENED", label: "Container opened" },
|
||||
{ value: "CONTAINER_DAMAGED", label: "Container damaged" },
|
||||
{ value: "FLUID_LEAKING", label: "Fluid leaking" },
|
||||
{ value: "OTHER", label: "Other" },
|
||||
];
|
||||
|
||||
const LABEL: Record<Freight.IncidentType, string> = {
|
||||
@@ -30,6 +31,7 @@ const LABEL: Record<Freight.IncidentType, string> = {
|
||||
CONTAINER_OPENED: "Container opened",
|
||||
CONTAINER_DAMAGED: "Container damaged",
|
||||
FLUID_LEAKING: "Fluid leaking",
|
||||
OTHER: "Other",
|
||||
};
|
||||
|
||||
export function IncidentReportCard({ bookingId }: { bookingId: string }) {
|
||||
|
||||
@@ -61,8 +61,11 @@ export default function ResetPasswordAction({
|
||||
|
||||
if (!allowed) return null;
|
||||
|
||||
// SMS is domestic-only: a foreign number counts as unavailable, same as a
|
||||
// missing one, so staff can't send a link that will never arrive.
|
||||
const phoneUsable = !!target?.phone && target.phoneIsDomestic !== false;
|
||||
const channelMissing =
|
||||
!!target && (channel === "email" ? !target.email : !target.phone);
|
||||
!!target && (channel === "email" ? !target.email : !phoneUsable);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -106,9 +109,13 @@ export default function ResetPasswordAction({
|
||||
<Radio
|
||||
value="phone"
|
||||
label="SMS"
|
||||
disabled={!target.phone}
|
||||
disabled={!phoneUsable}
|
||||
description={
|
||||
target.phone ?? "No phone number on this account"
|
||||
!target.phone
|
||||
? "No phone number on this account"
|
||||
: target.phoneIsDomestic === false
|
||||
? `${target.phone} — foreign number, SMS unavailable; use email`
|
||||
: target.phone
|
||||
}
|
||||
/>
|
||||
<Radio
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user