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

This commit is contained in:
Nathnael
2026-07-08 07:50:24 +00:00
335 changed files with 20479 additions and 3635 deletions

View File

@@ -369,7 +369,7 @@ pnpm --filter @edr/passenger-api run prisma:seed
- 3 User accounts (Admin, Passenger, Agent)
- Fare rules for ADULT and CHILD passenger categories
- Currency exchange rates (ETB, DJF, USD)
- Baggage allowance rules
- Luggage allowance rules
- Notification templates
- Promotions and FAQ content
- Menu items and station crowd signals

View File

@@ -1,5 +1,7 @@
# Copy to .env for local/docker compose (not committed).
PORT=3001
# GT06 GPS tracker TCP listener port (raw TCP, must be reachable by tracker SIMs). 0 disables.
GT06_TCP_PORT=5023
DB_HOST=localhost
DB_PORT=5433
DB_USER=postgres

View File

@@ -40,4 +40,6 @@ RUN addgroup --system --gid 1001 nodejs \
COPY --from=deployer --chown=nestjs:nodejs /deploy .
USER nestjs
EXPOSE 3001
# GT06 GPS tracker TCP listener (raw TCP, not HTTP). Change via GT06_TCP_PORT.
EXPOSE 5023
CMD ["node", "dist/main.js"]

View File

@@ -8,7 +8,10 @@ import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
import { ScheduleModule } from "@nestjs/schedule";
import { EventEmitterModule } from "@nestjs/event-emitter";
import { DataSource, DataSourceOptions } from "typeorm";
import { ensurePostgresSchemas } from "./config/ensure-postgres-schemas";
import {
ensurePostgresSchemas,
APPLICATION_SEARCH_PATH,
} from "./config/ensure-postgres-schemas";
import { IamModule, DataSeeder } from "@tria-plc/iamapi-common";
import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module";
@@ -48,6 +51,7 @@ import {
EDR_FREIGHT_PERMISSIONS,
} from "./seed/edr-freight.seed";
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
import { FreightPositionsSeeder } from "./seed/freight-positions.seeder";
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
import { PaymentModule } from "./modules/payment/payment.module";
@@ -80,6 +84,10 @@ import { VehiclesModule } from "./modules/vehicles/vehicles.module";
import { DriversModule } from "./modules/drivers/drivers.module";
import { FuelModule } from "./modules/fuel/fuel.module";
import { MaintenanceModule } from "./modules/maintenance/maintenance.module";
import { ComplianceModule } from "./modules/compliance/compliance.module";
import { IncidentsModule } from "./modules/incidents/incidents.module";
import { ProcurementModule } from "./modules/procurement/procurement.module";
import { GpsTrackingModule } from "./modules/gps-tracking/gps-tracking.module";
import { FirstMileModule } from "./modules/first-mile/first-mile.module";
import { LastMileModule } from "./modules/last-mile/last-mile.module";
import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module";
@@ -104,7 +112,27 @@ import { LoggerMiddleware } from "./logger.middleware";
}
await ensurePostgresSchemas(options as DataSourceOptions);
const dataSource = new DataSource(options as DataSourceOptions);
return dataSource.initialize();
await dataSource.initialize();
// The remote edr_dev DB sits behind a connection pooler/proxy that rejects
// the Postgres `options` startup parameter (08P01). Instead of setting
// search_path at connect time, apply it per physical connection: the pg
// Pool emits `connect` for every new client (initial fill, pool growth,
// reconnect), so every backend session gets the schema search order.
const pool = (dataSource.driver as { master?: unknown }).master as
| { on?: (event: string, cb: (client: unknown) => void) => void }
| undefined;
if (pool?.on) {
pool.on("connect", (client) => {
(client as { query: (sql: string) => Promise<unknown> })
.query(`SET search_path TO ${APPLICATION_SEARCH_PATH}`)
.catch(() => {
/* connection will be validated on first real query */
});
});
}
return dataSource;
},
}),
SharedAuthModule,
@@ -148,6 +176,10 @@ import { LoggerMiddleware } from "./logger.middleware";
DriversModule,
FuelModule,
MaintenanceModule,
ComplianceModule,
IncidentsModule,
ProcurementModule,
GpsTrackingModule,
FirstMileModule,
LastMileModule,
InterchangeDocumentsModule,
@@ -157,6 +189,7 @@ import { LoggerMiddleware } from "./logger.middleware";
],
providers: [
EdrOrgSeeder,
FreightPositionsSeeder,
DemoUsersSeeder,
FreightStaffUsersSeeder,
PricingDataSeeder,
@@ -180,6 +213,7 @@ export class AppModule implements OnApplicationBootstrap {
constructor(
private readonly seeder: DataSeeder,
private readonly edrOrgSeeder: EdrOrgSeeder,
private readonly freightPositionsSeeder: FreightPositionsSeeder,
private readonly demoUsersSeeder: DemoUsersSeeder,
private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
private readonly pricingDataSeeder: PricingDataSeeder,
@@ -201,6 +235,7 @@ export class AppModule implements OnApplicationBootstrap {
await this.freightPermissionKeyMigrationSeeder.run();
await this.seeder.run();
await this.edrOrgSeeder.run();
await this.freightPositionsSeeder.run();
await this.demoUsersSeeder.run();
await this.freightStaffUsersSeeder.run();
await this.pricingDataSeeder.run();

View File

@@ -44,7 +44,6 @@ import {
NotificationTemplate,
} from "@tria-plc/iamapi-common";
import { OrganizationSetting } from "@tria-plc/iamapi-common/entities/iam/organization-structure/organization-setting.entity";
import { APPLICATION_SEARCH_PATH } from "./ensure-postgres-schemas";
const iamEntities = [
DefaultPosition,
@@ -105,14 +104,12 @@ export default registerAs("database", (): TypeOrmModuleOptions => {
password: process.env.DB_PASSWORD ?? "",
database: process.env.DB_NAME ?? "edr_freight",
schema: "public",
// The `-c search_path=...` startup option is rejected by transaction-pooling
// poolers (e.g. PgBouncer: "unsupported startup parameter in options"). When
// behind such a pooler set DB_PGBOUNCER=true and instead make the search_path
// a role default: ALTER ROLE <user> IN DATABASE <db> SET search_path TO
// public,iam,freight,audit;
...(process.env.DB_PGBOUNCER === "true"
? {}
: { extra: { options: `-c search_path=${APPLICATION_SEARCH_PATH}` } }),
// NOTE: do NOT pass `extra.options: '-c search_path=...'`. That sends the
// Postgres startup `options` parameter, which connection poolers (PgBouncer /
// proxies fronting the remote edr_dev DB) reject with
// `08P01 unsupported startup parameter in options: search_path`.
// The search_path is instead applied per-connection via a pool `connect`
// handler in app.module.ts (see setPoolSearchPath).
entities: [__dirname + "/../**/*.entity.{ts,js}", ...iamEntities],
autoLoadEntities: true,
migrations: [

View File

@@ -12,23 +12,28 @@ export class AddEmailToOtpVerifications1900000000000
name = "AddEmailToOtpVerifications1900000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
// The table lives in the `freight` schema (the OtpVerification entity pins
// schema: "freight"). An earlier version of this migration targeted
// `public.otp_verifications`, which does not exist there — leaving the real
// freight table without an `email` column and OTP send failing with
// `column OtpVerification.email does not exist`. Target `freight` explicitly.
await queryRunner.query(`
ALTER TABLE public.otp_verifications
ALTER TABLE freight.otp_verifications
ALTER COLUMN phone DROP NOT NULL
`);
await queryRunner.query(`
ALTER TABLE public.otp_verifications
ALTER TABLE freight.otp_verifications
ADD COLUMN IF NOT EXISTS email varchar UNIQUE
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE public.otp_verifications
ALTER TABLE freight.otp_verifications
DROP COLUMN IF EXISTS email
`);
await queryRunner.query(`
ALTER TABLE public.otp_verifications
ALTER TABLE freight.otp_verifications
ALTER COLUMN phone SET NOT NULL
`);
}

View File

@@ -0,0 +1,59 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Vehicle Compliance & Expiry Alerts.
* - Adds expiry-tracking columns to freight.vehicles.
* - Creates freight.compliance_records for per-document compliance tracking.
*/
export class AddVehicleCompliance1950000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// Vehicle expiry / compliance columns.
await queryRunner.query(`
ALTER TABLE freight.vehicles
ADD COLUMN IF NOT EXISTS vin VARCHAR,
ADD COLUMN IF NOT EXISTS ownership VARCHAR,
ADD COLUMN IF NOT EXISTS insurance_expiry DATE,
ADD COLUMN IF NOT EXISTS registration_expiry DATE,
ADD COLUMN IF NOT EXISTS next_inspection_date DATE;
`);
// Compliance records table.
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.compliance_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
vehicle_id UUID NOT NULL REFERENCES freight.vehicles(id),
type VARCHAR NOT NULL,
document_number VARCHAR,
issued_date DATE,
expiry_date DATE NOT NULL,
status VARCHAR NOT NULL DEFAULT 'VALID',
notes TEXT,
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_compliance_records_vehicle_id ON freight.compliance_records(vehicle_id);`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS idx_compliance_records_expiry_date ON freight.compliance_records(expiry_date);`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS idx_compliance_records_type ON freight.compliance_records(type);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.compliance_records CASCADE;`);
await queryRunner.query(`
ALTER TABLE freight.vehicles
DROP COLUMN IF EXISTS vin,
DROP COLUMN IF EXISTS ownership,
DROP COLUMN IF EXISTS insurance_expiry,
DROP COLUMN IF EXISTS registration_expiry,
DROP COLUMN IF EXISTS next_inspection_date;
`);
}
}

View File

@@ -0,0 +1,46 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Accident & Incident register for the fleet. Tracks accidents, breakdowns,
* traffic violations, thefts and other incidents against a vehicle, driver
* and/or booking, with severity, damage estimate, insurance claim tracking and
* a lifecycle status. Queried by driver_id for per-driver incident history.
*/
export class AddIncidents1960000000000 implements MigrationInterface {
name = 'AddIncidents1960000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.incidents (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
vehicle_id uuid,
driver_id uuid,
booking_id uuid,
type varchar NOT NULL,
severity varchar NOT NULL,
occurred_at timestamptz NOT NULL,
location varchar,
description text NOT NULL,
damage_estimate numeric(14,2),
status varchar NOT NULL DEFAULT 'REPORTED',
insurance_claim_number varchar,
reported_by varchar
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_INCIDENTS_DRIVER"
ON freight.incidents (driver_id, occurred_at)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_INCIDENTS_VEHICLE"
ON freight.incidents (vehicle_id, occurred_at)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.incidents`);
}
}

View File

@@ -0,0 +1,93 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddMaintenanceDepth1970000000000 implements MigrationInterface {
name = 'AddMaintenanceDepth1970000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE SCHEMA IF NOT EXISTS freight`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.work_orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
vehicle_id UUID NOT NULL,
title VARCHAR NOT NULL,
description TEXT,
status VARCHAR NOT NULL DEFAULT 'OPEN',
priority VARCHAR NOT NULL DEFAULT 'MEDIUM',
assigned_to VARCHAR,
opened_at TIMESTAMPTZ NOT NULL DEFAULT now(),
closed_at TIMESTAMPTZ,
labor_cost NUMERIC(14, 2),
parts_cost NUMERIC(14, 2),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.parts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR NOT NULL,
sku VARCHAR,
category VARCHAR,
quantity_in_stock INT NOT NULL DEFAULT 0,
reorder_level INT NOT NULL DEFAULT 0,
unit_cost NUMERIC(14, 2),
location VARCHAR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.warranties (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
vehicle_id UUID NOT NULL,
component VARCHAR NOT NULL,
provider VARCHAR,
start_date DATE,
expiry_date DATE NOT NULL,
coverage_notes TEXT,
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_work_orders_vehicle_id_status" ON freight.work_orders (vehicle_id, status)`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_parts_category" ON freight.parts (category)`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_warranties_vehicle_id_expiry_date" ON freight.warranties (vehicle_id, expiry_date)`,
);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.work_orders
ADD CONSTRAINT "FK_work_orders_vehicle_id"
FOREIGN KEY (vehicle_id) REFERENCES freight.vehicles(id) ON DELETE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.warranties
ADD CONSTRAINT "FK_warranties_vehicle_id"
FOREIGN KEY (vehicle_id) REFERENCES freight.vehicles(id) ON DELETE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.warranties CASCADE`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.parts CASCADE`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.work_orders CASCADE`);
}
}

View File

@@ -0,0 +1,73 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddProcurement1980000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.vendors (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
name varchar NOT NULL,
type varchar,
contact_person varchar,
phone varchar,
email varchar,
address varchar,
is_active boolean NOT NULL DEFAULT true
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.asset_acquisitions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
vehicle_id uuid,
vendor_id uuid,
acquisition_type varchar NOT NULL,
acquisition_date date NOT NULL,
cost numeric(14,2),
useful_life_months integer,
salvage_value numeric(14,2),
lease_start date,
lease_end date,
monthly_payment numeric(14,2),
status varchar NOT NULL DEFAULT 'ACTIVE',
notes text
);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_asset_acquisitions_vehicle_date
ON freight.asset_acquisitions(vehicle_id, acquisition_date);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.asset_disposals (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
vehicle_id uuid NOT NULL,
disposal_date date NOT NULL,
method varchar NOT NULL,
sale_price numeric(14,2),
buyer varchar,
notes text
);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_asset_disposals_vehicle_date
ON freight.asset_disposals(vehicle_id, disposal_date);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.asset_disposals CASCADE;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.asset_acquisitions CASCADE;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.vendors CASCADE;`);
}
}

View File

@@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Per-km haulage rate on a vehicle (mainly trucks) plus the currency it's
* quoted in (ETB | USD, default ETB).
*/
export class AddVehiclePricePerKm1990000000000 implements MigrationInterface {
name = "AddVehiclePricePerKm1990000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.vehicles
ADD COLUMN IF NOT EXISTS price_per_km numeric(14,2),
ADD COLUMN IF NOT EXISTS currency varchar(8) NOT NULL DEFAULT 'ETB'
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.vehicles
DROP COLUMN IF EXISTS price_per_km,
DROP COLUMN IF EXISTS currency
`);
}
}

View File

@@ -0,0 +1,75 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Segment corridor bookings: a booking may ride only part of a train's route
* (its own origin→destination leg), so dispatch/arrival become per-booking
* facts and wagon capacity is consumed per leg instead of per whole route.
*
* - bookings.loaded_at / arrived_at (+ by-user): operator-confirmed load at
* the booking's origin yard and unload at its destination yard. Clearance
* gates read arrived_at, not the train's actual_arrival_at.
* - train_set_wagons.board_yard_id / alight_yard_id: the leg a consist slot
* occupies; NULL/NULL = whole route (legacy). Non-overlapping legs coexist
* without consuming each other's capacity.
* - wagon_movements: auditable ledger of every physical wagon relocation
* (loaded leg / empty reposition / manual correction) with the acting user.
*/
export class SegmentCorridorBookings1990000000000 implements MigrationInterface {
name = 'SegmentCorridorBookings1990000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS loaded_at timestamptz,
ADD COLUMN IF NOT EXISTS loaded_by_user_id uuid,
ADD COLUMN IF NOT EXISTS arrived_at timestamptz,
ADD COLUMN IF NOT EXISTS arrived_by_user_id uuid;
`);
await queryRunner.query(`
ALTER TABLE freight.train_set_wagons
ADD COLUMN IF NOT EXISTS board_yard_id uuid REFERENCES freight.yards(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS alight_yard_id uuid REFERENCES freight.yards(id) ON DELETE SET NULL;
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.wagon_movements (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
wagon_id uuid NOT NULL REFERENCES freight.wagons(id) ON DELETE CASCADE,
from_yard_id uuid REFERENCES freight.yards(id),
to_yard_id uuid NOT NULL REFERENCES freight.yards(id),
train_schedule_id uuid REFERENCES freight.train_schedules(id) ON DELETE SET NULL,
booking_id uuid REFERENCES freight.bookings(id) ON DELETE SET NULL,
kind varchar(30) NOT NULL,
moved_by_user_id uuid,
occurred_at timestamptz NOT NULL DEFAULT now(),
note text,
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_wagon_movements_wagon_occurred" ON freight.wagon_movements (wagon_id, occurred_at);`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_wagon_movements_schedule" ON freight.wagon_movements (train_schedule_id);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_movements;`);
await queryRunner.query(`
ALTER TABLE freight.train_set_wagons
DROP COLUMN IF EXISTS board_yard_id,
DROP COLUMN IF EXISTS alight_yard_id;
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS loaded_at,
DROP COLUMN IF EXISTS loaded_by_user_id,
DROP COLUMN IF EXISTS arrived_at,
DROP COLUMN IF EXISTS arrived_by_user_id;
`);
}
}

View File

@@ -0,0 +1,83 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Moves service-level priority off the service_types table and onto the
* admin-managed priority_configs table as a new CUSTOMS rule type.
*
* - Drops service_types.priority_bonus_points (replaced by CUSTOMS configs).
* - Widens priority_configs.type CHECK to allow 'CUSTOMS' (currency must be
* null, same as WAGON).
* - Seeds the two customs wagon-count tiers: 110 → 7 pts, 1153 → 15 pts.
* CUSTOMS rules apply only when the booking's service type includesCustoms.
*/
export class AddCustomsPriorityConfig2000000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.service_types DROP COLUMN IF EXISTS priority_bonus_points;
`);
await queryRunner.query(`
ALTER TABLE freight.priority_configs
DROP CONSTRAINT IF EXISTS priority_configs_type_check;
`);
await queryRunner.query(`
ALTER TABLE freight.priority_configs
ADD CONSTRAINT priority_configs_type_check
CHECK (type IN ('WAGON', 'CURRENCY', 'CUSTOMS'));
`);
await queryRunner.query(`
ALTER TABLE freight.priority_configs
DROP CONSTRAINT IF EXISTS chk_currency_for_type;
`);
await queryRunner.query(`
ALTER TABLE freight.priority_configs
ADD CONSTRAINT chk_currency_for_type CHECK (
(type = 'WAGON' AND currency IS NULL) OR
(type = 'CURRENCY' AND currency IS NOT NULL) OR
(type = 'CUSTOMS' AND currency IS NULL)
);
`);
await queryRunner.query(`
INSERT INTO freight.priority_configs
(type, label, currency, min_wagon_count, max_wagon_count, score_points, is_active, display_order)
VALUES
('CUSTOMS', 'With customs 110 wagons', NULL, 1, 10, 7, true, 1),
('CUSTOMS', 'With customs 1153 wagons', NULL, 11, 53, 15, true, 2);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DELETE FROM freight.priority_configs WHERE type = 'CUSTOMS';
`);
await queryRunner.query(`
ALTER TABLE freight.priority_configs
DROP CONSTRAINT IF EXISTS chk_currency_for_type;
`);
await queryRunner.query(`
ALTER TABLE freight.priority_configs
ADD CONSTRAINT chk_currency_for_type CHECK (
(type = 'WAGON' AND currency IS NULL) OR
(type = 'CURRENCY' AND currency IS NOT NULL)
);
`);
await queryRunner.query(`
ALTER TABLE freight.priority_configs
DROP CONSTRAINT IF EXISTS priority_configs_type_check;
`);
await queryRunner.query(`
ALTER TABLE freight.priority_configs
ADD CONSTRAINT priority_configs_type_check
CHECK (type IN ('WAGON', 'CURRENCY'));
`);
await queryRunner.query(`
ALTER TABLE freight.service_types
ADD COLUMN IF NOT EXISTS priority_bonus_points INT NOT NULL DEFAULT 0;
`);
}
}

View File

@@ -0,0 +1,69 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* GPS tracking: physical trackers (gps_devices, one denormalized latest fix per
* device for the live map) + append-only fix history (gps_positions).
*/
export class AddGpsTracking2000000000000 implements MigrationInterface {
name = "AddGpsTracking2000000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.gps_devices (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
imei varchar(20) NOT NULL UNIQUE,
name varchar,
vehicle_id uuid REFERENCES freight.vehicles(id),
status varchar(16) NOT NULL DEFAULT 'REGISTERED',
last_seen_at timestamptz,
last_lat numeric(10,6),
last_lng numeric(10,6),
last_speed numeric(6,2),
last_course int,
last_fix_at timestamptz,
voltage_level int,
gsm_level int,
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_GPS_DEVICES_VEHICLE"
ON freight.gps_devices (vehicle_id)
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.gps_positions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
device_id uuid NOT NULL,
imei varchar(20) NOT NULL,
vehicle_id uuid,
lat numeric(10,6) NOT NULL,
lng numeric(10,6) NOT NULL,
speed numeric(6,2) NOT NULL DEFAULT 0,
course int NOT NULL DEFAULT 0,
satellites int NOT NULL DEFAULT 0,
positioned boolean NOT NULL DEFAULT false,
gps_time timestamptz NOT NULL,
alarm int NOT NULL DEFAULT 0,
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_GPS_POSITIONS_DEVICE_TIME"
ON freight.gps_positions (device_id, gps_time)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_GPS_POSITIONS_VEHICLE_TIME"
ON freight.gps_positions (vehicle_id, gps_time)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.gps_positions`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.gps_devices`);
}
}

View File

@@ -0,0 +1,32 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Truck detention support.
* - last_mile.arrived_at / delivered_at: the detention window for an EDR
* last-mile vehicle. The clock runs from arrival at destination; the customer
* has a grace period (default 3h) to clear/return, after which detention
* accrues per truck per day until delivered_at (or now, if still out).
* - warehouse_fee_rules.free_hours: configurable grace window (hours) for a
* TRUCK_DETENTION_FEE rule; null/0 falls back to the 3-hour default.
*/
export class AddTruckDetentionTiming2000000000000 implements MigrationInterface {
name = 'AddTruckDetentionTiming2000000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.last_mile ADD COLUMN IF NOT EXISTS arrived_at timestamptz`,
);
await queryRunner.query(
`ALTER TABLE freight.last_mile ADD COLUMN IF NOT EXISTS delivered_at timestamptz`,
);
await queryRunner.query(
`ALTER TABLE freight.warehouse_fee_rules ADD COLUMN IF NOT EXISTS free_hours int`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE freight.warehouse_fee_rules DROP COLUMN IF EXISTS free_hours`);
await queryRunner.query(`ALTER TABLE freight.last_mile DROP COLUMN IF EXISTS delivered_at`);
await queryRunner.query(`ALTER TABLE freight.last_mile DROP COLUMN IF EXISTS arrived_at`);
}
}

View File

@@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Adds bookings.consolidation_resume_status: the status a booking parked in
* PENDING_CONSOLIDATION returns to once it pairs with a wagon partner.
*
* Direct customer bookings leave it NULL (they resume to SUBMITTED, unchanged).
* Contract-drawdown bookings (GL shipments) set it to the status
* createUnderContract would otherwise have used (OPERATION_REQUEST_PENDING or
* AWAITING_DOCUMENTS), so pairing resumes them into the contract-booking flow
* instead of wrongly moving them to SUBMITTED.
*/
export class AddConsolidationResumeStatus2010000000000
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS consolidation_resume_status VARCHAR(40);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS consolidation_resume_status;
`);
}
}

View File

@@ -0,0 +1,20 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Truck-detention rules can be scoped by vehicle type (TRUCK / VAN / TRAILER /
* TANKER / FLATBED / …), so different truck types carry different detention
* rates. Null = applies to any truck type.
*/
export class AddFeeRuleVehicleType2010000000000 implements MigrationInterface {
name = 'AddFeeRuleVehicleType2010000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.warehouse_fee_rules ADD COLUMN IF NOT EXISTS vehicle_type varchar(20)`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE freight.warehouse_fee_rules DROP COLUMN IF EXISTS vehicle_type`);
}
}

View File

@@ -0,0 +1,41 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Repair: AddEmailToOtpVerifications1900000000000 originally altered
* `public.otp_verifications`, but the OtpVerification entity pins
* schema: "freight". On any DB where that migration already ran (and is recorded
* as executed, so it won't run again), the real `freight.otp_verifications` table
* never got the `email` column and `phone` was never made nullable — so OTP send
* dies with `column OtpVerification.email does not exist`.
*
* This migration re-applies the change against the correct schema. Idempotent
* (IF NOT EXISTS / no-op DROP NOT NULL), and guarded so it's a no-op when the
* freight table is absent.
*/
export class RepairOtpEmailSchema2020000000000 implements MigrationInterface {
name = "RepairOtpEmailSchema2020000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
const exists = await queryRunner.hasTable("freight.otp_verifications");
if (!exists) return;
await queryRunner.query(`
ALTER TABLE freight.otp_verifications
ALTER COLUMN phone DROP NOT NULL
`);
await queryRunner.query(`
ALTER TABLE freight.otp_verifications
ADD COLUMN IF NOT EXISTS email varchar UNIQUE
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
const exists = await queryRunner.hasTable("freight.otp_verifications");
if (!exists) return;
await queryRunner.query(`
ALTER TABLE freight.otp_verifications
DROP COLUMN IF EXISTS email
`);
}
}

View File

@@ -0,0 +1,35 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Weights are tonnes everywhere. Warehouse / yard / zone capacity was stored in
* kg (e.g. 25000, 5000, 2500) — convert existing rows to tonnes (÷1000). Cargo
* weight (warehouse_inventory.weight ← cargo_total_weight_vgm) is already tonnes
* and is NOT touched; truck gross weight has no data yet. Runs exactly once
* (tracked by TypeORM) — re-running would divide again.
*/
export class WarehouseCapacityKgToTons2020000000000 implements MigrationInterface {
name = 'WarehouseCapacityKgToTons2020000000000';
private readonly tables = ['warehouses', 'warehouse_yards', 'warehouse_zones'];
private readonly columns = ['capacity_weight', 'current_weight', 'max_weight'];
public async up(queryRunner: QueryRunner): Promise<void> {
for (const table of this.tables) {
for (const column of this.columns) {
await queryRunner.query(
`UPDATE freight.${table} SET ${column} = ${column} / 1000.0 WHERE ${column} IS NOT NULL`,
);
}
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
for (const table of this.tables) {
for (const column of this.columns) {
await queryRunner.query(
`UPDATE freight.${table} SET ${column} = ${column} * 1000.0 WHERE ${column} IS NOT NULL`,
);
}
}
}
}

View File

@@ -0,0 +1,58 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Adds train_schedules.reference: a human-facing unique schedule number
* S-YYYY-NNNNN (per-year sequence, like bookings' BK-YYYY-NNNNNN).
*
* - Adds the nullable column.
* - Backfills existing rows: within each created-at year, numbers rows by
* created_at ascending (oldest → S-<year>-00001). Deterministic order.
* - Adds a partial unique index (NULLs allowed so a future insert can stage
* the row before the app stamps its reference).
*/
export class AddTrainScheduleReference2030000000000
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
ADD COLUMN IF NOT EXISTS reference VARCHAR(20);
`);
// Backfill per-year, ordered by created_at (oldest = 00001). Uses the row's
// own created-at year as the reference year so historical rows keep a
// sensible number.
await queryRunner.query(`
WITH numbered AS (
SELECT
id,
EXTRACT(YEAR FROM created_at)::int AS yr,
ROW_NUMBER() OVER (
PARTITION BY EXTRACT(YEAR FROM created_at)
ORDER BY created_at ASC, id ASC
) AS seq
FROM freight.train_schedules
WHERE reference IS NULL
)
UPDATE freight.train_schedules ts
SET reference = 'S-' || numbered.yr || '-' || LPAD(numbered.seq::text, 5, '0')
FROM numbered
WHERE ts.id = numbered.id;
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS ux_train_schedules_reference
ON freight.train_schedules (reference)
WHERE reference IS NOT NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DROP INDEX IF EXISTS freight.ux_train_schedules_reference;
`);
await queryRunner.query(`
ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS reference;
`);
}
}

View File

@@ -28,7 +28,7 @@ export const BOOKING_LIST_TABS: ReadonlyArray<{
{ key: 'payment', statuses: ['FULLY_EXECUTED'] },
{
key: 'operations',
statuses: ['IN_TRANSIT', 'PAID'],
statuses: ['IN_TRANSIT', 'ARRIVED', 'PAID'],
},
{ key: 'completed', statuses: ['COMPLETED'] },
{ key: 'closed', statuses: ['REJECTED', 'CANCELLED'] },

View File

@@ -102,6 +102,7 @@ export function computeNextStep(
description: 'Mark shipment as in transit',
};
case 'IN_TRANSIT':
case 'ARRIVED':
return {
action: 'COMPLETE',
description: 'Mark shipment complete',

View File

@@ -485,7 +485,7 @@ export class BookingTransitionService {
async complete(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ["IN_TRANSIT"]);
assertBookingStatus(booking, ["IN_TRANSIT", "ARRIVED"]);
const updated = await this.bookingsRepository.update(bookingId, {
status: "COMPLETED",

View File

@@ -350,6 +350,21 @@ export class BookingsController {
return this.customerTruckService.addTruck(id, dto);
}
@Patch(':id/customer-trucks/:assignmentId')
@ApiOperation({ summary: 'Edit a not-yet-arrived customer truck (plate/driver/type + containers)' })
async updateCustomerTruck(
@Param('id', ParseUUIDPipe) id: string,
@Param('assignmentId', ParseUUIDPipe) assignmentId: string,
@Body() dto: 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.updateTruck(id, assignmentId, dto);
}
@Delete(':id/customer-trucks/:assignmentId')
@ApiOperation({ summary: 'Remove a not-yet-arrived customer truck from a booking' })
async removeCustomerTruck(

View File

@@ -114,6 +114,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
BookingPricingService,
BookingInvoiceService,
BookingLifecycleNotifierService,
ConsolidationService,
CustomerTruckService,
ContainerReceiptService,
],

View File

@@ -272,26 +272,51 @@ export class BookingsRepository extends BaseRepository<Booking> {
}
/**
* Pair two bookings for consolidation. Both return to SUBMITTED so staff can
* accept them into the approval chain; the link itself (consolidationPartnerId)
* marks them as consolidated in the UI.
* Pair two bookings for consolidation. Each returns to its own resume status —
* SUBMITTED for a direct customer booking (so staff can accept it into the
* approval chain) or the stored consolidationResumeStatus for a contract
* drawdown (OPERATION_REQUEST_PENDING / AWAITING_DOCUMENTS). The link itself
* (consolidationPartnerId) marks them as consolidated in the UI. The resume
* status is cleared once used, so a later un-pair re-parks cleanly.
*/
async pairConsolidation(bookingId: string, partnerId: string): Promise<void> {
const [booking, partner] = await Promise.all([
this.repository.findOne({
where: { id: bookingId },
select: { id: true, consolidationResumeStatus: true },
}),
this.repository.findOne({
where: { id: partnerId },
select: { id: true, consolidationResumeStatus: true },
}),
]);
await this.repository.update(bookingId, {
consolidationPartnerId: partnerId,
status: 'SUBMITTED',
status: booking?.consolidationResumeStatus ?? 'SUBMITTED',
consolidationResumeStatus: null,
} as never);
await this.repository.update(partnerId, {
consolidationPartnerId: bookingId,
status: 'SUBMITTED',
status: partner?.consolidationResumeStatus ?? 'SUBMITTED',
consolidationResumeStatus: null,
} as never);
}
/** Park a booking that needs consolidation but has no partner yet. */
async parkForConsolidation(bookingId: string): Promise<void> {
/**
* Park a booking that needs consolidation but has no partner yet. The optional
* resumeStatus is where the booking returns once it pairs — pass it for a
* contract drawdown so pairing resumes the contract-booking flow rather than
* the direct-booking SUBMITTED default.
*/
async parkForConsolidation(
bookingId: string,
resumeStatus?: string | null,
): Promise<void> {
await this.repository.update(bookingId, {
consolidationPartnerId: null,
status: 'PENDING_CONSOLIDATION',
consolidationResumeStatus: resumeStatus ?? null,
} as never);
}
@@ -1019,6 +1044,81 @@ export class BookingsRepository extends BaseRepository<Booking> {
.getMany();
}
/**
* Corridor day pool: ready, not-yet-allocated bookings for one EAT day whose
* origin AND destination both lie on the day's corridor stop set — covers
* full-route bookings and sub-corridor bookings (Dire→Djibouti on an
* Addis→…→Djibouti train). The caller still verifies stop ORDER per train
* via the corridor budget; this query only narrows the pool. Same status
* rules and ordering as {@link findBatchPool}.
*/
findBatchPoolByCorridorDay(
corridorYardIds: string[],
day: string,
): Promise<Booking[]> {
if (corridorYardIds.length === 0) return Promise.resolve([]);
return this.repository
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
.where('booking.origin_yard_id IN (:...corridorYardIds)', { corridorYardIds })
.andWhere('booking.destination_yard_id IN (:...corridorYardIds)', {
corridorYardIds,
})
.andWhere(
`DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`,
{ day },
)
.andWhere('sb.id IS NULL')
.andWhere(
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`,
)
.orderBy('booking.is_government', 'DESC')
.addOrderBy('booking.priority_score', 'DESC')
.addOrderBy('booking.fully_executed_at', 'ASC')
.addOrderBy('booking.created_at', 'ASC')
.getMany();
}
/**
* Commercial bookings on the day's corridor whose operation request was NOT
* accepted by staff (still pending / changes / price-confirm) and are not yet
* linked to a train. These never reached FULLY_EXECUTED, so they never enter the
* batch pool; the window's doc-review end sweeps them to EXPIRED. Government
* bookings are excluded (they don't go through the customer window).
*/
findUnacceptedForRouteDay(
corridorYardIds: string[],
day: string,
): Promise<Booking[]> {
if (corridorYardIds.length === 0) return Promise.resolve([]);
return this.repository
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
.where('booking.origin_yard_id IN (:...corridorYardIds)', { corridorYardIds })
.andWhere('booking.destination_yard_id IN (:...corridorYardIds)', {
corridorYardIds,
})
.andWhere(
`DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`,
{ day },
)
.andWhere('sb.id IS NULL')
.andWhere('booking.is_government = false')
.andWhere(
`booking.status IN (
'OPERATION_REQUESTED',
'OPERATION_REQUEST_PENDING',
'OPERATION_CHANGES_REQUESTED',
'OPERATION_PRICE_PENDING_CONFIRM'
)`,
)
.getMany();
}
/** Every booking that targeted a schedule (any status) — for the batch monitoring board. */
findAllBySchedule(scheduleId: string): Promise<Booking[]> {
return this.repository

View File

@@ -24,6 +24,7 @@ import {
RuleEngineService,
} from '../rule-engine/rule-engine.service';
import { InjectDataSource } from '@nestjs/typeorm';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { DataSource, In } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
@@ -99,6 +100,7 @@ export class BookingsService {
private readonly consolidationService: ConsolidationService,
private readonly vehiclesService: VehiclesService,
private readonly contractPdfService: ContractPdfService,
private readonly events: EventEmitter2,
) {}
async assignCustomerTruck(
@@ -493,6 +495,14 @@ export class BookingsService {
messages.push(
this.consolidationService.describePaired(partner.reference, slots),
);
// Let deferred owners (e.g. contract drawdowns whose invoice/milestones
// were held while the booking waited) finalize now that a whole wagon
// exists. Fire-and-forget: a listener failure must not undo the pairing.
this.events
.emitAsync('booking.consolidation.paired', {
bookingIds: [booking.id, partner.id],
})
.catch(() => undefined);
return { booking: paired, messages };
}
@@ -605,10 +615,15 @@ export class BookingsService {
if (schedule.bookingWindowStatus !== 'OPEN') {
throw new BadRequestException('Selected schedule is no longer accepting bookings');
}
if (
schedule.originStationId !== dto.originYardId ||
schedule.destinationStationId !== dto.destinationYardId
) {
// Corridor-aware: the booking's leg must lie on the schedule's route in
// stop order — sub-corridor pins (Dire→Djibouti on an Addis→Djibouti
// train) are valid.
const stops = await this.trainSchedulingService.stopYardsForSchedule(
schedule,
);
const fromIdx = stops.indexOf(dto.originYardId);
const toIdx = stops.indexOf(dto.destinationYardId);
if (fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx) {
throw new BadRequestException('Selected schedule is not on the booking route');
}
} else if (dto.scheduledDate) {
@@ -1278,6 +1293,14 @@ export class BookingsService {
): Promise<Freight.IBookingTracking> {
const booking = await this.findById(bookingId);
const journey = {
bookingStatus: booking.status ?? null,
bookingOriginYardId: booking.originYardId ?? null,
bookingDestinationYardId: booking.destinationYardId ?? null,
loadedAt: booking.loadedAt ? new Date(booking.loadedAt).toISOString() : null,
arrivedAt: booking.arrivedAt ? new Date(booking.arrivedAt).toISOString() : null,
};
const empty: Freight.IBookingTracking = {
bookingId: booking.id,
bookingReference: booking.reference,
@@ -1295,6 +1318,7 @@ export class BookingsService {
actualArrivalAt: null,
scheduledDepartureAt: null,
scheduledArrivalAt: null,
...journey,
};
if (!booking.trainScheduleId) {
@@ -1331,6 +1355,7 @@ export class BookingsService {
actualArrivalAt: track.actualArrivalAt,
scheduledDepartureAt: track.scheduledDepartureAt,
scheduledArrivalAt: track.scheduledArrivalAt,
...journey,
};
}
@@ -1607,7 +1632,7 @@ export class BookingsService {
if (!booking.isGovernment) {
throw new BadRequestException('Only government bookings can be expedited');
}
const blocked = ['PAID', 'IN_TRANSIT', 'COMPLETED', 'CANCELLED', 'REJECTED'];
const blocked = ['PAID', 'IN_TRANSIT', 'ARRIVED', 'COMPLETED', 'CANCELLED', 'REJECTED'];
if (blocked.includes(booking.status)) {
throw new BadRequestException(`Cannot expedite booking in status ${booking.status}`);
}

View File

@@ -42,16 +42,16 @@ export class CustomerTruckService {
const booking = await this.loadBookingGuard(bookingId);
this.assertSelfHaulPaid(booking);
const isExport = booking.tradeDirection === 'EXPORT';
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
// EXPORT: the truck delivers 12 known containers. IMPORT: containers are
// not pre-specified — they are registered + weighed when the truck leaves.
if (isExport) {
if (requested.length < 1 || requested.length > 2) {
throw new BadRequestException('An export truck must carry 1 or 2 of the booking containers');
}
} else if (requested.length > 2) {
// Both import and export specify the containers each truck carries. Capacity
// is size-based: a 40ft container fills the truck (max 1); two 20ft containers
// fit (max 2), no size mixing. #trucks <= #containers follows naturally since
// each container is assigned to exactly one truck.
if (requested.length < 1) {
throw new BadRequestException('Select at least one container for this truck');
}
if (requested.length > 2) {
throw new BadRequestException('A truck carries at most 2 containers');
}
@@ -68,6 +68,13 @@ export class CustomerTruckService {
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
}
}
// Size cap: a 40ft container fills the truck.
const sizes = await this.containerSizes(bookingId, requested);
if (sizes.some((s) => s.includes('40')) && requested.length > 1) {
throw new BadRequestException(
'A 40ft container fills the truck — assign only 1 container to this truck',
);
}
}
await this.dataSource.transaction(async (manager) => {
@@ -133,6 +140,76 @@ export class CustomerTruckService {
return this.listTrucks(bookingId);
}
/**
* Edit a truck assignment — plate/driver/type and the containers it carries.
* Allowed only until the truck has arrived (same guard as removal). Container
* rules mirror {@link addTruck}: 12 of the booking's containers, none already
* on another truck, and a 40ft container fills the truck (max 1).
*/
async updateTruck(
bookingId: string,
assignmentId: string,
dto: AddCustomerTruckDto,
): Promise<CustomerTruckAssignment[]> {
const booking = await this.loadBookingGuard(bookingId);
this.assertSelfHaulPaid(booking);
const assignment = await this.assignments.findByIdWithContainers(assignmentId);
if (!assignment || assignment.bookingId !== bookingId) {
throw new NotFoundException('Truck assignment not found for this booking');
}
if (assignment.arrivedAt) {
throw new ConflictException('Cannot edit a truck that has already arrived');
}
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
if (requested.length < 1) {
throw new BadRequestException('Select at least one container for this truck');
}
if (requested.length > 2) {
throw new BadRequestException('A truck carries at most 2 containers');
}
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
for (const n of requested) {
if (!bookingNumbers.includes(n)) {
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
}
}
// Exclude THIS truck's own containers so re-saving the same set is allowed.
const assignedElsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId);
for (const n of requested) {
if (assignedElsewhere.includes(n)) {
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
}
}
const sizes = await this.containerSizes(bookingId, requested);
if (sizes.some((s) => s.includes('40')) && requested.length > 1) {
throw new BadRequestException(
'A 40ft container fills the truck — assign only 1 container to this truck',
);
}
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
plateNumber: dto.truckPlateNumber.trim().toUpperCase(),
driverName: dto.driverName.trim(),
truckType: dto.truckType.trim(),
});
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
await manager.getRepository(CustomerTruckContainer).save(
requested.map((containerNumber) =>
manager.getRepository(CustomerTruckContainer).create({
assignmentId,
bookingId,
containerNumber,
}),
),
);
});
return this.listTrucks(bookingId);
}
/**
* Register an IMPORT self-haul truck leaving the port: the containers it
* actually loaded (replacing any provisional list) and its weighed gross.
@@ -244,7 +321,7 @@ export class CustomerTruckService {
}
}
const grossKg = await this.vgmKgForContainers(bookingId, requested);
const grossTons = await this.vgmTonsForContainers(bookingId, requested);
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
await manager.getRepository(CustomerTruckContainer).save(
@@ -256,18 +333,19 @@ export class CustomerTruckService {
}),
),
);
// Provisional gross from the loaded containers' VGM — overridden by the
// weighed gross on departure.
// Provisional gross (tonnes) from the loaded containers' VGM — overridden
// by the weighed gross on departure. (Column is *_kg but holds tonnes.)
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
grossWeightKg: grossKg,
grossWeightKg: grossTons,
});
});
return this.listTrucks(bookingId);
}
private async vgmKgForContainers(bookingId: string, numbers: string[]): Promise<number> {
const [row]: Array<{ kg: string }> = await this.dataSource.query(
`SELECT COALESCE(SUM(bcu.vgm_tons), 0) * 1000 AS kg
/** Summed VGM (tonnes) of the given containers — provisional truck gross. */
private async vgmTonsForContainers(bookingId: string, numbers: string[]): Promise<number> {
const [row]: Array<{ tons: string }> = await this.dataSource.query(
`SELECT COALESCE(SUM(bcu.vgm_tons), 0) AS tons
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
@@ -276,7 +354,7 @@ export class CustomerTruckService {
AND bcu.deleted_at IS NULL`,
[bookingId, numbers],
);
return Number(row?.kg ?? 0);
return Number(row?.tons ?? 0);
}
/**
@@ -399,4 +477,20 @@ export class CustomerTruckService {
);
return rows.map((r) => r.containerNumber.trim().toUpperCase());
}
/** Contract container sizes (e.g. "20ft" / "40ft") for the given container numbers. */
private async containerSizes(bookingId: string, numbers: string[]): Promise<string[]> {
if (!numbers.length) return [];
const rows: Array<{ size: string | null }> = await this.dataSource.query(
`SELECT bc.container_size AS "size"
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1
AND UPPER(bcu.container_number) = ANY($2)
AND bcu.deleted_at IS NULL`,
[bookingId, numbers],
);
return rows.map((r) => (r.size ?? '').trim());
}
}

View File

@@ -37,6 +37,7 @@ export const BOOKING_STATUSES = [
'PAYMENT_VERIFICATION_IN_PROGRESS',
'PAID',
'IN_TRANSIT',
'ARRIVED',
'COMPLETED',
'REJECTED',
'CANCELLED',
@@ -430,6 +431,14 @@ export class Booking extends BaseEntity {
@JoinColumn({ name: 'consolidation_partner_id' })
consolidationPartner?: Booking | null;
// Status a booking parked in PENDING_CONSOLIDATION returns to once it pairs.
// Null for direct customer bookings (they resume to SUBMITTED, the historical
// default); contract-drawdown bookings set it to the status createUnderContract
// would otherwise have used (OPERATION_REQUEST_PENDING / AWAITING_DOCUMENTS), so
// pairing resumes them into the right flow instead of the direct-booking one.
@Column({ name: 'consolidation_resume_status', type: 'varchar', length: 40, nullable: true })
consolidationResumeStatus?: string | null;
@Column({ name: 'wagons_required', type: 'numeric', precision: 6, scale: 2, nullable: true })
wagonsRequired?: number | null;
@@ -458,6 +467,24 @@ export class Booking extends BaseEntity {
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
trainScheduleId?: string | null;
// ── Per-booking journey (segment corridor bookings) ────────────────────────
// A booking rides only its own origin→destination leg of the train's route,
// so dispatch/arrival are per-booking facts, not train facts. Clearance gates
// read arrivedAt (booking arrival), never the schedule's actualArrivalAt.
/** Operator confirmed cargo loaded at the booking's origin yard (per-booking dispatch). */
@Column({ name: 'loaded_at', type: 'timestamptz', nullable: true })
loadedAt?: Date | null;
@Column({ name: 'loaded_by_user_id', type: 'uuid', nullable: true })
loadedByUserId?: string | null;
/** Operator confirmed cargo unloaded at the booking's destination yard (per-booking arrival). */
@Column({ name: 'arrived_at', type: 'timestamptz', nullable: true })
arrivedAt?: Date | null;
@Column({ name: 'arrived_by_user_id', type: 'uuid', nullable: true })
arrivedByUserId?: string | null;
/** End of the pay window once the booking is SELECTED_FOR_BATCH. */
@Column({ name: 'payment_deadline', type: 'timestamptz', nullable: true })
paymentDeadline?: Date | null;

View File

@@ -21,6 +21,7 @@ const COMMITTED_STATUSES = [
'PAYMENT_VERIFICATION_IN_PROGRESS',
'PAID',
'IN_TRANSIT',
'ARRIVED',
'COMPLETED',
'DELIVERED',
'CONSOLIDATED',

View File

@@ -0,0 +1,53 @@
import { Controller, Post, Get, Patch, Delete, Body, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { ComplianceService } from './compliance.service';
import {
CreateComplianceRecordDto,
UpdateComplianceRecordDto,
} from './dto/create-compliance-record.dto';
import { ComplianceType } from './entities/compliance-record.entity';
@ApiTags('Vehicle Compliance')
@Controller('compliance')
export class ComplianceController {
constructor(private readonly complianceService: ComplianceService) {}
@Post()
@ApiOperation({ summary: 'Create a compliance record' })
create(@Body() dto: CreateComplianceRecordDto) {
return this.complianceService.create(dto);
}
@Get()
@ApiOperation({ summary: 'List compliance records' })
findAll(
@Query('vehicleId') vehicleId?: string,
@Query('type') type?: ComplianceType,
) {
return this.complianceService.findAll({ vehicleId, type });
}
@Get('alerts')
@ApiOperation({ summary: 'List overdue / due-soon compliance & expiry alerts' })
getAlerts() {
return this.complianceService.getAlerts();
}
@Get(':id')
@ApiOperation({ summary: 'Get a compliance record by ID' })
findOne(@Param('id') id: string) {
return this.complianceService.findById(id);
}
@Patch(':id')
@ApiOperation({ summary: 'Update a compliance record' })
update(@Param('id') id: string, @Body() dto: UpdateComplianceRecordDto) {
return this.complianceService.update(id, dto);
}
@Delete(':id')
@ApiOperation({ summary: 'Soft-delete a compliance record' })
remove(@Param('id') id: string) {
return this.complianceService.remove(id);
}
}

View File

@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ComplianceRecord } from './entities/compliance-record.entity';
import { Vehicle } from '../vehicles/entities/vehicle.entity';
import { Driver } from '../drivers/entities/driver.entity';
import { ComplianceService } from './compliance.service';
import { ComplianceRepository } from './compliance.repository';
import { ComplianceController } from './compliance.controller';
@Module({
imports: [TypeOrmModule.forFeature([ComplianceRecord, Vehicle, Driver])],
providers: [ComplianceService, ComplianceRepository],
controllers: [ComplianceController],
exports: [ComplianceService],
})
export class ComplianceModule {}

View File

@@ -0,0 +1,26 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { BaseRepository } from '@edr/api-common';
import { Repository, FindOptionsWhere } from 'typeorm';
import { ComplianceRecord, ComplianceType } from './entities/compliance-record.entity';
@Injectable()
export class ComplianceRepository extends BaseRepository<ComplianceRecord> {
constructor(
@InjectRepository(ComplianceRecord)
private readonly complianceRepository: Repository<ComplianceRecord>,
) {
super(complianceRepository);
}
async findWithFilters(filter: { vehicleId?: string; type?: ComplianceType } = {}) {
const where: FindOptionsWhere<ComplianceRecord> = {};
if (filter.vehicleId) where.vehicleId = filter.vehicleId;
if (filter.type) where.type = filter.type;
return this.complianceRepository.find({
where,
order: { expiryDate: 'ASC' },
});
}
}

View File

@@ -0,0 +1,184 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, IsNull, Repository } from 'typeorm';
import { ComplianceRepository } from './compliance.repository';
import {
ComplianceRecord,
ComplianceStatus,
ComplianceType,
} from './entities/compliance-record.entity';
import {
CreateComplianceRecordDto,
UpdateComplianceRecordDto,
} from './dto/create-compliance-record.dto';
import { Vehicle } from '../vehicles/entities/vehicle.entity';
import { Driver } from '../drivers/entities/driver.entity';
const DUE_SOON_DAYS = 30;
const MS_PER_DAY = 24 * 60 * 60 * 1000;
export type AlertSeverity = 'OVERDUE' | 'DUE_SOON';
export interface ComplianceAlert {
vehicleId: string;
vehiclePlate?: string;
kind: string;
label: string;
expiryDate: string;
daysUntil: number;
severity: AlertSeverity;
}
@Injectable()
export class ComplianceService {
constructor(
private readonly complianceRepository: ComplianceRepository,
@InjectRepository(Vehicle)
private readonly vehicleRepo: Repository<Vehicle>,
@InjectRepository(Driver)
private readonly driverRepo: Repository<Driver>,
) {}
async create(dto: CreateComplianceRecordDto): Promise<ComplianceRecord> {
return this.complianceRepository.create({
...dto,
status: dto.status ?? this.deriveStatus(dto.expiryDate),
});
}
async findAll(filter: { vehicleId?: string; type?: ComplianceType } = {}) {
return this.complianceRepository.findWithFilters(filter);
}
async findById(id: string): Promise<ComplianceRecord> {
const record = await this.complianceRepository.findById(id);
if (!record) {
throw new NotFoundException(`Compliance record ${id} not found`);
}
return record;
}
async update(id: string, dto: UpdateComplianceRecordDto): Promise<ComplianceRecord> {
await this.findById(id);
const nextExpiry = dto.expiryDate;
const updated = await this.complianceRepository.update(id, {
...dto,
// Re-derive status when expiry changes and the caller didn't set it explicitly.
status: dto.status ?? (nextExpiry ? this.deriveStatus(nextExpiry) : undefined),
});
return updated!;
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.complianceRepository.softDelete(id);
}
/**
* Flat list of compliance items that are overdue or due within 30 days.
* Combines the compliance_records table with the vehicle expiry columns
* (insurance / registration / next inspection) and assigned-driver license
* expiry. `new Date()` is fine here — this is the NestJS API runtime.
*/
async getAlerts(): Promise<ComplianceAlert[]> {
const now = new Date();
const alerts: ComplianceAlert[] = [];
const vehicles = await this.vehicleRepo.find({ where: { deletedAt: IsNull() } });
const vehicleById = new Map(vehicles.map((v) => [v.id, v]));
const plateOf = (v?: Vehicle) => v?.plateNumber ?? v?.code ?? undefined;
// 1. Compliance records
const records = await this.complianceRepository.findWithFilters();
for (const record of records) {
const computed = this.computeSeverity(record.expiryDate, now);
if (!computed) continue;
const vehicle = vehicleById.get(record.vehicleId);
alerts.push({
vehicleId: record.vehicleId,
vehiclePlate: plateOf(vehicle),
kind: record.type,
label: record.documentNumber
? `${record.type} · ${record.documentNumber}`
: record.type,
expiryDate: record.expiryDate,
daysUntil: computed.daysUntil,
severity: computed.severity,
});
}
// 2. Vehicle-level expiry columns
const vehicleFields: { field: keyof Vehicle; kind: string; label: string }[] = [
{ field: 'insuranceExpiry', kind: 'INSURANCE', label: 'Insurance' },
{ field: 'registrationExpiry', kind: 'REGISTRATION', label: 'Registration' },
{ field: 'nextInspectionDate', kind: 'INSPECTION', label: 'Inspection' },
];
for (const vehicle of vehicles) {
for (const { field, kind, label } of vehicleFields) {
const value = vehicle[field] as string | undefined;
if (!value) continue;
const computed = this.computeSeverity(value, now);
if (!computed) continue;
alerts.push({
vehicleId: vehicle.id,
vehiclePlate: plateOf(vehicle),
kind,
label,
expiryDate: value,
daysUntil: computed.daysUntil,
severity: computed.severity,
});
}
}
// 3. Assigned-driver license expiry
const driverIds = [
...new Set(vehicles.map((v) => v.assignedDriverId).filter((id): id is string => !!id)),
];
if (driverIds.length > 0) {
const drivers = await this.driverRepo.find({ where: { id: In(driverIds) } });
const driverById = new Map(drivers.map((d) => [d.id, d]));
for (const vehicle of vehicles) {
if (!vehicle.assignedDriverId) continue;
const driver = driverById.get(vehicle.assignedDriverId);
if (!driver?.licenseExpiryDate) continue;
const expiry =
driver.licenseExpiryDate instanceof Date
? driver.licenseExpiryDate.toISOString().slice(0, 10)
: String(driver.licenseExpiryDate);
const computed = this.computeSeverity(expiry, now);
if (!computed) continue;
alerts.push({
vehicleId: vehicle.id,
vehiclePlate: plateOf(vehicle),
kind: 'DRIVER_LICENSE',
label: `Driver License · ${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(),
expiryDate: expiry,
daysUntil: computed.daysUntil,
severity: computed.severity,
});
}
}
return alerts.sort((a, b) => a.daysUntil - b.daysUntil);
}
private computeSeverity(
expiryDate: string,
now: Date,
): { daysUntil: number; severity: AlertSeverity } | null {
const daysUntil = Math.ceil((new Date(expiryDate).getTime() - now.getTime()) / MS_PER_DAY);
if (daysUntil < 0) return { daysUntil, severity: 'OVERDUE' };
if (daysUntil <= DUE_SOON_DAYS) return { daysUntil, severity: 'DUE_SOON' };
return null;
}
private deriveStatus(expiryDate: string): ComplianceStatus {
const daysUntil = Math.ceil(
(new Date(expiryDate).getTime() - Date.now()) / MS_PER_DAY,
);
if (daysUntil < 0) return ComplianceStatus.EXPIRED;
if (daysUntil <= DUE_SOON_DAYS) return ComplianceStatus.EXPIRING;
return ComplianceStatus.VALID;
}
}

View File

@@ -0,0 +1,55 @@
import { IsUUID, IsString, IsDateString, IsOptional, IsEnum } from 'class-validator';
import { ComplianceType, ComplianceStatus } from '../entities/compliance-record.entity';
export class CreateComplianceRecordDto {
@IsUUID()
vehicleId!: string;
@IsEnum(ComplianceType)
type!: ComplianceType;
@IsOptional()
@IsString()
documentNumber?: string;
@IsOptional()
@IsDateString()
issuedDate?: string;
@IsDateString()
expiryDate!: string;
@IsOptional()
@IsEnum(ComplianceStatus)
status?: ComplianceStatus;
@IsOptional()
@IsString()
notes?: string;
}
export class UpdateComplianceRecordDto {
@IsOptional()
@IsEnum(ComplianceType)
type?: ComplianceType;
@IsOptional()
@IsString()
documentNumber?: string;
@IsOptional()
@IsDateString()
issuedDate?: string;
@IsOptional()
@IsDateString()
expiryDate?: string;
@IsOptional()
@IsEnum(ComplianceStatus)
status?: ComplianceStatus;
@IsOptional()
@IsString()
notes?: string;
}

View File

@@ -0,0 +1,46 @@
import { BaseEntity } from '@edr/api-common';
import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
export enum ComplianceType {
INSPECTION = 'INSPECTION',
INSURANCE = 'INSURANCE',
ROADWORTHINESS = 'ROADWORTHINESS',
PERMIT = 'PERMIT',
TAX = 'TAX',
}
export enum ComplianceStatus {
VALID = 'VALID',
EXPIRING = 'EXPIRING',
EXPIRED = 'EXPIRED',
}
@Entity({ name: 'compliance_records', schema: 'freight' })
@Index(['vehicleId', 'expiryDate'])
export class ComplianceRecord extends BaseEntity {
@Column({ name: 'vehicle_id', type: 'uuid' })
vehicleId!: string;
@ManyToOne(() => Vehicle, { eager: false, nullable: false })
@JoinColumn({ name: 'vehicle_id' })
vehicle!: Vehicle;
@Column({ name: 'type', type: 'varchar' })
type!: ComplianceType;
@Column({ name: 'document_number', type: 'varchar', nullable: true })
documentNumber?: string;
@Column({ name: 'issued_date', type: 'date', nullable: true })
issuedDate?: string;
@Column({ name: 'expiry_date', type: 'date' })
expiryDate!: string;
@Column({ name: 'status', type: 'varchar', default: ComplianceStatus.VALID })
status!: ComplianceStatus;
@Column({ name: 'notes', type: 'text', nullable: true })
notes?: string;
}

View File

@@ -176,7 +176,28 @@ export class BookingClearanceService {
}
const allApproved = await this.isClearanceFullyApproved(booking);
const milestones = await this.workflowService.listMilestonesForBooking(bookingId);
let milestones = await this.workflowService.listMilestonesForBooking(bookingId);
// Self-heal: a booking that has settled its freight payment must have
// FREIGHT_PAYMENT_SETTLED completed. The batch settle path writes it, but an
// export FCFS booking (linked to its train at booking time) paid via the
// prepaid invoice can leave the milestone PENDING — the clearance "Payment &
// wagon allocation" step then never ticks. Backfill it here so already-stuck
// rows recover without a migration; idempotent (no-op once COMPLETED).
const paymentSettled = milestones.find(
(m) => m.milestoneCode === 'FREIGHT_PAYMENT_SETTLED',
);
if (
paymentSettled &&
paymentSettled.status === 'PENDING' &&
(booking.paymentStatus === 'PAID' || booking.status === 'PAID')
) {
await this.workflowService.completeMilestoneForBooking(
bookingId,
'FREIGHT_PAYMENT_SETTLED',
);
milestones = await this.workflowService.listMilestonesForBooking(bookingId);
}
const phase = this.workflowService.resolvePhaseForBooking(booking, milestones);
const nextAction = this.workflowService.computeNextActionForBooking(booking, milestones);
const boundary = await this.workflowService.isBoundaryCompleteForBooking(

View File

@@ -6,6 +6,7 @@ import {
CustomsRiskLevel,
MilestoneMetadata,
} from './entities/clearance-milestone.entity';
import { Booking } from '../bookings/entities/booking.entity';
import { Contract } from './entities/contract.entity';
import {
HANDOFF_MILESTONES,
@@ -85,10 +86,36 @@ export class ClearanceMilestoneService {
}
async listForBooking(bookingId: string): Promise<ClearanceMilestone[]> {
return this.repo.find({
const rows = await this.repo.find({
where: { bookingId },
order: { sortOrder: 'ASC' },
});
// Self-heal: a booking that has settled its freight payment must have
// FREIGHT_PAYMENT_SETTLED completed. The batch settle path writes it, but an
// export FCFS booking (linked to its train at booking time) paid via the
// prepaid invoice can leave the milestone PENDING — the clearance "Payment &
// wagon allocation" step then never ticks. getClearanceView backfills it, but
// the stepper reads its gating milestones straight from here, so heal here too.
// Idempotent (no-op once COMPLETED); recovers already-stuck rows with no migration.
const paymentSettled = rows.find(
(m) => m.milestoneCode === 'FREIGHT_PAYMENT_SETTLED',
);
if (paymentSettled && paymentSettled.status === 'PENDING') {
const booking = await this.dataSource.getRepository(Booking).findOne({
where: { id: bookingId },
select: { id: true, status: true, paymentStatus: true },
});
if (booking?.paymentStatus === 'PAID' || booking?.status === 'PAID') {
await this.completeForBooking(bookingId, 'FREIGHT_PAYMENT_SETTLED');
return this.repo.find({
where: { bookingId },
order: { sortOrder: 'ASC' },
});
}
}
return rows;
}
/**

View File

@@ -0,0 +1,181 @@
import { ContractBookingService } from './contract-booking.service';
import { Booking } from '../bookings/entities/booking.entity';
/**
* The GL contract-drawdown path must run wagon consolidation before invoicing.
* A partial-wagon drawdown (e.g. 21× 20FT → one leftover container) parks in
* PENDING_CONSOLIDATION and is NOT finalized (no invoice / milestones) until it
* pairs with a wagon partner. These tests exercise the two new hooks directly.
*/
describe('ContractBookingService — drawdown consolidation gate', () => {
function makeService(overrides: {
consolidationService?: Partial<Record<string, jest.Mock>>;
bookingsRepository?: Partial<Record<string, jest.Mock>>;
invoiceService?: Partial<Record<string, jest.Mock>>;
milestoneService?: Partial<Record<string, jest.Mock>>;
contractsRepository?: Partial<Record<string, jest.Mock>>;
}) {
const consolidationService = {
slotsFromBooking: jest.fn().mockResolvedValue([]),
describePaired: jest.fn().mockReturnValue('paired'),
describePending: jest.fn().mockReturnValue('pending'),
needsConsolidationFromBooking: jest.fn().mockResolvedValue(false),
...overrides.consolidationService,
};
const bookingsRepository = {
findConsolidationPartner: jest.fn().mockResolvedValue(null),
pairConsolidation: jest.fn().mockResolvedValue(undefined),
parkForConsolidation: jest.fn().mockResolvedValue(undefined),
findByIdWithFiles: jest.fn(),
...overrides.bookingsRepository,
};
const invoiceService = {
ensureInvoiceForBooking: jest.fn().mockResolvedValue({ id: 'inv-1' }),
...overrides.invoiceService,
};
const milestoneService = {
seedPostBookingMilestones: jest.fn().mockResolvedValue(undefined),
seedPreBookingMilestonesOnBooking: jest.fn().mockResolvedValue(undefined),
...overrides.milestoneService,
};
const contractsRepository = {
findByIdWithRelations: jest.fn(),
currentCycle: jest.fn().mockResolvedValue(null),
linkBooking: jest.fn().mockResolvedValue(undefined),
update: jest.fn().mockResolvedValue(undefined),
...overrides.contractsRepository,
};
const service = new ContractBookingService(
contractsRepository as never,
bookingsRepository as never,
{} as never, // bookingPricingService
consolidationService as never,
{} as never, // containerTypesService
{} as never, // ruleEngineService
milestoneService as never,
{} as never, // workflowService
invoiceService as never,
{} as never, // dataSource
{} as never, // trainSchedulingService
);
return {
service,
consolidationService,
bookingsRepository,
invoiceService,
milestoneService,
contractsRepository,
};
}
const booking = { id: 'b-1', reference: 'BK-1' } as Booking;
it('parks (not pairs) when no complementary partner exists', async () => {
const { service, bookingsRepository } = makeService({
consolidationService: {
slotsFromBooking: jest
.fn()
.mockResolvedValue([{ containerTypeId: 'ct', slotsNeeded: 1 }]),
},
bookingsRepository: {
findConsolidationPartner: jest.fn().mockResolvedValue(null),
},
});
const result = await (service as never as {
consolidateDrawdown: (b: Booking, s: string) => Promise<{ paired: boolean }>;
}).consolidateDrawdown(booking, 'OPERATION_REQUEST_PENDING');
expect(result.paired).toBe(false);
expect(bookingsRepository.parkForConsolidation).toHaveBeenCalledWith(
'b-1',
'OPERATION_REQUEST_PENDING',
);
expect(bookingsRepository.pairConsolidation).not.toHaveBeenCalled();
});
it('pairs when a complementary partner exists', async () => {
const { service, bookingsRepository } = makeService({
consolidationService: {
slotsFromBooking: jest
.fn()
.mockResolvedValue([{ containerTypeId: 'ct', slotsNeeded: 1 }]),
},
bookingsRepository: {
findConsolidationPartner: jest
.fn()
.mockResolvedValue({ id: 'p-1', reference: 'BK-2' }),
},
});
const result = await (service as never as {
consolidateDrawdown: (b: Booking, s: string) => Promise<{ paired: boolean }>;
}).consolidateDrawdown(booking, 'AWAITING_DOCUMENTS');
expect(result.paired).toBe(true);
expect(bookingsRepository.pairConsolidation).toHaveBeenCalledWith('b-1', 'p-1');
expect(bookingsRepository.parkForConsolidation).not.toHaveBeenCalled();
});
it('onConsolidationPaired finalizes a resumed contract booking (invoice + milestones)', async () => {
const paired = {
id: 'b-1',
reference: 'BK-1',
contractId: 'c-1',
status: 'OPERATION_REQUEST_PENDING',
} as Booking;
const contract = {
id: 'c-1',
contractKind: 'GENERAL',
customsClearingEnabled: true,
tradeDirection: 'EXPORT',
};
const { service, invoiceService, milestoneService } = makeService({
bookingsRepository: {
findByIdWithFiles: jest.fn().mockResolvedValue(paired),
},
contractsRepository: {
findByIdWithRelations: jest.fn().mockResolvedValue(contract),
},
});
await service.onConsolidationPaired({ bookingIds: ['b-1'] });
expect(invoiceService.ensureInvoiceForBooking).toHaveBeenCalledTimes(1);
// GENERAL customs → per-booking pre + post milestones.
expect(milestoneService.seedPreBookingMilestonesOnBooking).toHaveBeenCalled();
expect(milestoneService.seedPostBookingMilestones).toHaveBeenCalled();
});
it('onConsolidationPaired ignores a booking still PENDING_CONSOLIDATION', async () => {
const stillPending = {
id: 'b-1',
contractId: 'c-1',
status: 'PENDING_CONSOLIDATION',
} as Booking;
const { service, invoiceService } = makeService({
bookingsRepository: {
findByIdWithFiles: jest.fn().mockResolvedValue(stillPending),
},
});
await service.onConsolidationPaired({ bookingIds: ['b-1'] });
expect(invoiceService.ensureInvoiceForBooking).not.toHaveBeenCalled();
});
it('onConsolidationPaired ignores a non-contract (direct) booking', async () => {
const direct = { id: 'd-1', status: 'SUBMITTED', contractId: null } as Booking;
const { service, invoiceService, contractsRepository } = makeService({
bookingsRepository: {
findByIdWithFiles: jest.fn().mockResolvedValue(direct),
},
});
await service.onConsolidationPaired({ bookingIds: ['d-1'] });
expect(contractsRepository.findByIdWithRelations).not.toHaveBeenCalled();
expect(invoiceService.ensureInvoiceForBooking).not.toHaveBeenCalled();
});
});

View File

@@ -8,6 +8,7 @@ import {
forwardRef,
} from '@nestjs/common';
import { DataSource } from 'typeorm';
import { OnEvent } from '@nestjs/event-emitter';
import { insertWithGeneratedReference } from '@edr/api-common';
import { Booking } from '../bookings/entities/booking.entity';
@@ -15,6 +16,7 @@ import { BookingContainer } from '../bookings/entities/booking-container.entity'
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingPricingService } from '../bookings/booking-pricing.service';
import { ConsolidationService } from '../bookings/consolidation.service';
import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
@@ -61,6 +63,7 @@ export class ContractBookingService {
private readonly contractsRepository: ContractsRepository,
private readonly bookingsRepository: BookingsRepository,
private readonly bookingPricingService: BookingPricingService,
private readonly consolidationService: ConsolidationService,
private readonly containerTypesService: ContainerTypesService,
private readonly ruleEngineService: RuleEngineService,
private readonly milestoneService: ClearanceMilestoneService,
@@ -214,6 +217,20 @@ export class ContractBookingService {
await this.applyWeightResults(loaded);
}
const computed = await this.bookingPricingService.computePriceForBooking(loaded);
// 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. Roll back the just-inserted row + its lines so it
// does NOT occupy the one-time contract's single active-booking slot — else
// the customer's retry hits "already has an active booking" against a broken
// draft. The customer must fix the contract's rates, then rebook.
if (!(computed.totalAmount > 0)) {
await this.bookingsRepository.deleteContainers(booking.id);
await this.bookingsRepository.hardDelete(booking.id);
throw new BadRequestException(
'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, {
totalAmount: computed.totalAmount,
priorityScore: computed.priorityScore,
@@ -232,6 +249,105 @@ export class ContractBookingService {
warnings.push(...computed.warnings);
}
// Wagon consolidation gate. A container drawdown whose lines leave a partial
// wagon (e.g. 21× 20FT → one leftover) must share that wagon with a partner
// before it can ship. Direct bookings do this at submit; drawdowns have no
// submit step, so we run it here — BEFORE invoicing/milestones. When it parks
// for a partner the booking is NOT invoiced or scheduled: those steps run
// later in finalizeContractBooking, triggered by the pairing event. When it
// pairs (or needs no consolidation) we finalize inline.
const withContainers = await this.bookingsRepository.findByIdWithFiles(
booking.id,
);
const intendedStatus = generalCustoms
? 'AWAITING_DOCUMENTS'
: 'OPERATION_REQUEST_PENDING';
if (
withContainers &&
freightType === 'CONTAINER' &&
(await this.consolidationService.needsConsolidationFromBooking(
withContainers,
))
) {
const parked = await this.consolidateDrawdown(
withContainers,
intendedStatus,
);
warnings.push(parked.message);
if (!parked.paired) {
// Waiting for a partner — stop here. The booking sits in
// PENDING_CONSOLIDATION, unbilled and unscheduled, until it pairs.
const pendingResult = await this.bookingsRepository.findByIdWithFiles(
booking.id,
);
return { booking: pendingResult ?? booking, warnings };
}
}
await this.finalizeContractBooking(
booking.id,
contract,
generalCustoms,
);
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
return { booking: result ?? booking, warnings };
}
/**
* Search for a complementary partner for a parked-eligible drawdown, pair it or
* park it in PENDING_CONSOLIDATION with the resume status it should return to.
* Pairing (via BookingsRepository.pairConsolidation) resumes both partners and
* emits booking.consolidation.paired, which finalizes any deferred contract
* booking. Returns whether a partner was found plus a customer-facing message.
*/
private async consolidateDrawdown(
booking: Booking,
resumeStatus: string,
): Promise<{ paired: boolean; message: string }> {
const slots = await this.consolidationService.slotsFromBooking(booking);
if (!slots.length) {
return { paired: false, message: '' };
}
const partner = await this.bookingsRepository.findConsolidationPartner(
booking,
slots,
);
if (partner) {
await this.bookingsRepository.pairConsolidation(booking.id, partner.id);
return {
paired: true,
message: this.consolidationService.describePaired(
partner.reference,
slots,
),
};
}
await this.bookingsRepository.parkForConsolidation(booking.id, resumeStatus);
return {
paired: false,
message: this.consolidationService.describePending(booking, slots),
};
}
/**
* Finalize a contract booking once it is cleared to proceed (needed no
* consolidation, or has just paired): seed clearance milestones / link the
* contract cycle, then generate the invoice. Idempotent — safe to call again
* for a booking that pairs after having waited. Skips a booking that is still
* PENDING_CONSOLIDATION (guards the pairing event against a stray partner).
*/
private async finalizeContractBooking(
bookingId: string,
contract: Contract,
generalCustoms: boolean,
): Promise<void> {
const booking = await this.bookingsRepository.findByIdWithFiles(bookingId);
if (!booking || booking.status === 'PENDING_CONSOLIDATION') return;
// ONE_TIME customs (legacy contract-cycle path): link the contract clearance
// cycle to this booking, seed post-booking milestones, and lock the contract
// to ACTIVE_SHIPMENT_IN_PROGRESS. NOT for GENERAL — it has no contract cycle
@@ -239,10 +355,10 @@ export class ContractBookingService {
if (contract.customsClearingEnabled && !generalCustoms) {
const cycle = await this.contractsRepository.currentCycle(contract.id);
if (cycle) {
await this.contractsRepository.linkBooking(cycle.id, booking.id);
await this.contractsRepository.linkBooking(cycle.id, bookingId);
}
await this.milestoneService.seedPostBookingMilestones(
booking.id,
bookingId,
contract.tradeDirection,
);
await this.contractsRepository.update(contract.id, {
@@ -252,24 +368,22 @@ export class ContractBookingService {
} else if (generalCustoms) {
// Per-booking clearance: seed full milestone timeline on the booking.
await this.milestoneService.seedPreBookingMilestonesOnBooking(
booking.id,
bookingId,
contract.tradeDirection,
);
await this.milestoneService.seedPostBookingMilestones(
booking.id,
bookingId,
contract.tradeDirection,
);
}
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
// Contract bookings are born past the billable gate (the contract is already
// executed), so the invoice is generated here — they never pass through the
// legacy marketingApprove → FULLY_EXECUTED path that invoices direct bookings.
// Idempotent and non-blocking: a billing hiccup must not undo the booking.
// Skips silently when unbillable (no company / no priced amount).
await this.invoiceService
.ensureInvoiceForBooking(result ?? booking)
.ensureInvoiceForBooking(booking)
.catch((err) =>
this.logger.error(
`Failed to generate invoice for contract booking ${booking.reference}: ${
@@ -277,8 +391,40 @@ export class ContractBookingService {
}`,
),
);
}
return { booking: result ?? booking, warnings };
/**
* A parked drawdown just paired — finalize whichever partner is a contract
* booking that was waiting (invoice + milestones deferred at creation). The
* pairing already resumed the booking's status from consolidationResumeStatus;
* this runs the create-time tail that was skipped. Non-contract partners have
* their own finalize path (staff accept) and are ignored here.
*/
@OnEvent('booking.consolidation.paired')
async onConsolidationPaired(payload: {
bookingIds: string[];
}): Promise<void> {
for (const id of payload.bookingIds ?? []) {
const booking = await this.bookingsRepository.findByIdWithFiles(id);
if (!booking?.contractId || booking.status === 'PENDING_CONSOLIDATION') {
continue;
}
const contract = await this.contractsRepository.findByIdWithRelations(
booking.contractId,
);
if (!contract) continue;
const generalCustoms =
contract.contractKind === 'GENERAL' &&
Boolean(contract.customsClearingEnabled);
await this.finalizeContractBooking(id, contract, generalCustoms).catch(
(err) =>
this.logger.error(
`Failed to finalize paired contract booking ${booking.reference}: ${
err instanceof Error ? err.message : String(err)
}`,
),
);
}
}
/**

View File

@@ -250,6 +250,44 @@ export class ContractTransitionService {
return updated;
}
/**
* Reject one approval step (line staff / director / CEO). The rejecting
* approver must supply a reason. A rejection is terminal: the whole contract
* moves to REJECTED and the customer must create a new one — there is no
* resubmit of the same contract. The reason is recorded both on the step and
* as a REJECTION review note so it is visible to the customer and the rest of
* the approval chain.
*/
async rejectStep(
contractId: string,
stepId: string,
actorId: string,
reason: string,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']);
const step = await this.contractsRepository.findApprovalStepById(contractId, stepId);
if (!step) throw new BadRequestException('Approval step not found');
await this.contractsRepository.completeApprovalStep(step.id, actorId, 'REJECTED', reason);
await this.contractsRepository.createReviewNote(
contractId,
reason,
'REJECTION',
actorId,
'STAFF',
);
await this.contractsRepository.update(contractId, {
status: 'REJECTED',
} as never);
const updated = await this.contractsService.findById(contractId);
this.notifier.rejected(updated, reason);
return updated;
}
/** Approve one approval step in sequence; → APPROVED when all complete. */
async approveStep(
contractId: string,

View File

@@ -60,6 +60,7 @@ import { AcceptContractDto } from './dto/accept-contract.dto';
import {
ApproveStepDto,
RejectContractDto,
RejectStepDto,
RequestChangesDto,
} from './dto/approve-step.dto';
import { SignContractDto } from './dto/sign-contract.dto';
@@ -390,6 +391,27 @@ export class ContractsController {
);
}
@Post(':id/approval-steps/:stepId/reject')
@BookingStaff([
FREIGHT_PERMS.contracts.approveLineStaff,
FREIGHT_PERMS.contracts.approveDirector,
FREIGHT_PERMS.contracts.approveCeo,
])
@ApiOperation({ summary: 'Reject one approval step (terminal → REJECTED)' })
rejectStep(
@Param('id', ParseUUIDPipe) id: string,
@Param('stepId', ParseUUIDPipe) stepId: string,
@Body() dto: RejectStepDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.transitionService.rejectStep(
id,
stepId,
resolveAuthUserId(user),
dto.reason,
);
}
@Post(':id/contract/generate')
@BookingStaff(FREIGHT_PERMS.contracts.generateContract)
@ApiOperation({ summary: 'Generate contract document → CONTRACT_READY' })

View File

@@ -567,6 +567,21 @@ export class ContractsService {
);
}
// Surface the staff "request changes" note so the portal can show the
// customer what to fix. Degrade to null on lookup failure — a missing note
// must never 500 a contract fetch.
if (contract.status === 'CHANGES_REQUESTED') {
try {
const note = await this.contractsRepository.findLatestReviewNote(
contract.id,
'CHANGES_REQUESTED',
);
contract.latestChangeRequestNote = note?.body ?? null;
} catch {
contract.latestChangeRequestNote = null;
}
}
return contract;
}

View File

@@ -260,4 +260,11 @@ export class Contract extends BaseEntity {
* ContractsRepository.attachClearancePhases for list responses. Not a column.
*/
clearancePhase?: string | null;
/**
* Body of the most recent CHANGES_REQUESTED review note, attached by
* ContractsService.findById so the portal can show the customer what staff
* asked them to fix. Lives in contract_review_notes, not a column here.
*/
latestChangeRequestNote?: string | null;
}

View File

@@ -202,15 +202,22 @@ export class GlOperationsService {
.findOne({ where: { id: booking.trainScheduleId } });
}
// Per-booking journey first: a booking rides only its own leg, so ITS
// loaded/arrived timestamps gate clearance — a Dire→Djibouti booking that
// unloaded at its own destination clears while the train keeps rolling,
// and a booking still on board does NOT clear just because the train
// arrived. The schedule actuals remain only as fallback for legacy
// in-flight bookings that predate per-booking load/unload (no loadedAt).
const departedAt = booking.loadedAt ?? schedule?.actualDepartureAt ?? null;
const arrivedAt =
booking.arrivedAt ??
(booking.loadedAt ? null : (schedule?.actualArrivalAt ?? null));
return {
scheduleId: schedule?.id ?? null,
wagonAllocated,
departedAt: schedule?.actualDepartureAt
? new Date(schedule.actualDepartureAt).toISOString()
: null,
arrivedAt: schedule?.actualArrivalAt
? new Date(schedule.actualArrivalAt).toISOString()
: null,
departedAt: departedAt ? new Date(departedAt).toISOString() : null,
arrivedAt: arrivedAt ? new Date(arrivedAt).toISOString() : null,
};
}
@@ -278,7 +285,7 @@ export class GlOperationsService {
/**
* GL Djibouti uploads T1 transport documents (multi-file) once the gate pass
* is secured on the train schedule (which itself follows wagon allocation).
* Replaces the previous batch; locked once the train departs or T1 is closed.
* Replaces the previous batch; locked only once GL Ethiopia closes the T1.
*/
async uploadT1Documents(
bookingId: string,
@@ -304,11 +311,8 @@ export class GlOperationsService {
if (state.closed) {
throw new BadRequestException('T1 has been closed by GL Ethiopia — documents are final.');
}
if (state.trainDepartedAt) {
throw new BadRequestException(
'The train has departed — T1 transport documents can no longer be changed.',
);
}
// Departure no longer locks T1 docs — GL DJ may replace them any time until
// GL Ethiopia closes/accepts the T1.
await persistT1TransportUploads(this.filesService, bookingId, files);
return { uploaded: files.length };
@@ -395,14 +399,20 @@ export class GlOperationsService {
}
if (!file) throw new BadRequestException('Attach the invoice document.');
// Invoiceable once cargo is offloaded, or — for export, where OFFLOADED is a
// DJ doc milestone that may never be recorded — once the Djibouti gate pass
// is secured. The invoice itself stays optional; nothing forces GL DJ to send one.
const milestones = await this.milestoneService.listForBooking(bookingId);
const offloaded = milestones.find(
(m) => m.milestoneCode === 'OFFLOADED' && m.status === 'COMPLETED',
);
if (!offloaded) {
throw new BadRequestException(
'Cargo must be offloaded before the final invoice can be raised.',
);
const gatepass = await this.gatepassForBooking(bookingId);
if (!gatepass.granted) {
throw new BadRequestException(
'Cargo must be offloaded (or the gate pass secured) before the final invoice can be raised.',
);
}
}
const existing = await this.billingService.findInvoice(

View File

@@ -276,6 +276,7 @@ export const PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES = [
'OPERATION_CHANGES_REQUESTED',
'ROAD_DISPATCH_PENDING',
'IN_TRANSIT',
'ARRIVED',
'PAID',
'COMPLETED',
'CONTRACT_ACTIVE',

View File

@@ -8,9 +8,13 @@ import {
Body,
Query,
ParseUUIDPipe,
UploadedFiles,
UseInterceptors,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { FleetManage, FleetView } from '../../common/booking-guards';
import { AnyFilesInterceptor } from '@nestjs/platform-express';
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { DriversService } from './drivers.service';
import { CreateDriverDto } from './dto/create-driver.dto';
import { UpdateDriverDto } from './dto/update-driver.dto';
@@ -19,7 +23,7 @@ import { FleetHistoryService } from '../fleet-history/fleet-history.service';
@ApiTags('drivers')
@ApiBearerAuth()
@Controller('drivers')
@FleetView()
@BookingStaff(FREIGHT_PERMS.drivers.view)
export class DriversController {
constructor(
private readonly driversService: DriversService,
@@ -27,7 +31,7 @@ export class DriversController {
) {}
@Post()
@FleetManage()
@BookingStaff(FREIGHT_PERMS.drivers.create)
@ApiOperation({ summary: 'Create a new driver' })
create(@Body() createDriverDto: CreateDriverDto) {
return this.driversService.create(createDriverDto);
@@ -65,8 +69,33 @@ export class DriversController {
return this.fleetHistory.getDriverHistory(id);
}
@Post(':id/documents')
@BookingStaff(FREIGHT_PERMS.drivers.update)
@ApiConsumes('multipart/form-data')
@UseInterceptors(AnyFilesInterceptor())
@ApiOperation({ summary: 'Upload driver documents (code driver_docs)' })
uploadDocuments(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
) {
return this.driversService.uploadDocuments(id, files ?? []);
}
@Get(':id/documents')
@ApiOperation({ summary: "List a driver's documents" })
listDocuments(@Param('id', ParseUUIDPipe) id: string) {
return this.driversService.listDocuments(id);
}
@Delete(':id/documents/:fileId')
@BookingStaff(FREIGHT_PERMS.drivers.update)
@ApiOperation({ summary: 'Delete a driver document' })
removeDocument(@Param('fileId', ParseUUIDPipe) fileId: string) {
return this.driversService.removeDocument(fileId);
}
@Patch(':id')
@FleetManage()
@BookingStaff(FREIGHT_PERMS.drivers.update)
@ApiOperation({ summary: 'Update a driver' })
update(
@Param('id', ParseUUIDPipe) id: string,
@@ -76,7 +105,7 @@ export class DriversController {
}
@Delete(':id')
@FleetManage()
@BookingStaff(FREIGHT_PERMS.drivers.delete)
@ApiOperation({ summary: 'Delete a driver' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.driversService.remove(id);

View File

@@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { Driver } from './entities/driver.entity';
import { DriversService } from './drivers.service';
import { DriversController } from './drivers.controller';
import { FilesModule } from '../files/files.module';
@Module({
imports: [TypeOrmModule.forFeature([Driver])],
imports: [TypeOrmModule.forFeature([Driver]), FilesModule],
providers: [DriversService],
controllers: [DriversController],
exports: [DriversService],

View File

@@ -6,6 +6,11 @@ import { UpdateDriverDto } from './dto/update-driver.dto';
import { Driver, DriverStatus } from './entities/driver.entity';
import { FleetHistoryService } from '../fleet-history/fleet-history.service';
import { FleetEventType } from '../fleet-history/entities/fleet-event.entity';
import { FilesService } from '../files/files.service';
/** Resource + code the driver-documents upload area is stored under. */
const DRIVER_DOCS_RESOURCE = 'driver';
const DRIVER_DOCS_CODE = 'driver_docs';
@Injectable()
export class DriversService {
@@ -13,8 +18,37 @@ export class DriversService {
@InjectRepository(Driver)
private readonly driverRepo: Repository<Driver>,
private readonly history: FleetHistoryService,
private readonly filesService: FilesService,
) {}
/** Upload one or more driver documents (code "driver_docs"). */
async uploadDocuments(driverId: string, files: Express.Multer.File[]) {
const driver = await this.driverRepo.findOneBy({ id: driverId });
if (!driver) throw new NotFoundException(`Driver ${driverId} not found`);
if (!files?.length) throw new BadRequestException('No files provided');
return Promise.all(
files.map((file) =>
this.filesService.upload({
resourceId: driverId,
resource: DRIVER_DOCS_RESOURCE,
code: DRIVER_DOCS_CODE,
file,
}),
),
);
}
/** List a driver's uploaded documents (code "driver_docs"). */
async listDocuments(driverId: string) {
const all = await this.filesService.findByResource(driverId, DRIVER_DOCS_RESOURCE);
return all.filter((f) => f.code === DRIVER_DOCS_CODE);
}
/** Delete a single driver document by file id. */
async removeDocument(fileId: string): Promise<void> {
await this.filesService.remove(fileId);
}
async create(dto: CreateDriverDto): Promise<Driver> {
if (dto.faydaVerified !== true) {
throw new BadRequestException(

View File

@@ -119,6 +119,11 @@ export class FilesService {
return record;
}
/** Soft-delete a stored file row by id (object bytes are left in MinIO). */
async remove(id: string): Promise<void> {
await this.filesRepository.softDelete(id);
}
findByResource(resourceId: string, resource: string): Promise<FileRecord[]> {
return this.filesRepository.findByResource(resourceId, resource);
}

View File

@@ -1,4 +1,4 @@
import { Injectable, Logger } from '@nestjs/common';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { Freight } from '@edr/types';
@@ -66,13 +66,37 @@ export class FirstMileInvoiceService {
return null;
}
// Reject mixed-currency truck sets — a single invoice can only be one
// currency, and amounts across currencies can't be summed.
const billableTrucks = (record.vehicleAssignments ?? []).filter(
(a) => Number(a.distanceKm) > 0,
);
const currencies = [
...new Set(
billableTrucks
.map((a) => (a.vehicle as { currency?: string } | undefined)?.currency)
.filter((c): c is string => Boolean(c)),
),
];
if (currencies.length > 1) {
throw new BadRequestException(
`Cannot generate invoice: assigned trucks use mixed currencies (${currencies.join(', ')}). Assign trucks that share one currency.`,
);
}
// Currency follows the truck (price/km is quoted per vehicle), falling back
// to the booking's currency, then ETB.
const truckCurrency =
(record.vehicle as { currency?: string } | undefined)?.currency ||
(record.vehicleAssignments?.[0]?.vehicle as { currency?: string } | undefined)?.currency;
return this.billing.generateInvoice({
source: 'first_mile' as Freight.InvoiceSource,
sourceId: record.id,
type: 'DELIVERY_FEE',
companyId: fm.booking!.companyId,
companyProfileId: fm.booking!.companyProfileId || '',
currency: fm.booking!.paymentCurrency || 'ETB',
currency: truckCurrency || fm.booking!.paymentCurrency || 'ETB',
lines: [
{
chargeType: 'DELIVERY',

View File

@@ -14,7 +14,8 @@ import {
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking-guards';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { CreateFirstMileDto } from './dto/create-first-mile.dto';
import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
@@ -27,7 +28,7 @@ import { FirstMileInvoiceService } from './first-mile-invoice.service';
@ApiTags('first-mile')
@ApiBearerAuth()
@Controller('first-mile')
@TrainSchedulingView()
@BookingStaff(FREIGHT_PERMS.firstMile.view)
export class FirstMileController {
constructor(
private readonly firstMileService: FirstMileService,
@@ -63,27 +64,28 @@ export class FirstMileController {
}
@Get('acceptitem/:id')
@BookingStaff(FREIGHT_PERMS.firstMile.accept)
@ApiOperation({ summary: 'Get a first-mile accep by ID' })
acceptItem(@Param('id', ParseUUIDPipe) id: string) {
return this.firstMileService.acceptBooking(id);
}
@Post('accept/:reference')
@TrainSchedulingManage()
@BookingStaff(FREIGHT_PERMS.firstMile.accept)
@ApiOperation({ summary: 'Accept a paid booking and create a first-mile leg' })
acceptBooking(@Param('reference') reference: string) {
return this.firstMileService.acceptBookingByReference(reference);
}
@Post()
@TrainSchedulingManage()
@BookingStaff(FREIGHT_PERMS.firstMile.create)
@ApiOperation({ summary: 'Create a first-mile leg' })
create(@Body() dto: CreateFirstMileDto) {
return this.firstMileService.create(dto);
}
@Patch(':id')
@TrainSchedulingManage()
@BookingStaff(FREIGHT_PERMS.firstMile.update)
@ApiOperation({ summary: 'Update a first-mile leg' })
async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFirstMileDto) {
// No invoice side-effects — invoices are generated only via the explicit
@@ -92,7 +94,7 @@ export class FirstMileController {
}
@Post(':id/invoice')
@TrainSchedulingManage()
@BookingStaff(FREIGHT_PERMS.firstMile.generateInvoice)
@ApiOperation({ summary: 'Generate the first-mile delivery-fee invoice' })
async generateInvoice(@Param('id', ParseUUIDPipe) id: string) {
const record = await this.firstMileService.findById(id);
@@ -106,7 +108,7 @@ export class FirstMileController {
}
@Post(':id/vehicles')
@TrainSchedulingManage()
@BookingStaff(FREIGHT_PERMS.firstMile.assignVehicles)
@ApiOperation({ summary: 'Set the vehicles assigned to a first-mile pickup (multi-truck)' })
async setVehicles(
@Param('id', ParseUUIDPipe) id: string,
@@ -116,7 +118,7 @@ export class FirstMileController {
}
@Post(':id/distances')
@TrainSchedulingManage()
@BookingStaff(FREIGHT_PERMS.firstMile.setDistances)
@ApiOperation({ summary: 'Set per-vehicle actual distances (does not generate an invoice)' })
async setDistances(
@Param('id', ParseUUIDPipe) id: string,
@@ -126,7 +128,7 @@ export class FirstMileController {
}
@Delete(':id')
@TrainSchedulingManage()
@BookingStaff(FREIGHT_PERMS.firstMile.delete)
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a first-mile leg' })
remove(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -640,10 +640,24 @@ export class FirstMileService {
{ distanceKm: d.distanceKm },
);
}
const total = distances.reduce((s, d) => s + (Number(d.distanceKm) || 0), 0);
// Billing is per truck: amount = Σ (truck distance × truck price/km). The
// per-vehicle rate + currency live on the vehicle, so we ignore the legacy
// FIRST_MILE flat rate and any client-sent amount. `remainingPayment` param
// kept only for signature back-compat.
void remainingPayment;
const assignments = await this.dataSource.manager.find(FirstMileVehicleAssignment, {
where: { firstMileId: id },
relations: { vehicle: true },
});
const total = assignments.reduce((s, a) => s + (Number(a.distanceKm) || 0), 0);
const amount = assignments.reduce(
(s, a) => s + (Number(a.distanceKm) || 0) * (Number(a.vehicle?.pricePerKm) || 0),
0,
);
await this.firstMileRepository.update(id, {
exactKm: total,
...(remainingPayment != null ? { remainingPayment } : {}),
remainingPayment: amount,
} as any);
return this.findById(id);
}

View File

@@ -1,26 +1,40 @@
import { Controller, Post, Get, Body, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { ApiBearerAuth, ApiTags, ApiOperation } from '@nestjs/swagger';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { FuelService } from './fuel.service';
import { CreateFuelPurchaseDto } from './dto/create-fuel-purchase.dto';
// Stats feed the Financial Reports + Fleet Dashboard pages, so their viewers may
// read them without full fuel access.
const FUEL_STATS_PERMS = [
FREIGHT_PERMS.fuel.view,
FREIGHT_PERMS.fleetReports.view,
FREIGHT_PERMS.fleetDashboard.view,
];
@ApiTags('Fuel Management')
@ApiBearerAuth()
@Controller('fuel')
export class FuelController {
constructor(private readonly fuelService: FuelService) {}
@Post('purchases')
@BookingStaff(FREIGHT_PERMS.fuel.create)
@ApiOperation({ summary: 'Record fuel purchase' })
async recordFuelPurchase(@Body() dto: CreateFuelPurchaseDto) {
return this.fuelService.recordFuelPurchase(dto);
}
@Get('purchases')
@BookingStaff(FREIGHT_PERMS.fuel.view)
@ApiOperation({ summary: 'Get all fuel purchases' })
async getAllFuelPurchases() {
return this.fuelService.getAllFuelPurchases();
}
@Get('purchases/:vehicleId')
@BookingStaff(FREIGHT_PERMS.fuel.view)
@ApiOperation({ summary: 'Get fuel purchases for vehicle' })
async getFuelPurchases(
@Param('vehicleId') vehicleId: string,
@@ -35,6 +49,7 @@ export class FuelController {
}
@Get('consumption/:vehicleId/:month')
@BookingStaff(FREIGHT_PERMS.fuel.view)
@ApiOperation({ summary: 'Get monthly fuel consumption' })
async getMonthlyConsumption(
@Param('vehicleId') vehicleId: string,
@@ -44,12 +59,14 @@ export class FuelController {
}
@Get('stats')
@BookingStaff(FUEL_STATS_PERMS)
@ApiOperation({ summary: 'Get fleet-wide fuel statistics' })
async getFleetFuelStats(@Query('months') months: number = 12) {
return this.fuelService.getFleetFuelStats(months);
}
@Get('stats/:vehicleId')
@BookingStaff(FUEL_STATS_PERMS)
@ApiOperation({ summary: 'Get fuel statistics for vehicle' })
async getVehicleFuelStats(
@Param('vehicleId') vehicleId: string,

View File

@@ -0,0 +1,24 @@
import { IsOptional, IsString, IsUUID } from 'class-validator';
export class RegisterDeviceDto {
@IsString()
imei!: string;
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsUUID()
vehicleId?: string;
}
export class UpdateDeviceDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsUUID()
vehicleId?: string | null;
}

View File

@@ -0,0 +1,55 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
/**
* A physical GPS tracker (GT06). Identified by IMEI, optionally bound to a
* vehicle. Carries the denormalized latest fix so the live map reads one row
* per device without scanning position history.
*/
@Entity({ name: 'gps_devices', schema: 'freight' })
@Index(['vehicleId'])
export class GpsDevice extends BaseEntity {
@Column({ name: 'imei', type: 'varchar', length: 20, unique: true })
imei!: string;
@Column({ name: 'name', type: 'varchar', nullable: true })
name?: string | null;
@Column({ name: 'vehicle_id', type: 'uuid', nullable: true })
vehicleId?: string | null;
@ManyToOne(() => Vehicle, { nullable: true, eager: false })
@JoinColumn({ name: 'vehicle_id' })
vehicle?: Vehicle | null;
/** ONLINE once a packet arrives; OFFLINE when stale (derived on read). */
@Column({ name: 'status', type: 'varchar', length: 16, default: 'REGISTERED' })
status!: string;
@Column({ name: 'last_seen_at', type: 'timestamptz', nullable: true })
lastSeenAt?: Date | null;
// ── Denormalized latest fix ──
@Column({ name: 'last_lat', type: 'numeric', precision: 10, scale: 6, nullable: true })
lastLat?: number | null;
@Column({ name: 'last_lng', type: 'numeric', precision: 10, scale: 6, nullable: true })
lastLng?: number | null;
@Column({ name: 'last_speed', type: 'numeric', precision: 6, scale: 2, nullable: true })
lastSpeed?: number | null;
@Column({ name: 'last_course', type: 'int', nullable: true })
lastCourse?: number | null;
@Column({ name: 'last_fix_at', type: 'timestamptz', nullable: true })
lastFixAt?: Date | null;
@Column({ name: 'voltage_level', type: 'int', nullable: true })
voltageLevel?: number | null;
@Column({ name: 'gsm_level', type: 'int', nullable: true })
gsmLevel?: number | null;
}

View File

@@ -0,0 +1,43 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
/** One GPS fix from a tracker (append-only history). */
@Entity({ name: 'gps_positions', schema: 'freight' })
@Index(['deviceId', 'gpsTime'])
@Index(['vehicleId', 'gpsTime'])
export class GpsPosition extends BaseEntity {
@Column({ name: 'device_id', type: 'uuid' })
deviceId!: string;
@Column({ name: 'imei', type: 'varchar', length: 20 })
imei!: string;
@Column({ name: 'vehicle_id', type: 'uuid', nullable: true })
vehicleId?: string | null;
@Column({ name: 'lat', type: 'numeric', precision: 10, scale: 6 })
lat!: number;
@Column({ name: 'lng', type: 'numeric', precision: 10, scale: 6 })
lng!: number;
@Column({ name: 'speed', type: 'numeric', precision: 6, scale: 2, default: 0 })
speed!: number;
@Column({ name: 'course', type: 'int', default: 0 })
course!: number;
@Column({ name: 'satellites', type: 'int', default: 0 })
satellites!: number;
@Column({ name: 'positioned', type: 'boolean', default: false })
positioned!: boolean;
/** Fix time reported by the device (UTC). */
@Column({ name: 'gps_time', type: 'timestamptz' })
gpsTime!: Date;
/** Non-zero when the fix came in via an alarm packet. */
@Column({ name: 'alarm', type: 'int', default: 0 })
alarm!: number;
}

View File

@@ -0,0 +1,66 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { FleetManage, FleetView } from '../../common/booking-guards';
import { GpsTrackingService } from './gps-tracking.service';
import { RegisterDeviceDto, UpdateDeviceDto } from './dto/gps-device.dto';
@ApiTags('gps-tracking')
@ApiBearerAuth()
@Controller('gps')
@FleetView()
export class GpsTrackingController {
constructor(private readonly gps: GpsTrackingService) {}
@Get('positions/latest')
@ApiOperation({ summary: 'Latest fix per device (live map feed)' })
latest() {
return this.gps.latest();
}
@Get('positions/:vehicleId/history')
@ApiOperation({ summary: 'Position history for a vehicle' })
history(
@Param('vehicleId', ParseUUIDPipe) vehicleId: string,
@Query('limit') limit?: string,
) {
return this.gps.history(vehicleId, limit ? parseInt(limit, 10) : undefined);
}
@Get('devices')
@ApiOperation({ summary: 'List GPS trackers' })
listDevices() {
return this.gps.listDevices();
}
@Post('devices')
@FleetManage()
@ApiOperation({ summary: 'Register a GPS tracker' })
register(@Body() dto: RegisterDeviceDto) {
return this.gps.registerDevice(dto);
}
@Patch('devices/:id')
@FleetManage()
@ApiOperation({ summary: 'Update a GPS tracker (name / assigned vehicle)' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateDeviceDto) {
return this.gps.updateDevice(id, dto);
}
@Delete('devices/:id')
@FleetManage()
@ApiOperation({ summary: 'Delete a GPS tracker' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.gps.removeDevice(id);
}
}

View File

@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { GpsDevice } from './entities/gps-device.entity';
import { GpsPosition } from './entities/gps-position.entity';
import { GpsDeviceRepository, GpsPositionRepository } from './gps-tracking.repository';
import { GpsTrackingService } from './gps-tracking.service';
import { GpsTrackingController } from './gps-tracking.controller';
import { Gt06Server } from './gt06/gt06.server';
@Module({
imports: [TypeOrmModule.forFeature([GpsDevice, GpsPosition])],
controllers: [GpsTrackingController],
providers: [GpsDeviceRepository, GpsPositionRepository, GpsTrackingService, Gt06Server],
exports: [GpsTrackingService],
})
export class GpsTrackingModule {}

View File

@@ -0,0 +1,29 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { GpsDevice } from './entities/gps-device.entity';
import { GpsPosition } from './entities/gps-position.entity';
@Injectable()
export class GpsDeviceRepository extends BaseRepository<GpsDevice> {
constructor(
@InjectRepository(GpsDevice) repository: Repository<GpsDevice>,
) {
super(repository);
}
findByImei(imei: string): Promise<GpsDevice | null> {
return this.repository.findOne({ where: { imei } });
}
}
@Injectable()
export class GpsPositionRepository extends BaseRepository<GpsPosition> {
constructor(
@InjectRepository(GpsPosition) repository: Repository<GpsPosition>,
) {
super(repository);
}
}

View File

@@ -0,0 +1,124 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { GpsDeviceRepository, GpsPositionRepository } from './gps-tracking.repository';
import { GpsDevice } from './entities/gps-device.entity';
import { Gt06Gps, Gt06Status } from './gt06/gt06.codec';
/** A device is considered ONLINE if seen within this window. */
const ONLINE_WINDOW_MS = 5 * 60 * 1000;
@Injectable()
export class GpsTrackingService {
private readonly logger = new Logger(GpsTrackingService.name);
constructor(
private readonly devices: GpsDeviceRepository,
private readonly positions: GpsPositionRepository,
) {}
private isOnline(d: GpsDevice): boolean {
return Boolean(d.lastSeenAt && Date.now() - new Date(d.lastSeenAt).getTime() < ONLINE_WINDOW_MS);
}
/** Find the device for an IMEI, auto-registering it on first contact. */
private async ensureDevice(imei: string): Promise<GpsDevice> {
const existing = await this.devices.findByImei(imei);
if (existing) return existing;
this.logger.log(`Auto-registering new GPS tracker ${imei}`);
return this.devices.create({ imei, status: 'REGISTERED', lastSeenAt: new Date() });
}
// ── Ingestion (called by the TCP server) ──
async handleLogin(imei: string): Promise<void> {
const device = await this.ensureDevice(imei);
await this.devices.update(device.id, { lastSeenAt: new Date(), status: 'ONLINE' });
}
async handleHeartbeat(imei: string, status: Gt06Status): Promise<void> {
const device = await this.ensureDevice(imei);
await this.devices.update(device.id, {
lastSeenAt: new Date(),
status: 'ONLINE',
voltageLevel: status.voltageLevel,
gsmLevel: status.gsmLevel,
});
}
async handleFix(imei: string, gps: Gt06Gps, alarm = 0, status?: Gt06Status): Promise<void> {
const device = await this.ensureDevice(imei);
const now = new Date();
await this.devices.update(device.id, {
lastSeenAt: now,
status: 'ONLINE',
lastLat: gps.latitude,
lastLng: gps.longitude,
lastSpeed: gps.speed,
lastCourse: gps.course,
lastFixAt: new Date(gps.time),
...(status ? { voltageLevel: status.voltageLevel, gsmLevel: status.gsmLevel } : {}),
});
await this.positions.create({
deviceId: device.id,
imei,
vehicleId: device.vehicleId ?? null,
lat: gps.latitude,
lng: gps.longitude,
speed: gps.speed,
course: gps.course,
satellites: gps.satellites,
positioned: gps.positioned,
gpsTime: new Date(gps.time),
alarm,
});
}
// ── Queries / management (REST) ──
private decorate(d: GpsDevice) {
return { ...d, online: this.isOnline(d) };
}
async listDevices() {
const rows = await this.devices.findAll({ relations: { vehicle: true }, order: { createdAt: 'DESC' } });
return rows.map((d) => this.decorate(d));
}
/** Live map feed — devices that have at least one fix. */
async latest() {
const rows = await this.devices.findAll({ relations: { vehicle: true } });
return rows.filter((d) => d.lastLat != null && d.lastLng != null).map((d) => this.decorate(d));
}
async history(vehicleId: string, limit = 200) {
return this.positions.findAll({
where: { vehicleId },
order: { gpsTime: 'DESC' },
take: Math.min(limit, 1000),
});
}
async registerDevice(dto: { imei: string; name?: string; vehicleId?: string | null }) {
const existing = await this.devices.findByImei(dto.imei);
if (existing) throw new BadRequestException(`A device with IMEI ${dto.imei} already exists`);
return this.devices.create({
imei: dto.imei,
name: dto.name ?? null,
vehicleId: dto.vehicleId ?? null,
status: 'REGISTERED',
});
}
async updateDevice(id: string, dto: { name?: string; vehicleId?: string | null }) {
const updated = await this.devices.update(id, {
...(dto.name !== undefined ? { name: dto.name } : {}),
...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}),
});
if (!updated) throw new NotFoundException(`GPS device ${id} not found`);
return updated;
}
async removeDevice(id: string): Promise<void> {
await this.devices.softDelete(id);
}
}

View File

@@ -0,0 +1,207 @@
/**
* GT06 GPS-tracker protocol codec.
*
* Frame: 0x78 0x78 | len(1) | protocol(1) | content(N) | serial(2) | crc(2) | 0x0D 0x0A
* `len` counts protocol..crc (= 5 + N). CRC-ITU (CRC-16/X.25) is computed over
* len..serial (inclusive) and equals the 2 crc bytes.
*/
const START = 0x7878;
const STOP = 0x0d0a;
export const GT06_PROTOCOL = {
LOGIN: 0x01,
LOCATION: 0x12,
HEARTBEAT: 0x13,
STRING: 0x15,
ALARM: 0x16,
ADDRESS_BY_PHONE: 0x1a,
SERVER_COMMAND: 0x80,
} as const;
/** CRC-16/X.25 (a.k.a. CRC-ITU) used by GT06 — reflected, poly 0x8408, init/xorout 0xFFFF. */
export function crcItu(bytes: Buffer): number {
let fcs = 0xffff;
for (const b of bytes) {
fcs ^= b;
for (let i = 0; i < 8; i++) {
fcs = fcs & 1 ? (fcs >> 1) ^ 0x8408 : fcs >> 1;
}
}
return (~fcs) & 0xffff;
}
export interface Gt06Gps {
time: string; // ISO (UTC)
satellites: number;
latitude: number;
longitude: number;
speed: number; // km/h
course: number; // 0-360
positioned: boolean;
}
export interface Gt06Lbs {
mcc: number;
mnc: number;
lac: number;
cellId: number;
}
export interface Gt06Status {
terminalInfo: number;
voltageLevel: number;
gsmLevel: number;
alarm: number; // former byte of alarm/language
charging: boolean;
accOn: boolean;
gpsTracking: boolean;
oilCut: boolean;
}
export type Gt06Packet =
| { type: 'login'; protocol: number; serial: number; imei: string }
| { type: 'location'; protocol: number; serial: number; gps: Gt06Gps; lbs: Gt06Lbs }
| { type: 'heartbeat'; protocol: number; serial: number; status: Gt06Status }
| { type: 'alarm'; protocol: number; serial: number; gps: Gt06Gps; lbs: Gt06Lbs; status: Gt06Status }
| { type: 'unknown'; protocol: number; serial: number };
/** Terminal ID (8 BCD bytes) → 15-digit IMEI (drops the leading pad nibble). */
function decodeImei(buf: Buffer): string {
return buf.toString('hex').replace(/^0/, '');
}
function decodeDateTime(buf: Buffer, off: number): string {
const year = 2000 + buf[off];
const month = buf[off + 1];
const day = buf[off + 2];
const hour = buf[off + 3];
const min = buf[off + 4];
const sec = buf[off + 5];
return new Date(Date.UTC(year, month - 1, day, hour, min, sec)).toISOString();
}
/** Convert a GT06 lat/long raw uint32 to decimal degrees (magnitude only). */
function rawToDegrees(raw: number): number {
return raw / 30000 / 60;
}
function decodeGps(buf: Buffer, off: number): Gt06Gps {
const time = decodeDateTime(buf, off);
const lenSat = buf[off + 6];
const satellites = lenSat & 0x0f;
const latRaw = buf.readUInt32BE(off + 7);
const lonRaw = buf.readUInt32BE(off + 11);
const speed = buf[off + 15];
const cs = buf.readUInt16BE(off + 16);
const hi = (cs >> 8) & 0xff;
const positioned = Boolean(hi & 0x10); // BYTE_1 Bit4
const isWest = Boolean(hi & 0x08); // BYTE_1 Bit3 (1 = West)
const isNorth = Boolean(hi & 0x04); // BYTE_1 Bit2 (1 = North)
const course = cs & 0x03ff; // BYTE_1 Bit1-0 + BYTE_2
let latitude = rawToDegrees(latRaw);
let longitude = rawToDegrees(lonRaw);
if (!isNorth) latitude = -latitude;
if (isWest) longitude = -longitude;
return { time, satellites, latitude, longitude, speed, course, positioned };
}
function decodeStatus(buf: Buffer, off: number): Gt06Status {
const terminalInfo = buf[off];
const voltageLevel = buf[off + 1];
const gsmLevel = buf[off + 2];
const alarm = buf[off + 3]; // alarm/language former byte
return {
terminalInfo,
voltageLevel,
gsmLevel,
alarm,
oilCut: Boolean(terminalInfo & 0x80),
gpsTracking: Boolean(terminalInfo & 0x40),
charging: Boolean(terminalInfo & 0x04),
accOn: Boolean(terminalInfo & 0x02),
};
}
function decodeLbs(buf: Buffer, off: number): Gt06Lbs {
return {
mcc: buf.readUInt16BE(off),
mnc: buf[off + 2],
lac: buf.readUInt16BE(off + 3),
cellId: buf.readUIntBE(off + 5, 3),
};
}
function decodeFrame(frame: Buffer): Gt06Packet | null {
// frame = 78 78 len ...content... serial(2) crc(2) 0D 0A
const len = frame[2];
const protocol = frame[3];
const serialOff = 3 + (len - 4); // after protocol + content, before serial(2)+crc(2)
const serial = frame.readUInt16BE(serialOff);
const contentOff = 4; // start of content (after protocol)
switch (protocol) {
case GT06_PROTOCOL.LOGIN:
return { type: 'login', protocol, serial, imei: decodeImei(frame.subarray(contentOff, contentOff + 8)) };
case GT06_PROTOCOL.LOCATION:
return { type: 'location', protocol, serial, gps: decodeGps(frame, contentOff), lbs: decodeLbs(frame, contentOff + 18) };
case GT06_PROTOCOL.HEARTBEAT:
return { type: 'heartbeat', protocol, serial, status: decodeStatus(frame, contentOff) };
case GT06_PROTOCOL.ALARM: {
const gps = decodeGps(frame, contentOff);
// content: date(6)+lenSat(1)+lat(4)+lng(4)+speed(1)+course(2)=18, lbsLen(1), lbs(8), status(1+1+1+2)
const lbs = decodeLbs(frame, contentOff + 18 + 1);
const status = decodeStatus(frame, contentOff + 18 + 1 + 8);
return { type: 'alarm', protocol, serial, gps, lbs, status };
}
default:
return { type: 'unknown', protocol, serial };
}
}
/**
* Pull all complete frames out of a stream buffer. Returns the decoded packets
* (skipping CRC-failed ones) and the trailing bytes that form a partial frame.
*/
export function parseStream(buffer: Buffer): { packets: Gt06Packet[]; rest: Buffer } {
const packets: Gt06Packet[] = [];
let i = 0;
while (i + 5 <= buffer.length) {
if (buffer.readUInt16BE(i) !== START) {
i += 1; // resync
continue;
}
const len = buffer[i + 2];
const frameLen = 2 + 1 + len + 2; // start + lenByte + (protocol..crc) + stop
if (i + frameLen > buffer.length) break; // incomplete
const frame = buffer.subarray(i, i + frameLen);
if (frame.readUInt16BE(frameLen - 2) === STOP) {
// CRC over len..serial (frame[2 .. frameLen-4]); crc bytes are frameLen-4..frameLen-3.
const crcCalc = crcItu(frame.subarray(2, frameLen - 4));
const crcRecv = frame.readUInt16BE(frameLen - 4);
if (crcCalc === crcRecv) {
const pkt = decodeFrame(frame);
if (pkt) packets.push(pkt);
}
i += frameLen;
} else {
i += 1; // bad frame, resync
}
}
return { packets, rest: buffer.subarray(i) };
}
/** Build a server → terminal ACK (login/heartbeat/alarm) echoing the serial. */
export function buildAck(protocol: number, serial: number): Buffer {
const body = Buffer.alloc(3); // protocol + serial(2)
body[0] = protocol;
body.writeUInt16BE(serial, 1);
const len = body.length + 2; // + crc(2)
const forCrc = Buffer.concat([Buffer.from([len]), body]);
const crc = crcItu(forCrc);
return Buffer.concat([
Buffer.from([0x78, 0x78, len]),
body,
Buffer.from([(crc >> 8) & 0xff, crc & 0xff, 0x0d, 0x0a]),
]);
}

View File

@@ -0,0 +1,97 @@
import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy } from '@nestjs/common';
import * as net from 'net';
import { GpsTrackingService } from '../gps-tracking.service';
import { buildAck, GT06_PROTOCOL, parseStream } from './gt06.codec';
interface Session {
buffer: Buffer;
imei: string | null;
}
const MAX_BUFFER = 64 * 1024;
/**
* Raw TCP listener for GT06 GPS trackers. Trackers open a socket, send a login
* (IMEI), then stream location/heartbeat/alarm packets; we decode, persist via
* {@link GpsTrackingService}, and ACK login/heartbeat/alarm so the device keeps
* the connection alive. Disabled when GT06_TCP_PORT=0.
*/
@Injectable()
export class Gt06Server implements OnApplicationBootstrap, OnModuleDestroy {
private readonly logger = new Logger(Gt06Server.name);
private server?: net.Server;
private readonly sessions = new Map<net.Socket, Session>();
constructor(private readonly gps: GpsTrackingService) {}
onApplicationBootstrap(): void {
const port = Number(process.env.GT06_TCP_PORT ?? 5023);
if (!port) {
this.logger.log('GT06 TCP listener disabled (GT06_TCP_PORT=0)');
return;
}
const host = process.env.GT06_TCP_HOST ?? '0.0.0.0';
this.server = net.createServer((socket) => this.onConnection(socket));
this.server.on('error', (err) => this.logger.error(`GT06 server error: ${String(err)}`));
this.server.listen(port, host, () => this.logger.log(`GT06 GPS tracker listener on ${host}:${port}`));
}
onModuleDestroy(): void {
for (const socket of this.sessions.keys()) socket.destroy();
this.sessions.clear();
this.server?.close();
}
private onConnection(socket: net.Socket): void {
this.sessions.set(socket, { buffer: Buffer.alloc(0), imei: null });
socket.on('data', (chunk) => void this.onData(socket, chunk));
socket.on('error', () => this.sessions.delete(socket));
socket.on('close', () => this.sessions.delete(socket));
}
private async onData(socket: net.Socket, chunk: Buffer): Promise<void> {
const session = this.sessions.get(socket);
if (!session) return;
session.buffer = Buffer.concat([session.buffer, chunk]);
if (session.buffer.length > MAX_BUFFER) session.buffer = Buffer.alloc(0); // drop garbage
const { packets, rest } = parseStream(session.buffer);
session.buffer = rest;
for (const pkt of packets) {
try {
await this.handle(socket, session, pkt);
} catch (err) {
this.logger.error(`Failed to handle GT06 packet (${pkt.type}): ${String(err)}`);
}
}
}
private async handle(
socket: net.Socket,
session: Session,
pkt: ReturnType<typeof parseStream>['packets'][number],
): Promise<void> {
switch (pkt.type) {
case 'login':
session.imei = pkt.imei;
await this.gps.handleLogin(pkt.imei);
socket.write(buildAck(GT06_PROTOCOL.LOGIN, pkt.serial));
break;
case 'heartbeat':
if (session.imei) await this.gps.handleHeartbeat(session.imei, pkt.status);
socket.write(buildAck(GT06_PROTOCOL.HEARTBEAT, pkt.serial));
break;
case 'location':
if (session.imei) await this.gps.handleFix(session.imei, pkt.gps);
break;
case 'alarm':
if (session.imei) await this.gps.handleFix(session.imei, pkt.gps, pkt.status.alarm, pkt.status);
socket.write(buildAck(GT06_PROTOCOL.ALARM, pkt.serial));
break;
default:
break;
}
}
}

View File

@@ -0,0 +1,48 @@
import { IsUUID, IsString, IsDateString, IsNumber, IsOptional, IsEnum } from 'class-validator';
import { IncidentType, IncidentSeverity, IncidentStatus } from '../entities/incident.entity';
export class CreateIncidentDto {
@IsOptional()
@IsUUID()
vehicleId?: string;
@IsOptional()
@IsUUID()
driverId?: string;
@IsOptional()
@IsUUID()
bookingId?: string;
@IsEnum(IncidentType)
type!: IncidentType;
@IsEnum(IncidentSeverity)
severity!: IncidentSeverity;
@IsDateString()
occurredAt!: string;
@IsOptional()
@IsString()
location?: string;
@IsString()
description!: string;
@IsOptional()
@IsNumber()
damageEstimate?: number;
@IsOptional()
@IsEnum(IncidentStatus)
status?: IncidentStatus;
@IsOptional()
@IsString()
insuranceClaimNumber?: string;
@IsOptional()
@IsString()
reportedBy?: string;
}

View File

@@ -0,0 +1,52 @@
import { IsUUID, IsString, IsDateString, IsNumber, IsOptional, IsEnum } from 'class-validator';
import { IncidentType, IncidentSeverity, IncidentStatus } from '../entities/incident.entity';
export class UpdateIncidentDto {
@IsOptional()
@IsUUID()
vehicleId?: string;
@IsOptional()
@IsUUID()
driverId?: string;
@IsOptional()
@IsUUID()
bookingId?: string;
@IsOptional()
@IsEnum(IncidentType)
type?: IncidentType;
@IsOptional()
@IsEnum(IncidentSeverity)
severity?: IncidentSeverity;
@IsOptional()
@IsDateString()
occurredAt?: string;
@IsOptional()
@IsString()
location?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsNumber()
damageEstimate?: number;
@IsOptional()
@IsEnum(IncidentStatus)
status?: IncidentStatus;
@IsOptional()
@IsString()
insuranceClaimNumber?: string;
@IsOptional()
@IsString()
reportedBy?: string;
}

View File

@@ -0,0 +1,76 @@
import { BaseEntity } from '@edr/api-common';
import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
import { Driver } from '../../drivers/entities/driver.entity';
export enum IncidentType {
ACCIDENT = 'ACCIDENT',
BREAKDOWN = 'BREAKDOWN',
TRAFFIC_VIOLATION = 'TRAFFIC_VIOLATION',
THEFT = 'THEFT',
OTHER = 'OTHER',
}
export enum IncidentSeverity {
MINOR = 'MINOR',
MODERATE = 'MODERATE',
MAJOR = 'MAJOR',
CRITICAL = 'CRITICAL',
}
export enum IncidentStatus {
REPORTED = 'REPORTED',
UNDER_REVIEW = 'UNDER_REVIEW',
CLAIM_FILED = 'CLAIM_FILED',
RESOLVED = 'RESOLVED',
CLOSED = 'CLOSED',
}
@Entity({ name: 'incidents', schema: 'freight' })
@Index(['driverId', 'occurredAt'])
@Index(['vehicleId', 'occurredAt'])
export class Incident extends BaseEntity {
@Column({ name: 'vehicle_id', type: 'uuid', nullable: true })
vehicleId?: string;
@ManyToOne(() => Vehicle, { eager: false, nullable: true })
@JoinColumn({ name: 'vehicle_id' })
vehicle?: Vehicle;
@Column({ name: 'driver_id', type: 'uuid', nullable: true })
driverId?: string;
@ManyToOne(() => Driver, { eager: false, nullable: true })
@JoinColumn({ name: 'driver_id' })
driver?: Driver;
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
bookingId?: string;
@Column({ name: 'type', type: 'varchar' })
type!: IncidentType;
@Column({ name: 'severity', type: 'varchar' })
severity!: IncidentSeverity;
@Column({ name: 'occurred_at', type: 'timestamptz' })
occurredAt!: Date;
@Column({ name: 'location', type: 'varchar', nullable: true })
location?: string;
@Column({ name: 'description', type: 'text' })
description!: string;
@Column({ name: 'damage_estimate', type: 'numeric', precision: 14, scale: 2, nullable: true })
damageEstimate?: number;
@Column({ name: 'status', type: 'varchar', default: IncidentStatus.REPORTED })
status!: IncidentStatus;
@Column({ name: 'insurance_claim_number', type: 'varchar', nullable: true })
insuranceClaimNumber?: string;
@Column({ name: 'reported_by', type: 'varchar', nullable: true })
reportedBy?: string;
}

View File

@@ -0,0 +1,69 @@
import {
Controller,
Post,
Get,
Patch,
Delete,
Body,
Param,
Query,
} from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { IncidentsService } from './incidents.service';
import { CreateIncidentDto } from './dto/create-incident.dto';
import { UpdateIncidentDto } from './dto/update-incident.dto';
import { IncidentStatus, IncidentType } from './entities/incident.entity';
@ApiTags('Accident & Incident Management')
@Controller('incidents')
export class IncidentsController {
constructor(private readonly incidentsService: IncidentsService) {}
@Post()
@ApiOperation({ summary: 'Report an incident' })
async create(@Body() dto: CreateIncidentDto) {
return this.incidentsService.create(dto);
}
@Get()
@ApiOperation({ summary: 'List incidents (optionally filtered)' })
async findAll(
@Query('vehicleId') vehicleId?: string,
@Query('driverId') driverId?: string,
@Query('status') status?: IncidentStatus,
@Query('type') type?: IncidentType,
) {
return this.incidentsService.findAll({ vehicleId, driverId, status, type });
}
@Get('driver/:driverId/stats')
@ApiOperation({ summary: 'Get incident statistics for a driver' })
async statsForDriver(@Param('driverId') driverId: string) {
return this.incidentsService.statsForDriver(driverId);
}
@Get('driver/:driverId')
@ApiOperation({ summary: 'List incidents for a driver (incident history)' })
async findByDriver(@Param('driverId') driverId: string) {
return this.incidentsService.findByDriver(driverId);
}
@Get(':id')
@ApiOperation({ summary: 'Get an incident by id' })
async findById(@Param('id') id: string) {
return this.incidentsService.findById(id);
}
@Patch(':id')
@ApiOperation({ summary: 'Update an incident' })
async update(@Param('id') id: string, @Body() dto: UpdateIncidentDto) {
return this.incidentsService.update(id, dto);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete an incident' })
async remove(@Param('id') id: string) {
await this.incidentsService.remove(id);
return { success: true };
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Incident } from './entities/incident.entity';
import { IncidentsService } from './incidents.service';
import { IncidentsRepository } from './incidents.repository';
import { IncidentsController } from './incidents.controller';
@Module({
imports: [TypeOrmModule.forFeature([Incident])],
providers: [IncidentsService, IncidentsRepository],
controllers: [IncidentsController],
exports: [IncidentsService],
})
export class IncidentsModule {}

View File

@@ -0,0 +1,15 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { BaseRepository } from '@edr/api-common';
import { Repository } from 'typeorm';
import { Incident } from './entities/incident.entity';
@Injectable()
export class IncidentsRepository extends BaseRepository<Incident> {
constructor(
@InjectRepository(Incident)
incidentRepository: Repository<Incident>,
) {
super(incidentRepository);
}
}

View File

@@ -0,0 +1,95 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { FindOptionsWhere } from 'typeorm';
import { IncidentsRepository } from './incidents.repository';
import {
Incident,
IncidentStatus,
IncidentType,
} from './entities/incident.entity';
import { CreateIncidentDto } from './dto/create-incident.dto';
import { UpdateIncidentDto } from './dto/update-incident.dto';
export interface IncidentFilter {
vehicleId?: string;
driverId?: string;
status?: IncidentStatus;
type?: IncidentType;
}
export interface DriverIncidentStats {
total: number;
byType: Record<string, number>;
lastIncidentAt: Date | null;
}
@Injectable()
export class IncidentsService {
constructor(private readonly incidentsRepository: IncidentsRepository) {}
async create(dto: CreateIncidentDto): Promise<Incident> {
return this.incidentsRepository.create({
...dto,
occurredAt: new Date(dto.occurredAt),
});
}
async findAll(filter: IncidentFilter = {}): Promise<Incident[]> {
const where: FindOptionsWhere<Incident> = {};
if (filter.vehicleId) where.vehicleId = filter.vehicleId;
if (filter.driverId) where.driverId = filter.driverId;
if (filter.status) where.status = filter.status;
if (filter.type) where.type = filter.type;
return this.incidentsRepository.findAll({
where,
order: { occurredAt: 'DESC' },
});
}
async findByDriver(driverId: string): Promise<Incident[]> {
return this.incidentsRepository.findAll({
where: { driverId },
order: { occurredAt: 'DESC' },
});
}
async findById(id: string): Promise<Incident> {
const incident = await this.incidentsRepository.findById(id);
if (!incident) {
throw new NotFoundException(`Incident ${id} not found`);
}
return incident;
}
async update(id: string, dto: UpdateIncidentDto): Promise<Incident> {
await this.findById(id);
const updated = await this.incidentsRepository.update(id, {
...dto,
occurredAt: dto.occurredAt ? new Date(dto.occurredAt) : undefined,
});
return updated!;
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.incidentsRepository.softDelete(id);
}
async statsForDriver(driverId: string): Promise<DriverIncidentStats> {
const incidents = await this.incidentsRepository.findAll({
where: { driverId },
order: { occurredAt: 'DESC' },
});
const byType: Record<string, number> = {};
for (const incident of incidents) {
byType[incident.type] = (byType[incident.type] || 0) + 1;
}
return {
total: incidents.length,
byType,
lastIncidentAt: incidents.length > 0 ? incidents[0].occurredAt : null,
};
}
}

View File

@@ -1,5 +1,18 @@
import { PartialType } from '@nestjs/mapped-types';
import { ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import { IsISO8601, IsOptional } from 'class-validator';
import { CreateLastMileDto } from './create-last-mile.dto';
export class UpdateLastMileDto extends PartialType(CreateLastMileDto) {}
export class UpdateLastMileDto extends PartialType(CreateLastMileDto) {
/** Truck-detention clock start (vehicle arrived at destination). Overrides the auto-stamp. */
@ApiPropertyOptional({ description: 'Vehicle arrival time (ISO 8601) — detention clock start.' })
@IsOptional()
@IsISO8601()
arrivedAt?: string;
/** Truck-detention clock end (cargo cleared / vehicle returned). Overrides the auto-stamp. */
@ApiPropertyOptional({ description: 'Delivery/return time (ISO 8601) — detention clock end.' })
@IsOptional()
@IsISO8601()
deliveredAt?: string;
}

View File

@@ -30,6 +30,15 @@ export class LastMile extends BaseEntity {
@Column({ name: 'status', type: 'varchar', length: 30, default: 'PAYMENT_PENDING' })
status!: LastMileStatus;
// Truck-detention window. arrivedAt = vehicle reached destination (IN_TRANSIT);
// deliveredAt = cargo cleared / vehicle returned (DELIVERED). Detention accrues
// between them beyond the rule's grace hours (default 3h), per truck per day.
@Column({ name: 'arrived_at', type: 'timestamptz', nullable: true })
arrivedAt?: Date | null;
@Column({ name: 'delivered_at', type: 'timestamptz', nullable: true })
deliveredAt?: Date | null;
@Column({ name: 'advanced_payment', type: 'numeric', precision: 14, scale: 2, default: 0 })
advancedPayment!: number;

View File

@@ -1,4 +1,4 @@
import { Injectable, Logger } from '@nestjs/common';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { Freight } from '@edr/types';
@@ -63,6 +63,30 @@ export class LastMileInvoiceService {
return null;
}
// Reject mixed-currency truck sets — a single invoice can only be one
// currency, and amounts across currencies can't be summed.
const billableTrucks = (record.vehicleAssignments ?? []).filter(
(a) => Number(a.distanceKm) > 0,
);
const currencies = [
...new Set(
billableTrucks
.map((a) => (a.vehicle as { currency?: string } | undefined)?.currency)
.filter((c): c is string => Boolean(c)),
),
];
if (currencies.length > 1) {
throw new BadRequestException(
`Cannot generate invoice: assigned trucks use mixed currencies (${currencies.join(', ')}). Assign trucks that share one currency.`,
);
}
// Currency follows the truck (price/km is quoted per vehicle), falling back
// to the booking's currency, then ETB.
const truckCurrency =
(record.vehicle as { currency?: string } | undefined)?.currency ||
(record.vehicleAssignments?.[0]?.vehicle as { currency?: string } | undefined)?.currency;
// Generate invoice with remainingPayment as totalAmount
const input: GenerateInvoiceInput = {
source: 'last_mile' as Freight.InvoiceSource,
@@ -70,7 +94,7 @@ export class LastMileInvoiceService {
type: 'DELIVERY_FEE',
companyId: lm.booking!.companyId,
companyProfileId: lm.booking!.companyProfileId || '',
currency: lm.booking!.paymentCurrency || 'ETB',
currency: truckCurrency || lm.booking!.paymentCurrency || 'ETB',
lines: [
{
chargeType: 'DELIVERY',

View File

@@ -14,7 +14,8 @@ import {
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking-guards';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { CreateLastMileDto } from './dto/create-last-mile.dto';
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
@@ -27,7 +28,7 @@ import { LastMileInvoiceService } from './last-mile-invoice.service';
@ApiTags('last-mile')
@ApiBearerAuth()
@Controller('last-mile')
@TrainSchedulingView()
@BookingStaff(FREIGHT_PERMS.lastMile.view)
export class LastMileController {
constructor(
private readonly lastMileService: LastMileService,
@@ -63,21 +64,21 @@ export class LastMileController {
}
@Post('accept/:reference')
@TrainSchedulingManage()
@BookingStaff(FREIGHT_PERMS.lastMile.accept)
@ApiOperation({ summary: 'Accept a paid booking and create a last-mile leg' })
acceptBooking(@Param('reference') reference: string) {
return this.lastMileService.acceptBookingByReference(reference);
}
@Post()
@TrainSchedulingManage()
@BookingStaff(FREIGHT_PERMS.lastMile.create)
@ApiOperation({ summary: 'Create a last-mile leg' })
create(@Body() dto: CreateLastMileDto) {
return this.lastMileService.create(dto);
}
@Patch(':id')
@TrainSchedulingManage()
@BookingStaff(FREIGHT_PERMS.lastMile.update)
@ApiOperation({ summary: 'Update a last-mile leg' })
async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLastMileDto) {
// No invoice side-effects here — invoices are generated only via the
@@ -86,7 +87,7 @@ export class LastMileController {
}
@Delete(':id')
@TrainSchedulingManage()
@BookingStaff(FREIGHT_PERMS.lastMile.delete)
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a last-mile leg' })
remove(@Param('id', ParseUUIDPipe) id: string) {
@@ -95,7 +96,7 @@ export class LastMileController {
@Post(':id/vehicles')
@TrainSchedulingManage()
@BookingStaff(FREIGHT_PERMS.lastMile.assignVehicles)
@ApiOperation({ summary: 'Set the vehicles assigned to a last-mile delivery (multi-truck)' })
async setVehicles(
@Param('id', ParseUUIDPipe) id: string,
@@ -105,7 +106,7 @@ export class LastMileController {
}
@Post(':id/distances')
@TrainSchedulingManage()
@BookingStaff(FREIGHT_PERMS.lastMile.setDistances)
@ApiOperation({ summary: 'Set per-vehicle actual distances (does not generate an invoice)' })
async setDistances(
@Param('id', ParseUUIDPipe) id: string,
@@ -115,7 +116,7 @@ export class LastMileController {
}
@Post(':id/invoice')
@TrainSchedulingManage()
@BookingStaff(FREIGHT_PERMS.lastMile.generateInvoice)
@ApiOperation({ summary: 'Generate the delivery-fee invoice for a last-mile leg' })
async generateInvoice(@Param('id', ParseUUIDPipe) id: string) {
const record = await this.lastMileService.findById(id);

View File

@@ -282,6 +282,17 @@ export class LastMileService {
...(dto.exactKm !== undefined ? { exactKm: dto.exactKm } : {}),
...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}),
...(dtoAny.paid !== undefined ? { paid: dtoAny.paid } : {}),
// Truck-detention clock: stamp arrival when the vehicle goes IN_TRANSIT and
// delivery when it reaches DELIVERED (first time only). Explicit dto values
// below override the auto-stamp so staff can record the real times.
...(dto.status === 'IN_TRANSIT' && existing.status !== 'IN_TRANSIT' && !existing.arrivedAt
? { arrivedAt: new Date() }
: {}),
...(dto.status === 'DELIVERED' && existing.status !== 'DELIVERED' && !existing.deliveredAt
? { deliveredAt: new Date() }
: {}),
...(dtoAny.arrivedAt !== undefined ? { arrivedAt: dtoAny.arrivedAt ? new Date(dtoAny.arrivedAt) : null } : {}),
...(dtoAny.deliveredAt !== undefined ? { deliveredAt: dtoAny.deliveredAt ? new Date(dtoAny.deliveredAt) : null } : {}),
} as any);
if (!updated) {
@@ -510,10 +521,24 @@ export class LastMileService {
{ distanceKm: d.distanceKm },
);
}
const total = distances.reduce((s, d) => s + (Number(d.distanceKm) || 0), 0);
// Billing is per truck: amount = Σ (truck distance × truck price/km). The
// per-vehicle rate + currency live on the vehicle, so we ignore the legacy
// LAST_MILE flat rate and any client-sent amount. `remainingPayment` param
// kept only for signature back-compat.
void remainingPayment;
const assignments = await this.dataSource.manager.find(LastMileVehicleAssignment, {
where: { lastMileId: id },
relations: { vehicle: true },
});
const total = assignments.reduce((s, a) => s + (Number(a.distanceKm) || 0), 0);
const amount = assignments.reduce(
(s, a) => s + (Number(a.distanceKm) || 0) * (Number(a.vehicle?.pricePerKm) || 0),
0,
);
await this.lastMileRepository.update(id, {
exactKm: total,
...(remainingPayment != null ? { remainingPayment } : {}),
remainingPayment: amount,
} as any);
return this.findById(id);
}

View File

@@ -0,0 +1,171 @@
import {
IsUUID,
IsString,
IsDateString,
IsNumber,
IsInt,
IsOptional,
IsEnum,
Min,
} from 'class-validator';
import { WorkOrderStatus, WorkOrderPriority } from '../entities/work-order.entity';
export class CreateWorkOrderDto {
@IsUUID()
vehicleId!: string;
@IsString()
title!: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsEnum(WorkOrderStatus)
status?: WorkOrderStatus;
@IsOptional()
@IsEnum(WorkOrderPriority)
priority?: WorkOrderPriority;
@IsOptional()
@IsString()
assignedTo?: string;
@IsOptional()
@IsDateString()
openedAt?: string;
@IsOptional()
@IsDateString()
closedAt?: string;
@IsOptional()
@IsNumber()
laborCost?: number;
@IsOptional()
@IsNumber()
partsCost?: number;
}
export class UpdateWorkOrderDto {
@IsOptional()
@IsString()
title?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsEnum(WorkOrderStatus)
status?: WorkOrderStatus;
@IsOptional()
@IsEnum(WorkOrderPriority)
priority?: WorkOrderPriority;
@IsOptional()
@IsString()
assignedTo?: string;
@IsOptional()
@IsDateString()
closedAt?: string;
@IsOptional()
@IsNumber()
laborCost?: number;
@IsOptional()
@IsNumber()
partsCost?: number;
}
export class CreatePartDto {
@IsString()
name!: string;
@IsOptional()
@IsString()
sku?: string;
@IsOptional()
@IsString()
category?: string;
@IsOptional()
@IsInt()
@Min(0)
quantityInStock?: number;
@IsOptional()
@IsInt()
@Min(0)
reorderLevel?: number;
@IsOptional()
@IsNumber()
unitCost?: number;
@IsOptional()
@IsString()
location?: string;
}
export class UpdatePartDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
sku?: string;
@IsOptional()
@IsString()
category?: string;
@IsOptional()
@IsInt()
@Min(0)
quantityInStock?: number;
@IsOptional()
@IsInt()
@Min(0)
reorderLevel?: number;
@IsOptional()
@IsNumber()
unitCost?: number;
@IsOptional()
@IsString()
location?: string;
}
export class CreateWarrantyDto {
@IsUUID()
vehicleId!: string;
@IsString()
component!: string;
@IsOptional()
@IsString()
provider?: string;
@IsOptional()
@IsDateString()
startDate?: string;
@IsDateString()
expiryDate!: string;
@IsOptional()
@IsString()
coverageNotes?: string;
}

View File

@@ -0,0 +1,27 @@
import { BaseEntity } from '@edr/api-common';
import { Entity, Column, Index } from 'typeorm';
@Entity({ name: 'parts', schema: 'freight' })
@Index(['category'])
export class Part extends BaseEntity {
@Column({ name: 'name', type: 'varchar' })
name!: string;
@Column({ name: 'sku', type: 'varchar', nullable: true })
sku?: string;
@Column({ name: 'category', type: 'varchar', nullable: true })
category?: string; // includes 'TIRE' — doubles as tire inventory
@Column({ name: 'quantity_in_stock', type: 'int', default: 0 })
quantityInStock!: number;
@Column({ name: 'reorder_level', type: 'int', default: 0 })
reorderLevel!: number;
@Column({ name: 'unit_cost', type: 'numeric', precision: 14, scale: 2, nullable: true })
unitCost?: number;
@Column({ name: 'location', type: 'varchar', nullable: true })
location?: string;
}

View File

@@ -0,0 +1,29 @@
import { BaseEntity } from '@edr/api-common';
import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
@Entity({ name: 'warranties', schema: 'freight' })
@Index(['vehicleId', 'expiryDate'])
export class Warranty extends BaseEntity {
@Column({ name: 'vehicle_id', type: 'uuid' })
vehicleId!: string;
@ManyToOne(() => Vehicle, { eager: false })
@JoinColumn({ name: 'vehicle_id' })
vehicle!: Vehicle;
@Column({ name: 'component', type: 'varchar' })
component!: string;
@Column({ name: 'provider', type: 'varchar', nullable: true })
provider?: string;
@Column({ name: 'start_date', type: 'date', nullable: true })
startDate?: string;
@Column({ name: 'expiry_date', type: 'date' })
expiryDate!: string;
@Column({ name: 'coverage_notes', type: 'text', nullable: true })
coverageNotes?: string;
}

View File

@@ -0,0 +1,55 @@
import { BaseEntity } from '@edr/api-common';
import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
export enum WorkOrderStatus {
OPEN = 'OPEN',
IN_PROGRESS = 'IN_PROGRESS',
COMPLETED = 'COMPLETED',
CANCELLED = 'CANCELLED',
}
export enum WorkOrderPriority {
LOW = 'LOW',
MEDIUM = 'MEDIUM',
HIGH = 'HIGH',
URGENT = 'URGENT',
}
@Entity({ name: 'work_orders', schema: 'freight' })
@Index(['vehicleId', 'status'])
export class WorkOrder extends BaseEntity {
@Column({ name: 'vehicle_id', type: 'uuid' })
vehicleId!: string;
@ManyToOne(() => Vehicle, { eager: false })
@JoinColumn({ name: 'vehicle_id' })
vehicle!: Vehicle;
@Column({ name: 'title', type: 'varchar' })
title!: string;
@Column({ name: 'description', type: 'text', nullable: true })
description?: string;
@Column({ name: 'status', type: 'varchar', default: WorkOrderStatus.OPEN })
status!: WorkOrderStatus;
@Column({ name: 'priority', type: 'varchar', default: WorkOrderPriority.MEDIUM })
priority!: WorkOrderPriority;
@Column({ name: 'assigned_to', type: 'varchar', nullable: true })
assignedTo?: string;
@Column({ name: 'opened_at', type: 'timestamptz' })
openedAt!: Date;
@Column({ name: 'closed_at', type: 'timestamptz', nullable: true })
closedAt?: Date;
@Column({ name: 'labor_cost', type: 'numeric', precision: 14, scale: 2, nullable: true })
laborCost?: number;
@Column({ name: 'parts_cost', type: 'numeric', precision: 14, scale: 2, nullable: true })
partsCost?: number;
}

View File

@@ -0,0 +1,99 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { WorkOrderRepository } from './work-order.repository';
import { PartRepository } from './part.repository';
import { WarrantyRepository } from './warranty.repository';
import { WorkOrder, WorkOrderStatus } from './entities/work-order.entity';
import { Part } from './entities/part.entity';
import { Warranty } from './entities/warranty.entity';
import {
CreateWorkOrderDto,
UpdateWorkOrderDto,
CreatePartDto,
UpdatePartDto,
CreateWarrantyDto,
} from './dto/create-maintenance-depth.dto';
@Injectable()
export class MaintenanceDepthService {
constructor(
private readonly workOrderRepository: WorkOrderRepository,
private readonly partRepository: PartRepository,
private readonly warrantyRepository: WarrantyRepository,
) {}
// ---- Work Orders ----
async createWorkOrder(dto: CreateWorkOrderDto): Promise<WorkOrder> {
return this.workOrderRepository.create({
...dto,
openedAt: dto.openedAt ? new Date(dto.openedAt) : new Date(),
closedAt: dto.closedAt ? new Date(dto.closedAt) : undefined,
});
}
async findWorkOrders(filters: { vehicleId?: string; status?: WorkOrderStatus }) {
return this.workOrderRepository.findFiltered(filters);
}
async findWorkOrderById(id: string): Promise<WorkOrder> {
const workOrder = await this.workOrderRepository.findById(id);
if (!workOrder) throw new NotFoundException(`Work order ${id} not found`);
return workOrder;
}
async updateWorkOrder(id: string, dto: UpdateWorkOrderDto): Promise<WorkOrder> {
await this.findWorkOrderById(id);
const updated = await this.workOrderRepository.update(id, {
...dto,
closedAt: dto.closedAt ? new Date(dto.closedAt) : undefined,
});
return updated!;
}
async deleteWorkOrder(id: string): Promise<{ id: string; deleted: boolean }> {
await this.findWorkOrderById(id);
await this.workOrderRepository.softDelete(id);
return { id, deleted: true };
}
// ---- Parts / Tires ----
async createPart(dto: CreatePartDto): Promise<Part> {
return this.partRepository.create({ ...dto });
}
async findParts(filters: { category?: string; lowStock?: boolean }) {
return this.partRepository.findFiltered(filters);
}
async updatePart(id: string, dto: UpdatePartDto): Promise<Part> {
const part = await this.partRepository.findById(id);
if (!part) throw new NotFoundException(`Part ${id} not found`);
const updated = await this.partRepository.update(id, { ...dto });
return updated!;
}
async deletePart(id: string): Promise<{ id: string; deleted: boolean }> {
const part = await this.partRepository.findById(id);
if (!part) throw new NotFoundException(`Part ${id} not found`);
await this.partRepository.softDelete(id);
return { id, deleted: true };
}
// ---- Warranties ----
async createWarranty(dto: CreateWarrantyDto): Promise<Warranty> {
return this.warrantyRepository.create({ ...dto });
}
async findWarranties(filters: { vehicleId?: string }) {
return this.warrantyRepository.findFiltered(filters);
}
async deleteWarranty(id: string): Promise<{ id: string; deleted: boolean }> {
const warranty = await this.warrantyRepository.findById(id);
if (!warranty) throw new NotFoundException(`Warranty ${id} not found`);
await this.warrantyRepository.softDelete(id);
return { id, deleted: true };
}
}

View File

@@ -1,52 +1,173 @@
import { Controller, Post, Get, Patch, Body, Param } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { Controller, Post, Get, Patch, Delete, Body, Param, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiTags, ApiOperation } from '@nestjs/swagger';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { MaintenanceService } from './maintenance.service';
import { MaintenanceDepthService } from './maintenance-depth.service';
import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto';
import {
CreateWorkOrderDto,
UpdateWorkOrderDto,
CreatePartDto,
UpdatePartDto,
CreateWarrantyDto,
} from './dto/create-maintenance-depth.dto';
import { WorkOrderStatus } from './entities/work-order.entity';
@ApiTags('Maintenance Management')
@ApiBearerAuth()
@Controller('maintenance')
export class MaintenanceController {
constructor(private readonly maintenanceService: MaintenanceService) {}
constructor(
private readonly maintenanceService: MaintenanceService,
private readonly maintenanceDepthService: MaintenanceDepthService,
) {}
@Post('schedules')
@BookingStaff(FREIGHT_PERMS.maintenance.create)
@ApiOperation({ summary: 'Schedule maintenance' })
async scheduleMaintenanceAsync(@Body() dto: CreateMaintenanceScheduleDto) {
return this.maintenanceService.scheduleMaintenanceAsync(dto);
}
@Post('costs')
@BookingStaff(FREIGHT_PERMS.maintenance.create)
@ApiOperation({ summary: 'Record maintenance cost' })
async recordCost(@Body() dto: CreateMaintenanceCostDto) {
return this.maintenanceService.recordMaintenanceCost(dto);
}
@Patch('schedules/:id')
@BookingStaff(FREIGHT_PERMS.maintenance.update)
@ApiOperation({ summary: 'Update maintenance schedule' })
async updateSchedule(@Param('id') id: string, @Body() dto: UpdateMaintenanceScheduleDto) {
return this.maintenanceService.updateMaintenanceSchedule(id, dto);
}
@Get('upcoming/:vehicleId')
@BookingStaff(FREIGHT_PERMS.maintenance.view)
@ApiOperation({ summary: 'Get upcoming maintenance' })
async getUpcoming(@Param('vehicleId') vehicleId: string) {
return this.maintenanceService.getUpcomingMaintenance(vehicleId);
}
@Get('history/:vehicleId')
@BookingStaff(FREIGHT_PERMS.maintenance.view)
@ApiOperation({ summary: 'Get maintenance history' })
async getHistory(@Param('vehicleId') vehicleId: string) {
return this.maintenanceService.getMaintenanceHistory(vehicleId);
}
@Get('stats')
@BookingStaff([FREIGHT_PERMS.maintenance.view, FREIGHT_PERMS.fleetReports.view, FREIGHT_PERMS.fleetDashboard.view])
@ApiOperation({ summary: 'Get fleet-wide maintenance statistics' })
async getFleetStats() {
return this.maintenanceService.getFleetMaintenanceStats();
}
@Get('stats/:vehicleId')
@BookingStaff([FREIGHT_PERMS.maintenance.view, FREIGHT_PERMS.fleetReports.view, FREIGHT_PERMS.fleetDashboard.view])
@ApiOperation({ summary: 'Get maintenance statistics' })
async getStats(@Param('vehicleId') vehicleId: string) {
return this.maintenanceService.getVehicleMaintenanceStats(vehicleId);
}
// ---- Work Orders ----
@Post('work-orders')
@BookingStaff(FREIGHT_PERMS.maintenance.create)
@ApiOperation({ summary: 'Create work order' })
async createWorkOrder(@Body() dto: CreateWorkOrderDto) {
return this.maintenanceDepthService.createWorkOrder(dto);
}
@Get('work-orders')
@BookingStaff(FREIGHT_PERMS.maintenance.view)
@ApiOperation({ summary: 'List work orders' })
async listWorkOrders(
@Query('vehicleId') vehicleId?: string,
@Query('status') status?: WorkOrderStatus,
) {
return this.maintenanceDepthService.findWorkOrders({ vehicleId, status });
}
@Get('work-orders/:id')
@BookingStaff(FREIGHT_PERMS.maintenance.view)
@ApiOperation({ summary: 'Get work order' })
async getWorkOrder(@Param('id') id: string) {
return this.maintenanceDepthService.findWorkOrderById(id);
}
@Patch('work-orders/:id')
@BookingStaff(FREIGHT_PERMS.maintenance.update)
@ApiOperation({ summary: 'Update work order' })
async updateWorkOrder(@Param('id') id: string, @Body() dto: UpdateWorkOrderDto) {
return this.maintenanceDepthService.updateWorkOrder(id, dto);
}
@Delete('work-orders/:id')
@BookingStaff(FREIGHT_PERMS.maintenance.delete)
@ApiOperation({ summary: 'Delete work order' })
async deleteWorkOrder(@Param('id') id: string) {
return this.maintenanceDepthService.deleteWorkOrder(id);
}
// ---- Parts / Tires ----
@Post('parts')
@BookingStaff(FREIGHT_PERMS.maintenance.create)
@ApiOperation({ summary: 'Create part' })
async createPart(@Body() dto: CreatePartDto) {
return this.maintenanceDepthService.createPart(dto);
}
@Get('parts')
@BookingStaff(FREIGHT_PERMS.maintenance.view)
@ApiOperation({ summary: 'List parts / tire inventory' })
async listParts(
@Query('category') category?: string,
@Query('lowStock') lowStock?: string,
) {
return this.maintenanceDepthService.findParts({
category,
lowStock: lowStock === 'true',
});
}
@Patch('parts/:id')
@BookingStaff(FREIGHT_PERMS.maintenance.update)
@ApiOperation({ summary: 'Update part' })
async updatePart(@Param('id') id: string, @Body() dto: UpdatePartDto) {
return this.maintenanceDepthService.updatePart(id, dto);
}
@Delete('parts/:id')
@BookingStaff(FREIGHT_PERMS.maintenance.delete)
@ApiOperation({ summary: 'Delete part' })
async deletePart(@Param('id') id: string) {
return this.maintenanceDepthService.deletePart(id);
}
// ---- Warranties ----
@Post('warranties')
@BookingStaff(FREIGHT_PERMS.maintenance.create)
@ApiOperation({ summary: 'Create warranty' })
async createWarranty(@Body() dto: CreateWarrantyDto) {
return this.maintenanceDepthService.createWarranty(dto);
}
@Get('warranties')
@BookingStaff(FREIGHT_PERMS.maintenance.view)
@ApiOperation({ summary: 'List warranties' })
async listWarranties(@Query('vehicleId') vehicleId?: string) {
return this.maintenanceDepthService.findWarranties({ vehicleId });
}
@Delete('warranties/:id')
@BookingStaff(FREIGHT_PERMS.maintenance.delete)
@ApiOperation({ summary: 'Delete warranty' })
async deleteWarranty(@Param('id') id: string) {
return this.maintenanceDepthService.deleteWarranty(id);
}
}

View File

@@ -2,14 +2,30 @@ 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 { 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 { WorkOrderRepository } from './work-order.repository';
import { PartRepository } from './part.repository';
import { WarrantyRepository } from './warranty.repository';
import { MaintenanceController } from './maintenance.controller';
@Module({
imports: [TypeOrmModule.forFeature([MaintenanceSchedule, MaintenanceCost])],
providers: [MaintenanceService, MaintenanceRepository],
imports: [
TypeOrmModule.forFeature([MaintenanceSchedule, MaintenanceCost, WorkOrder, Part, Warranty]),
],
providers: [
MaintenanceService,
MaintenanceDepthService,
MaintenanceRepository,
WorkOrderRepository,
PartRepository,
WarrantyRepository,
],
controllers: [MaintenanceController],
exports: [MaintenanceService],
exports: [MaintenanceService, MaintenanceDepthService],
})
export class MaintenanceModule {}

View File

@@ -0,0 +1,27 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { BaseRepository } from '@edr/api-common';
import { Repository } from 'typeorm';
import { Part } from './entities/part.entity';
@Injectable()
export class PartRepository extends BaseRepository<Part> {
constructor(
@InjectRepository(Part)
private readonly partRepository: Repository<Part>,
) {
super(partRepository);
}
async findFiltered(filters: { category?: string; lowStock?: boolean }) {
const qb = this.partRepository.createQueryBuilder('part');
if (filters.category) {
qb.andWhere('part.category = :category', { category: filters.category });
}
if (filters.lowStock) {
qb.andWhere('part.quantityInStock <= part.reorderLevel');
}
qb.orderBy('part.name', 'ASC');
return qb.getMany();
}
}

View File

@@ -0,0 +1,24 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { BaseRepository } from '@edr/api-common';
import { Repository, FindOptionsWhere } from 'typeorm';
import { Warranty } from './entities/warranty.entity';
@Injectable()
export class WarrantyRepository extends BaseRepository<Warranty> {
constructor(
@InjectRepository(Warranty)
private readonly warrantyRepository: Repository<Warranty>,
) {
super(warrantyRepository);
}
async findFiltered(filters: { vehicleId?: string }) {
const where: FindOptionsWhere<Warranty> = {};
if (filters.vehicleId) where.vehicleId = filters.vehicleId;
return this.warrantyRepository.find({
where,
order: { expiryDate: 'ASC' },
});
}
}

View File

@@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { BaseRepository } from '@edr/api-common';
import { Repository, FindOptionsWhere } from 'typeorm';
import { WorkOrder, WorkOrderStatus } from './entities/work-order.entity';
@Injectable()
export class WorkOrderRepository extends BaseRepository<WorkOrder> {
constructor(
@InjectRepository(WorkOrder)
private readonly workOrderRepository: Repository<WorkOrder>,
) {
super(workOrderRepository);
}
async findFiltered(filters: { vehicleId?: string; status?: WorkOrderStatus }) {
const where: FindOptionsWhere<WorkOrder> = {};
if (filters.vehicleId) where.vehicleId = filters.vehicleId;
if (filters.status) where.status = filters.status;
return this.workOrderRepository.find({
where,
order: { openedAt: 'DESC' },
});
}
}

View File

@@ -7,6 +7,11 @@ import {
import { BaseEntity } from "@edr/api-common";
@Entity({
// Table lives in the freight schema like every other freight entity. Without
// this the entity inherits the DataSource default schema (public), so TypeORM
// queries public.otp_verifications — which doesn't exist — and OTP verify
// (e.g. the contract-signature sudo gate) fails with a 500 QueryFailedError.
schema: "freight",
name: "otp_verifications",
})
export class OtpVerification extends BaseEntity{

View File

@@ -72,8 +72,14 @@ export class OtpService {
message: "OTP sent successfully",
};
} catch (error) {
console.log(error);
// 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(
`Failed to send OTP to ${target.email ?? target.phone}: ${
error instanceof Error ? error.message : String(error)
}`,
error instanceof Error ? error.stack : undefined,
);
throw new BadRequestException("Failed to send OTP");
}
}

View File

@@ -0,0 +1,193 @@
import {
IsUUID,
IsString,
IsDateString,
IsNumber,
IsInt,
IsOptional,
IsEnum,
IsBoolean,
} from 'class-validator';
import { VendorType } from '../entities/vendor.entity';
import { AcquisitionType, AcquisitionStatus } from '../entities/asset-acquisition.entity';
import { DisposalMethod } from '../entities/asset-disposal.entity';
export class CreateVendorDto {
@IsString()
name!: string;
@IsOptional()
@IsEnum(VendorType)
type?: VendorType;
@IsOptional()
@IsString()
contactPerson?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
email?: string;
@IsOptional()
@IsString()
address?: string;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class UpdateVendorDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsEnum(VendorType)
type?: VendorType;
@IsOptional()
@IsString()
contactPerson?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
email?: string;
@IsOptional()
@IsString()
address?: string;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class CreateAcquisitionDto {
@IsOptional()
@IsUUID()
vehicleId?: string;
@IsOptional()
@IsUUID()
vendorId?: string;
@IsEnum(AcquisitionType)
acquisitionType!: AcquisitionType;
@IsDateString()
acquisitionDate!: string;
@IsOptional()
@IsNumber()
cost?: number;
@IsOptional()
@IsInt()
usefulLifeMonths?: number;
@IsOptional()
@IsNumber()
salvageValue?: number;
@IsOptional()
@IsDateString()
leaseStart?: string;
@IsOptional()
@IsDateString()
leaseEnd?: string;
@IsOptional()
@IsNumber()
monthlyPayment?: number;
@IsOptional()
@IsEnum(AcquisitionStatus)
status?: AcquisitionStatus;
@IsOptional()
@IsString()
notes?: string;
}
export class UpdateAcquisitionDto {
@IsOptional()
@IsUUID()
vehicleId?: string;
@IsOptional()
@IsUUID()
vendorId?: string;
@IsOptional()
@IsEnum(AcquisitionType)
acquisitionType?: AcquisitionType;
@IsOptional()
@IsDateString()
acquisitionDate?: string;
@IsOptional()
@IsNumber()
cost?: number;
@IsOptional()
@IsInt()
usefulLifeMonths?: number;
@IsOptional()
@IsNumber()
salvageValue?: number;
@IsOptional()
@IsDateString()
leaseStart?: string;
@IsOptional()
@IsDateString()
leaseEnd?: string;
@IsOptional()
@IsNumber()
monthlyPayment?: number;
@IsOptional()
@IsEnum(AcquisitionStatus)
status?: AcquisitionStatus;
@IsOptional()
@IsString()
notes?: string;
}
export class CreateDisposalDto {
@IsUUID()
vehicleId!: string;
@IsDateString()
disposalDate!: string;
@IsEnum(DisposalMethod)
method!: DisposalMethod;
@IsOptional()
@IsNumber()
salePrice?: number;
@IsOptional()
@IsString()
buyer?: string;
@IsOptional()
@IsString()
notes?: string;
}

View File

@@ -0,0 +1,64 @@
import { BaseEntity } from '@edr/api-common';
import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
import { Vendor } from './vendor.entity';
export enum AcquisitionType {
PURCHASE = 'PURCHASE',
LEASE = 'LEASE',
RENTAL = 'RENTAL',
}
export enum AcquisitionStatus {
ACTIVE = 'ACTIVE',
LEASE_EXPIRING = 'LEASE_EXPIRING',
DISPOSED = 'DISPOSED',
}
@Entity({ name: 'asset_acquisitions', schema: 'freight' })
@Index(['vehicleId', 'acquisitionDate'])
export class AssetAcquisition extends BaseEntity {
@Column({ name: 'vehicle_id', type: 'uuid', nullable: true })
vehicleId?: string;
@ManyToOne(() => Vehicle, { eager: false, nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'vehicle_id' })
vehicle?: Vehicle;
@Column({ name: 'vendor_id', type: 'uuid', nullable: true })
vendorId?: string;
@ManyToOne(() => Vendor, { eager: false, nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'vendor_id' })
vendor?: Vendor;
@Column({ name: 'acquisition_type', type: 'varchar' })
acquisitionType!: AcquisitionType;
@Column({ name: 'acquisition_date', type: 'date' })
acquisitionDate!: string;
@Column({ name: 'cost', type: 'numeric', precision: 14, scale: 2, nullable: true })
cost?: number;
@Column({ name: 'useful_life_months', type: 'int', nullable: true })
usefulLifeMonths?: number;
@Column({ name: 'salvage_value', type: 'numeric', precision: 14, scale: 2, nullable: true })
salvageValue?: number;
@Column({ name: 'lease_start', type: 'date', nullable: true })
leaseStart?: string;
@Column({ name: 'lease_end', type: 'date', nullable: true })
leaseEnd?: string;
@Column({ name: 'monthly_payment', type: 'numeric', precision: 14, scale: 2, nullable: true })
monthlyPayment?: number;
@Column({ name: 'status', type: 'varchar', default: AcquisitionStatus.ACTIVE })
status!: AcquisitionStatus;
@Column({ name: 'notes', type: 'text', nullable: true })
notes?: string;
}

View File

@@ -0,0 +1,31 @@
import { BaseEntity } from '@edr/api-common';
import { Entity, Column, Index } from 'typeorm';
export enum DisposalMethod {
SALE = 'SALE',
SCRAP = 'SCRAP',
RETURN_LEASE = 'RETURN_LEASE',
TRADE_IN = 'TRADE_IN',
}
@Entity({ name: 'asset_disposals', schema: 'freight' })
@Index(['vehicleId', 'disposalDate'])
export class AssetDisposal extends BaseEntity {
@Column({ name: 'vehicle_id', type: 'uuid' })
vehicleId!: string;
@Column({ name: 'disposal_date', type: 'date' })
disposalDate!: string;
@Column({ name: 'method', type: 'varchar' })
method!: DisposalMethod;
@Column({ name: 'sale_price', type: 'numeric', precision: 14, scale: 2, nullable: true })
salePrice?: number;
@Column({ name: 'buyer', nullable: true })
buyer?: string;
@Column({ name: 'notes', type: 'text', nullable: true })
notes?: string;
}

View File

@@ -0,0 +1,34 @@
import { BaseEntity } from '@edr/api-common';
import { Entity, Column } from 'typeorm';
export enum VendorType {
DEALER = 'DEALER',
LEASING = 'LEASING',
PARTS = 'PARTS',
SERVICE = 'SERVICE',
OTHER = 'OTHER',
}
@Entity({ name: 'vendors', schema: 'freight' })
export class Vendor extends BaseEntity {
@Column({ name: 'name' })
name!: string;
@Column({ name: 'type', type: 'varchar', nullable: true })
type?: VendorType;
@Column({ name: 'contact_person', nullable: true })
contactPerson?: string;
@Column({ name: 'phone', nullable: true })
phone?: string;
@Column({ name: 'email', nullable: true })
email?: string;
@Column({ name: 'address', nullable: true })
address?: string;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
}

View File

@@ -0,0 +1,98 @@
import { Controller, Post, Get, Patch, Delete, Body, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { ProcurementService } from './procurement.service';
import {
CreateVendorDto,
UpdateVendorDto,
CreateAcquisitionDto,
UpdateAcquisitionDto,
CreateDisposalDto,
} from './dto/procurement.dto';
@ApiTags('Procurement & Asset Lifecycle')
@Controller('procurement')
export class ProcurementController {
constructor(private readonly procurementService: ProcurementService) {}
// ---- Vendors ----
@Post('vendors')
@ApiOperation({ summary: 'Create a vendor' })
async createVendor(@Body() dto: CreateVendorDto) {
return this.procurementService.createVendor(dto);
}
@Get('vendors')
@ApiOperation({ summary: 'List vendors' })
async listVendors() {
return this.procurementService.listVendors();
}
@Patch('vendors/:id')
@ApiOperation({ summary: 'Update a vendor' })
async updateVendor(@Param('id') id: string, @Body() dto: UpdateVendorDto) {
return this.procurementService.updateVendor(id, dto);
}
@Delete('vendors/:id')
@ApiOperation({ summary: 'Delete a vendor' })
async deleteVendor(@Param('id') id: string) {
return this.procurementService.deleteVendor(id);
}
// ---- Acquisitions ----
@Post('acquisitions')
@ApiOperation({ summary: 'Create an asset acquisition' })
async createAcquisition(@Body() dto: CreateAcquisitionDto) {
return this.procurementService.createAcquisition(dto);
}
@Get('acquisitions')
@ApiOperation({ summary: 'List asset acquisitions (optionally filtered by vehicleId)' })
async listAcquisitions(@Query('vehicleId') vehicleId?: string) {
return this.procurementService.listAcquisitions(vehicleId);
}
@Get('acquisitions/:id')
@ApiOperation({ summary: 'Get an asset acquisition by id' })
async getAcquisition(@Param('id') id: string) {
return this.procurementService.getAcquisition(id);
}
@Patch('acquisitions/:id')
@ApiOperation({ summary: 'Update an asset acquisition' })
async updateAcquisition(@Param('id') id: string, @Body() dto: UpdateAcquisitionDto) {
return this.procurementService.updateAcquisition(id, dto);
}
@Delete('acquisitions/:id')
@ApiOperation({ summary: 'Delete an asset acquisition' })
async deleteAcquisition(@Param('id') id: string) {
return this.procurementService.deleteAcquisition(id);
}
// ---- Disposals ----
@Post('disposals')
@ApiOperation({ summary: 'Create an asset disposal' })
async createDisposal(@Body() dto: CreateDisposalDto) {
return this.procurementService.createDisposal(dto);
}
@Get('disposals')
@ApiOperation({ summary: 'List asset disposals' })
async listDisposals() {
return this.procurementService.listDisposals();
}
@Delete('disposals/:id')
@ApiOperation({ summary: 'Delete an asset disposal' })
async deleteDisposal(@Param('id') id: string) {
return this.procurementService.deleteDisposal(id);
}
// ---- Lifecycle ----
@Get('lifecycle/:vehicleId')
@ApiOperation({ summary: 'Get asset lifecycle (acquisition, disposal, depreciation) for a vehicle' })
async lifecycle(@Param('vehicleId') vehicleId: string) {
return this.procurementService.lifecycle(vehicleId);
}
}

View File

@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Vendor } from './entities/vendor.entity';
import { AssetAcquisition } from './entities/asset-acquisition.entity';
import { AssetDisposal } from './entities/asset-disposal.entity';
import { ProcurementService } from './procurement.service';
import { ProcurementRepository } from './procurement.repository';
import { ProcurementController } from './procurement.controller';
@Module({
imports: [TypeOrmModule.forFeature([Vendor, AssetAcquisition, AssetDisposal])],
providers: [ProcurementService, ProcurementRepository],
controllers: [ProcurementController],
exports: [ProcurementService],
})
export class ProcurementModule {}

View File

@@ -0,0 +1,102 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { BaseRepository } from '@edr/api-common';
import { DeepPartial, Repository } from 'typeorm';
import { Vendor } from './entities/vendor.entity';
import { AssetAcquisition } from './entities/asset-acquisition.entity';
import { AssetDisposal } from './entities/asset-disposal.entity';
@Injectable()
export class ProcurementRepository extends BaseRepository<AssetAcquisition> {
constructor(
@InjectRepository(AssetAcquisition)
private readonly acquisitionRepository: Repository<AssetAcquisition>,
@InjectRepository(Vendor)
private readonly vendorRepository: Repository<Vendor>,
@InjectRepository(AssetDisposal)
private readonly disposalRepository: Repository<AssetDisposal>,
) {
super(acquisitionRepository);
}
// ---- Vendors ----
async createVendor(data: DeepPartial<Vendor>): Promise<Vendor> {
const vendor = this.vendorRepository.create(data);
return this.vendorRepository.save(vendor);
}
async findVendors(): Promise<Vendor[]> {
return this.vendorRepository.find({ order: { createdAt: 'DESC' } });
}
async updateVendor(id: string, data: DeepPartial<Vendor>): Promise<Vendor | null> {
await this.vendorRepository.update(id, data as never);
return this.vendorRepository.findOneBy({ id });
}
async softDeleteVendor(id: string): Promise<void> {
await this.vendorRepository.softDelete(id);
}
// ---- Acquisitions ----
async createAcquisition(data: DeepPartial<AssetAcquisition>): Promise<AssetAcquisition> {
const acquisition = this.acquisitionRepository.create(data);
return this.acquisitionRepository.save(acquisition);
}
async findAcquisitions(vehicleId?: string): Promise<AssetAcquisition[]> {
return this.acquisitionRepository.find({
where: vehicleId ? { vehicleId } : {},
relations: ['vehicle', 'vendor'],
order: { acquisitionDate: 'DESC' },
});
}
async findAcquisitionById(id: string): Promise<AssetAcquisition | null> {
return this.acquisitionRepository.findOne({
where: { id },
relations: ['vehicle', 'vendor'],
});
}
async updateAcquisition(
id: string,
data: DeepPartial<AssetAcquisition>,
): Promise<AssetAcquisition | null> {
await this.acquisitionRepository.update(id, data as never);
return this.findAcquisitionById(id);
}
async softDeleteAcquisition(id: string): Promise<void> {
await this.acquisitionRepository.softDelete(id);
}
async findLatestAcquisitionByVehicle(vehicleId: string): Promise<AssetAcquisition | null> {
return this.acquisitionRepository.findOne({
where: { vehicleId },
relations: ['vehicle', 'vendor'],
order: { acquisitionDate: 'DESC' },
});
}
// ---- Disposals ----
async createDisposal(data: DeepPartial<AssetDisposal>): Promise<AssetDisposal> {
const disposal = this.disposalRepository.create(data);
return this.disposalRepository.save(disposal);
}
async findDisposals(): Promise<AssetDisposal[]> {
return this.disposalRepository.find({ order: { disposalDate: 'DESC' } });
}
async softDeleteDisposal(id: string): Promise<void> {
await this.disposalRepository.softDelete(id);
}
async findLatestDisposalByVehicle(vehicleId: string): Promise<AssetDisposal | null> {
return this.disposalRepository.findOne({
where: { vehicleId },
order: { disposalDate: 'DESC' },
});
}
}

View File

@@ -0,0 +1,143 @@
import { Injectable } from '@nestjs/common';
import { ProcurementRepository } from './procurement.repository';
import { Vendor } from './entities/vendor.entity';
import { AssetAcquisition } from './entities/asset-acquisition.entity';
import { AssetDisposal } from './entities/asset-disposal.entity';
import {
CreateVendorDto,
UpdateVendorDto,
CreateAcquisitionDto,
UpdateAcquisitionDto,
CreateDisposalDto,
} from './dto/procurement.dto';
export interface DepreciationResult {
method: 'STRAIGHT_LINE';
cost: number;
salvageValue: number;
usefulLifeMonths: number;
monthsElapsed: number;
monthlyDepreciation: number;
bookValue: number;
}
export interface LifecycleResult {
vehicleId: string;
acquisition: AssetAcquisition | null;
disposal: AssetDisposal | null;
depreciation: DepreciationResult | null;
}
@Injectable()
export class ProcurementService {
constructor(private readonly procurementRepository: ProcurementRepository) {}
// ---- Vendors ----
async createVendor(dto: CreateVendorDto): Promise<Vendor> {
return this.procurementRepository.createVendor(dto);
}
async listVendors(): Promise<Vendor[]> {
return this.procurementRepository.findVendors();
}
async updateVendor(id: string, dto: UpdateVendorDto): Promise<Vendor | null> {
return this.procurementRepository.updateVendor(id, dto);
}
async deleteVendor(id: string): Promise<{ success: boolean }> {
await this.procurementRepository.softDeleteVendor(id);
return { success: true };
}
// ---- Acquisitions ----
async createAcquisition(dto: CreateAcquisitionDto): Promise<AssetAcquisition> {
return this.procurementRepository.createAcquisition(dto);
}
async listAcquisitions(vehicleId?: string): Promise<AssetAcquisition[]> {
return this.procurementRepository.findAcquisitions(vehicleId);
}
async getAcquisition(id: string): Promise<AssetAcquisition | null> {
return this.procurementRepository.findAcquisitionById(id);
}
async updateAcquisition(id: string, dto: UpdateAcquisitionDto): Promise<AssetAcquisition | null> {
return this.procurementRepository.updateAcquisition(id, dto);
}
async deleteAcquisition(id: string): Promise<{ success: boolean }> {
await this.procurementRepository.softDeleteAcquisition(id);
return { success: true };
}
// ---- Disposals ----
async createDisposal(dto: CreateDisposalDto): Promise<AssetDisposal> {
return this.procurementRepository.createDisposal(dto);
}
async listDisposals(): Promise<AssetDisposal[]> {
return this.procurementRepository.findDisposals();
}
async deleteDisposal(id: string): Promise<{ success: boolean }> {
await this.procurementRepository.softDeleteDisposal(id);
return { success: true };
}
// ---- Lifecycle ----
async lifecycle(vehicleId: string): Promise<LifecycleResult> {
const acquisition = await this.procurementRepository.findLatestAcquisitionByVehicle(vehicleId);
const disposal = await this.procurementRepository.findLatestDisposalByVehicle(vehicleId);
return {
vehicleId,
acquisition,
disposal,
depreciation: this.computeStraightLineDepreciation(acquisition),
};
}
/**
* Straight-line depreciation. Requires a cost and a positive useful life.
* monthlyDep = (cost - salvageValue) / usefulLifeMonths
* bookValue = cost - monthlyDep * monthsElapsedSinceAcquisition, floored at salvageValue.
*/
private computeStraightLineDepreciation(
acquisition: AssetAcquisition | null,
): DepreciationResult | null {
if (!acquisition) return null;
const cost = acquisition.cost != null ? Number(acquisition.cost) : null;
const usefulLifeMonths =
acquisition.usefulLifeMonths != null ? Number(acquisition.usefulLifeMonths) : null;
if (cost == null || usefulLifeMonths == null || usefulLifeMonths <= 0) {
return null;
}
const salvageValue = acquisition.salvageValue != null ? Number(acquisition.salvageValue) : 0;
const monthlyDepreciation = (cost - salvageValue) / usefulLifeMonths;
const acquiredAt = new Date(acquisition.acquisitionDate);
const now = new Date();
const monthsElapsed = Math.max(
0,
(now.getFullYear() - acquiredAt.getFullYear()) * 12 +
(now.getMonth() - acquiredAt.getMonth()),
);
const bookValue = Math.max(cost - monthlyDepreciation * monthsElapsed, salvageValue);
return {
method: 'STRAIGHT_LINE',
cost,
salvageValue,
usefulLifeMonths,
monthsElapsed,
monthlyDepreciation,
bookValue,
};
}
}

View File

@@ -21,7 +21,7 @@ export class PriorityConfigsController {
@ApiOperation({ summary: 'List priority configs' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
type: (query['type'] as 'WAGON' | 'CURRENCY') || undefined,
type: (query['type'] as 'WAGON' | 'CURRENCY' | 'CUSTOMS') || undefined,
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
page: query['page'] ? parseInt(query['page'], 10) : undefined,
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,

View File

@@ -2,9 +2,12 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
export class CreatePriorityConfigDto {
@ApiProperty({ description: 'Config type: WAGON or CURRENCY', enum: ['WAGON', 'CURRENCY'] })
@IsIn(['WAGON', 'CURRENCY'])
type!: 'WAGON' | 'CURRENCY';
@ApiProperty({
description: 'Config type: WAGON, CURRENCY, or CUSTOMS',
enum: ['WAGON', 'CURRENCY', 'CUSTOMS'],
})
@IsIn(['WAGON', 'CURRENCY', 'CUSTOMS'])
type!: 'WAGON' | 'CURRENCY' | 'CUSTOMS';
@ApiProperty({ description: 'Human-readable label', maxLength: 100 })
@IsString()
@@ -12,7 +15,8 @@ export class CreatePriorityConfigDto {
label!: string;
@ApiPropertyOptional({
description: 'Currency code (e.g., USD, ETB). Required for type=CURRENCY, must be null for type=WAGON',
description:
'Currency code (e.g., USD, ETB). Required for type=CURRENCY, must be null for type=WAGON and type=CUSTOMS',
maxLength: 5,
})
@IsOptional()

View File

@@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator';
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
export class CreateServiceTypeDto {
@ApiProperty({ description: 'Service type display name', maxLength: 255 })
@@ -32,17 +32,6 @@ export class CreateServiceTypeDto {
@IsBoolean()
includesCustoms?: boolean;
@ApiPropertyOptional({
description: 'Priority bonus points awarded when this service is used (015)',
default: 0,
maximum: 15,
})
@IsOptional()
@IsInt()
@Min(0)
@Max(15)
priorityBonusPoints?: number;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()

View File

@@ -6,7 +6,7 @@ import { Column, Entity, Index } from 'typeorm';
@Index(['currency', 'type'])
export class PriorityConfig extends BaseEntity {
@Column({ name: 'type', type: 'varchar', length: 20 })
type!: 'WAGON' | 'CURRENCY';
type!: 'WAGON' | 'CURRENCY' | 'CUSTOMS';
@Column({ name: 'label', type: 'varchar', length: 100 })
label!: string;

View File

@@ -27,9 +27,6 @@ export class ServiceType extends BaseEntity {
@Column({ name: 'includes_customs', type: 'boolean', default: false })
includesCustoms!: boolean;
@Column({ name: 'priority_bonus_points', type: 'int', default: 0 })
priorityBonusPoints!: number;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;

Some files were not shown because too many files have changed in this diff Show More