diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 2b3b855cb..6fdaab48b 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -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 diff --git a/apps/edr-freight-api/Dockerfile b/apps/edr-freight-api/Dockerfile index f9107ed23..d88029a80 100644 --- a/apps/edr-freight-api/Dockerfile +++ b/apps/edr-freight-api/Dockerfile @@ -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"] diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 457786cbe..636bb7e05 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -93,6 +93,7 @@ "@types/supertest": "^6.0.2", "@types/vorpal": "^1.12.8", "jest": "^29.7.0", + "socket.io-client": "^4.8.3", "supertest": "^7.0.0", "ts-jest": "^29.2.5", "ts-loader": "^9.5.1", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index d23d5bd2a..e10fb7e47 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -80,6 +80,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"; @@ -148,6 +152,10 @@ import { LoggerMiddleware } from "./logger.middleware"; DriversModule, FuelModule, MaintenanceModule, + ComplianceModule, + IncidentsModule, + ProcurementModule, + GpsTrackingModule, FirstMileModule, LastMileModule, InterchangeDocumentsModule, diff --git a/apps/edr-freight-api/src/migrations/1950000000000-AddVehicleCompliance.ts b/apps/edr-freight-api/src/migrations/1950000000000-AddVehicleCompliance.ts new file mode 100644 index 000000000..f83f0a236 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1950000000000-AddVehicleCompliance.ts @@ -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 { + // 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 { + 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; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1960000000000-AddIncidents.ts b/apps/edr-freight-api/src/migrations/1960000000000-AddIncidents.ts new file mode 100644 index 000000000..dbb2994c3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1960000000000-AddIncidents.ts @@ -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 { + 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 { + await queryRunner.query(`DROP TABLE IF EXISTS freight.incidents`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1970000000000-AddMaintenanceDepth.ts b/apps/edr-freight-api/src/migrations/1970000000000-AddMaintenanceDepth.ts new file mode 100644 index 000000000..87b52ceff --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1970000000000-AddMaintenanceDepth.ts @@ -0,0 +1,93 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddMaintenanceDepth1970000000000 implements MigrationInterface { + name = 'AddMaintenanceDepth1970000000000'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1980000000000-AddProcurement.ts b/apps/edr-freight-api/src/migrations/1980000000000-AddProcurement.ts new file mode 100644 index 000000000..6d4304aaf --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1980000000000-AddProcurement.ts @@ -0,0 +1,73 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddProcurement1980000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1990000000000-AddDoubleHandlingBasisAndMachinery.ts b/apps/edr-freight-api/src/migrations/1990000000000-AddDoubleHandlingBasisAndMachinery.ts new file mode 100644 index 000000000..be4ed060a --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1990000000000-AddDoubleHandlingBasisAndMachinery.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Double-handling fee support. warehouse_fee_rules.basis: how a + * DOUBLE_HANDLING_FEE rule is charged — PER_CONTAINER | PER_TON | PER_ITEM + * (null for the day-based fee types). The PER_TON / PER_ITEM quantity comes from + * the booking's cargo total (cargo_total_weight_vgm, expressed in the cargo's + * unit of measure), so no new booking column is needed. + */ +export class AddDoubleHandlingBasisAndMachinery1990000000000 implements MigrationInterface { + name = 'AddDoubleHandlingBasisAndMachinery1990000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.warehouse_fee_rules ADD COLUMN IF NOT EXISTS basis varchar(20)`, + ); + // machinery_units is not used (PER_ITEM reads cargo_total_weight_vgm); drop it + // if a prior version of this migration added it. + await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS machinery_units`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE freight.warehouse_fee_rules DROP COLUMN IF EXISTS basis`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1990000000000-AddVehiclePricePerKm.ts b/apps/edr-freight-api/src/migrations/1990000000000-AddVehiclePricePerKm.ts new file mode 100644 index 000000000..7767cd796 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1990000000000-AddVehiclePricePerKm.ts @@ -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 { + 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 { + await queryRunner.query(` + ALTER TABLE freight.vehicles + DROP COLUMN IF EXISTS price_per_km, + DROP COLUMN IF EXISTS currency + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2000000000000-AddGpsTracking.ts b/apps/edr-freight-api/src/migrations/2000000000000-AddGpsTracking.ts new file mode 100644 index 000000000..3d440c678 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2000000000000-AddGpsTracking.ts @@ -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 { + 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 { + await queryRunner.query(`DROP TABLE IF EXISTS freight.gps_positions`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.gps_devices`); + } +} diff --git a/apps/edr-freight-api/src/migrations/2000000000000-AddTruckDetentionTiming.ts b/apps/edr-freight-api/src/migrations/2000000000000-AddTruckDetentionTiming.ts new file mode 100644 index 000000000..ce7bfd326 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2000000000000-AddTruckDetentionTiming.ts @@ -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 { + 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 { + 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`); + } +} diff --git a/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts b/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts index 7c7805b28..d49cc304e 100644 --- a/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts +++ b/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts @@ -8,7 +8,11 @@ import { hashPassword } from "@tria-plc/api-common/utils/argon"; import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum"; import { DataSource, EntityManager, In, IsNull, Repository } from "typeorm"; -import { Employee, Organization, UserCredential } from "@tria-plc/iamapi-common"; +// Subpath imports (not the package root) so ts-jest can resolve them when this +// file lands in a spec's compile graph via the notification recipients chain. +import { Employee } from "@tria-plc/iamapi-common/entities/iam/organization-structure/employee.entity"; +import { Organization } from "@tria-plc/iamapi-common/entities/iam/organization-structure/organization.entity"; +import { UserCredential } from "@tria-plc/iamapi-common/entities/iam/user/user-credential.entity"; import { Role } from "@tria-plc/iamapi-common/entities/iam/user/role.entity"; import { UserRole } from "@tria-plc/iamapi-common/entities/iam/user/user-role.entity"; import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; @@ -40,6 +44,21 @@ export class BackofficeService { private readonly dataSource: DataSource, ) {} + /** + * IAM user ids of every current employee across all organizations — used by + * the notification recipients resolver's `allBackoffice` selector. + */ + async getAllCurrentEmployeeUserIds(): Promise { + const employees = await this.employeeRepository.find({ + where: { isCurrent: true }, + }); + return [ + ...new Set( + employees.map((e) => e.userId).filter((id): id is string => Boolean(id)), + ), + ]; + } + async createOrganizationUser( organizationId: string, dto: CreateOrganizationUserDto, diff --git a/apps/edr-freight-api/src/modules/billing/documents/styled-pdf.util.ts b/apps/edr-freight-api/src/modules/billing/documents/styled-pdf.util.ts index 8eb90172e..b4f168e4e 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/styled-pdf.util.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/styled-pdf.util.ts @@ -121,6 +121,146 @@ export function sealOp( return ops.join("\n"); } +/** Hard-truncate to `max` chars (no marker — keeps dense table cells tight). */ +export function clipText(value: string, max: number): string { + const t = String(value ?? ""); + return t.length > max ? t.slice(0, Math.max(1, max)) : t; +} + +/** Strip HTML tags → plain text, decoding the basic entities the doc builders emit. */ +export function htmlToText(html: string): string { + return String(html ?? "") + .replace(//gi, " ") + .replace(/<[^>]+>/g, " ") + .replace(/&/gi, "&") + .replace(/</gi, "<") + .replace(/>/gi, ">") + .replace(/"/gi, '"') + .replace(/'/g, "'") + .replace(/ /gi, " ") + .replace(/[^\x20-\x7e]/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +/** + * Parse a "summary tiles + one + notice + signature lines" document (the + * marshalling / load-list layout the train-scheduling builders emit) and draw it as a + * styled PDF grid. Used as the Chromium-less fallback so the manifest reads as a real + * document, not a flat text dump. Switches to landscape when the table is wide. + */ +export function buildTabularFallbackPdf(html: string): Buffer { + const pick = (re: RegExp) => html.match(re)?.[1]; + const title = htmlToText(pick(/]*>([\s\S]*?)<\/h1>/i) ?? "Document"); + const subtitle = htmlToText(pick(/class="subtitle"[^>]*>([\s\S]*?)<\/div>/i) ?? ""); + const metaRef = htmlToText(pick(/class="meta"[\s\S]*?([\s\S]*?)<\/strong>/i) ?? ""); + const generated = htmlToText(pick(/Generated:\s*([^<]+)/i) ?? ""); + + const tiles: Array<[string, string]> = []; + for (const m of html.matchAll( + /class="tile"[^>]*>\s*([\s\S]*?)<\/span>\s*([\s\S]*?)<\/strong>/gi, + )) { + tiles.push([htmlToText(m[1]), htmlToText(m[2])]); + } + + const thead = pick(/([\s\S]*?)<\/thead>/i) ?? ""; + const headers = [...thead.matchAll(/]*>([\s\S]*?)<\/th>/gi)].map((m) => htmlToText(m[1])); + const tbody = pick(/([\s\S]*?)<\/tbody>/i) ?? ""; + const rows: string[][] = [...tbody.matchAll(/]*>([\s\S]*?)<\/tr>/gi)].map((tr) => + [...tr[1].matchAll(/]*>([\s\S]*?)<\/td>/gi)].map((td) => htmlToText(td[1])), + ); + const notice = htmlToText(pick(/class="notice"[^>]*>([\s\S]*?)<\/div>/i) ?? ""); + const parsedSigs = [...html.matchAll(/class="line"[^>]*>([\s\S]*?)<\/div>/gi)] + .map((m) => htmlToText(m[1])) + .filter(Boolean); + const signatures = parsedSigs.length ? parsedSigs : ["Prepared / date", "Check / date", "Authorization / date"]; + + const landscape = headers.length > 7; + const page = landscape ? PageSize.landscape : PageSize.portrait; + const M = 32; + const contentW = page.width - M * 2; + const right = page.width - M; + const ops: string[] = []; + + // Header + ops.push(lineOp(M, page.height - 28, right, page.height - 28, PdfColor.teal, 2.4)); + ops.push(textOp("ETHIO-DJIBOUTI RAILWAY S.C.", M, page.height - 44, 8.5, "F2", PdfColor.gray)); + ops.push(textOp(clipText(title, landscape ? 82 : 52), M, page.height - 68, 19, "F2", PdfColor.dark)); + if (subtitle) ops.push(textOp(clipText(subtitle, 96), M, page.height - 82, 9, "F1", PdfColor.gray)); + if (metaRef) { + ops.push(textOpRight("TRAIN / SCHEDULE", right, page.height - 42, 7.5, "F2", PdfColor.gray)); + ops.push(textOpRight(clipText(metaRef, 28), right, page.height - 58, 12, "F2", PdfColor.dark)); + } + if (generated) { + ops.push(textOpRight(clipText(`Generated ${generated}`, 40), right, page.height - 72, 8, "F1", PdfColor.gray)); + } + ops.push(lineOp(M, page.height - 92, right, page.height - 92, PdfColor.line, 1)); + + // Summary tiles + let y = page.height - 100; + if (tiles.length) { + const cols = landscape ? 6 : 4; + const tileW = contentW / cols; + const tileH = 32; + tiles.forEach(([label, value], i) => { + const col = i % cols; + if (col === 0 && i > 0) y -= tileH; + const x = M + col * tileW; + ops.push(rectOp(x, y - tileH + 4, tileW - 4, tileH - 4, PdfColor.shade, PdfColor.line, 0.5)); + ops.push(textOp(clipText(label.toUpperCase(), Math.floor((tileW - 12) / 3.6)), x + 6, y - 8, 6.5, "F1", PdfColor.gray)); + ops.push(textOp(clipText(value, Math.floor((tileW - 12) / 4.4)), x + 6, y - 20, 9, "F2", PdfColor.dark)); + }); + y -= tileH + 12; + } + + // Table + if (headers.length) { + const colW = contentW / headers.length; + const headerH = 16; + const rowH = 14; + const cellChars = Math.max(4, Math.floor(colW / 3.9)); + ops.push(rectOp(M, y - headerH, contentW, headerH, PdfColor.tint, PdfColor.line, 0.6)); + headers.forEach((h, c) => + ops.push(textOp(clipText(h, cellChars), M + c * colW + 4, y - 11, 7, "F2", PdfColor.teal)), + ); + y -= headerH; + + let shown = 0; + for (const row of rows) { + if (y < 96) break; + ops.push(rectOp(M, y - rowH, contentW, rowH, "1 1 1", PdfColor.line, 0.4)); + headers.forEach((_h, c) => { + if (c > 0) ops.push(lineOp(M + c * colW, y - rowH, M + c * colW, y, PdfColor.line, 0.3)); + const cell = row[c] ?? ""; + if (cell) ops.push(textOp(clipText(cell, cellChars), M + c * colW + 4, y - 10, 6.8, "F1", PdfColor.dark)); + }); + y -= rowH; + shown += 1; + } + if (shown < rows.length) { + ops.push(textOp(`... ${rows.length - shown} more row(s) not shown`, M, y - 10, 7, "F1", PdfColor.gray)); + } + } + + // Notice (verification clause) + if (notice) { + ops.push(lineOp(M, 78, M, 54, PdfColor.teal, 2)); + wrapText(notice, landscape ? 155 : 104) + .slice(0, 2) + .forEach((ln, i) => ops.push(textOp(ln, M + 8, 72 - i * 11, 7.5, "F1", PdfColor.gray))); + } + + // Signatures + const sigW = contentW / signatures.length; + signatures.forEach((s, i) => { + const x = M + i * sigW; + ops.push(lineOp(x, 40, x + sigW - 18, 40, PdfColor.dark, 0.7)); + ops.push(textOp(clipText(s, Math.floor((sigW - 18) / 3.6)), x, 30, 7, "F1", PdfColor.gray)); + }); + + return assembleSinglePagePdf(ops, page); +} + /** Greedy word-wrap to a maximum character width. */ export function wrapText(text: string, maxChars: number): string[] { const out: string[] = []; @@ -141,13 +281,22 @@ export function wrapText(text: string, maxChars: number): string[] { return out.length ? out : [""]; } -/** Assemble a single-page A4 PDF from content-stream ops (Helvetica fonts). */ -export function assembleSinglePagePdf(ops: string[]): Buffer { +/** A4 page sizes in PDF points. */ +export const PageSize = { + portrait: { width: 595, height: 842 }, + landscape: { width: 842, height: 595 }, +} as const; + +/** Assemble a single-page PDF from content-stream ops (Helvetica fonts). Defaults to A4 portrait. */ +export function assembleSinglePagePdf( + ops: string[], + page: { width: number; height: number } = PageSize.portrait, +): Buffer { const stream = ops.join("\n"); const objects = [ "<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", - "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>", + `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${page.width} ${page.height}] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>`, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>", `<< /Length ${Buffer.byteLength(stream, "latin1")} >>\nstream\n${stream}\nendstream`, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts new file mode 100644 index 000000000..a4546e301 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts @@ -0,0 +1,307 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { + NotificationAudience, + NotificationType, + NotifyInput, +} from '@edr/types'; + +import { Booking } from './entities/booking.entity'; +import { NotificationsService } from '../notifications/notifications.service'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; + +/** + * Customer + staff notifications for the booking lifecycle: review, clearance + * and operation flow. Every customer event fans out over SMS + email (direct) + * and a persisted in-app notification deep-linking to the booking detail page; + * staff events land in the backoffice inbox. All sends are fire-and-forget and + * never throw — a notification failure must not break a booking transition. + * + * NOTE: the batch/payment-window notifications (pay-now, allocated, expired, + * displaced) are handled separately by {@link BookingNotifierService} in + * train-scheduling. + */ +@Injectable() +export class BookingLifecycleNotifierService { + private readonly logger = new Logger(BookingLifecycleNotifierService.name); + + constructor( + private readonly notifications: NotificationsService, + private readonly inbox: NotificationInboxService, + ) {} + + private ref(b: Booking): string { + return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`; + } + + /** Send SMS + email to the booking's company contact; log-only on failure. */ + private async notifyContact( + b: Booking, + message: string, + logLabel: string, + ): Promise { + this.logger.log(`${logLabel} — ${this.ref(b)}`); + const phone = b.company?.contactPersonPhone ?? b.company?.phone ?? null; + const email = b.company?.email ?? b.company?.generalManagerEmail ?? null; + + if (phone) { + try { + await this.notifications.directSend('sms', phone, message); + } catch (err) { + this.logger.warn(`SMS failed for ${this.ref(b)}: ${(err as Error).message}`); + } + } + if (email) { + try { + await this.notifications.directSend('email', email, message); + } catch (err) { + this.logger.warn(`Email failed for ${this.ref(b)}: ${(err as Error).message}`); + } + } + if (!phone && !email) { + this.logger.warn(`No contact on file for ${this.ref(b)} — notification not sent`); + } + } + + /** Persist + push an in-app item to all portal users of the booking's company. */ + private inApp( + b: Booking, + title: string, + body: string, + overrides: Partial = {}, + ): void { + if (!b.companyId) return; // government/unlinked bookings have no portal users + void this.inbox.notify({ + recipients: { companyId: b.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.BOOKING_STATUS, + title, + body, + link: `/bookings/${b.id}`, + data: { bookingId: b.id, reference: b.reference }, + ...overrides, + }); + } + + /** Persist + push an in-app item to every backoffice staff user. */ + private inAppStaff( + b: Booking, + title: string, + body: string, + overrides: Partial = {}, + ): void { + void this.inbox.notify({ + recipients: { allBackoffice: true }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.REQUEST_SUBMITTED, + title, + body, + link: `/dashboard/booking-requests/${b.id}`, + data: { bookingId: b.id, reference: b.reference }, + ...overrides, + }); + } + + // ── Customer-facing lifecycle events ─────────────────────────────────────── + + /** Line staff accepted intake → booking is under approval. */ + accepted(b: Booking): void { + const msg = + `Your booking ${b.reference} has been accepted and is now under approval. ` + + `We will notify you once it is approved.`; + void this.notifyContact(b, msg, 'ACCEPTED'); + this.inApp(b, 'Booking accepted', msg); + } + + /** All approval steps complete → contract generated, ready for customer to sign. */ + approved(b: Booking): void { + const msg = + `Your booking ${b.reference} has been approved. ` + + `Please review and sign your contract from the portal.`; + void this.notifyContact(b, msg, 'APPROVED'); + this.inApp(b, 'Booking approved', msg); + } + + /** Staff rejected the booking (intake or approval step). */ + rejected(b: Booking, reason: string): void { + const msg = + `Your booking ${b.reference} was rejected. Reason: ${reason}. ` + + `Please contact us for details.`; + void this.notifyContact(b, msg, 'REJECTED'); + this.inApp(b, 'Booking rejected', msg); + } + + /** Staff requested changes before approval. */ + changesRequested(b: Booking, note: string): void { + const msg = + `Changes were requested on your booking ${b.reference}: ${note}. ` + + `Please update and resubmit from the portal.`; + void this.notifyContact(b, msg, 'CHANGES REQUESTED'); + this.inApp(b, 'Booking changes requested', msg); + } + + /** A clearance document was queried and needs the customer to re-upload. */ + documentQueried(b: Booking, fileKey: string, note: string): void { + const msg = + `A clearance document on booking ${b.reference} needs attention: "${fileKey}". ` + + `${note}. Please re-upload from the portal.`; + void this.notifyContact(b, msg, 'DOCUMENT QUERIED'); + this.inApp(b, 'Document queried', msg, { + type: NotificationType.DOCUMENT_ACTION, + }); + } + + /** Clearance finalized → customer can proceed to request operation. */ + clearanceReady(b: Booking): void { + const msg = + `Clearance for booking ${b.reference} is complete. ` + + `You can now proceed to request operation from the portal.`; + void this.notifyContact(b, msg, 'CLEARANCE READY'); + this.inApp(b, 'Clearance complete', msg, { + type: NotificationType.CLEARANCE_DECISION, + }); + } + + /** Operations returned the operation request for changes. */ + operationChangesRequested(b: Booking, note: string): void { + const msg = + `Your operation request for booking ${b.reference} needs changes: ${note}. ` + + `Please update and resubmit from the portal.`; + void this.notifyContact(b, msg, 'OPERATION CHANGES REQUESTED'); + this.inApp(b, 'Operation request needs changes', msg); + } + + /** Operation accepted → invoice ready; await payment / booking window. */ + operationAccepted(b: Booking): void { + const msg = + `Your operation request for booking ${b.reference} has been accepted. ` + + `An invoice has been prepared — watch for the payment window to secure your slot.`; + void this.notifyContact(b, msg, 'OPERATION ACCEPTED'); + this.inApp(b, 'Operation request accepted', msg); + } + + /** Shipment started → in transit. */ + inTransit(b: Booking): void { + const msg = `Your shipment for booking ${b.reference} is now in transit.`; + void this.notifyContact(b, msg, 'IN TRANSIT'); + this.inApp(b, 'Shipment in transit', msg); + } + + /** Shipment delivered → completed. */ + completed(b: Booking): void { + const msg = `Your shipment for booking ${b.reference} has been delivered. Thank you.`; + void this.notifyContact(b, msg, 'COMPLETED'); + this.inApp(b, 'Shipment delivered', msg); + } + + /** Booking cancelled. */ + cancelled(b: Booking, reason: string): void { + const msg = `Your booking ${b.reference} has been cancelled. Reason: ${reason}.`; + void this.notifyContact(b, msg, 'CANCELLED'); + this.inApp(b, 'Booking cancelled', msg); + } + + // ── Clearance milestones needing customer action ────────────────────────── + + /** GL advised duty & tax — the customer must pay and upload the slip. */ + dutyAdvised(b: Booking, amount: number, currency: string): void { + const msg = + `Duty & tax of ${amount} ${currency} has been advised for booking ${b.reference}. ` + + `Please pay and upload the payment slip from the portal.`; + void this.notifyContact(b, msg, 'DUTY ADVISED'); + this.inApp(b, 'Duty & tax advised', msg, { + type: NotificationType.INVOICE_ISSUED, + }); + } + + /** GL advised the post-arrival additional duty round (import). */ + secondDutyAdvised(b: Booking, amount: number, currency: string): void { + const msg = + `Additional duty & tax of ${amount} ${currency} has been advised for booking ${b.reference}. ` + + `Please pay and upload the payment slip from the portal.`; + void this.notifyContact(b, msg, 'SECOND DUTY ADVISED'); + this.inApp(b, 'Additional duty & tax advised', msg, { + type: NotificationType.INVOICE_ISSUED, + }); + } + + /** GL raised the final (post-offload) invoice — customer pays + uploads slip. */ + finalInvoiceCreated(b: Booking, amount: number, currency: string): void { + const msg = + `A final invoice of ${amount} ${currency} has been issued for booking ${b.reference}. ` + + `Please pay and upload the payment slip from the portal.`; + void this.notifyContact(b, msg, 'FINAL INVOICE'); + this.inApp(b, 'Final invoice issued', msg, { + type: NotificationType.INVOICE_ISSUED, + }); + } + + /** GL confirmed the final-invoice payment slip. */ + finalInvoicePaid(b: Booking): void { + const msg = `Your final invoice payment for booking ${b.reference} has been confirmed. Thank you.`; + void this.notifyContact(b, msg, 'FINAL INVOICE PAID'); + this.inApp(b, 'Final invoice paid', msg, { + type: NotificationType.INVOICE_ISSUED, + }); + } + + // ── Staff-facing (backoffice inbox) ──────────────────────────────────────── + + /** Customer submitted a booking for review. */ + submittedToStaff(b: Booking): void { + this.inAppStaff( + b, + 'New booking submitted', + `Booking ${this.ref(b)} was submitted and is awaiting intake review.`, + ); + } + + /** Customer signed the booking contract. */ + customerSignedToStaff(b: Booking): void { + this.inAppStaff( + b, + 'Customer signed booking contract', + `The contract for booking ${this.ref(b)} was signed by the customer.`, + ); + } + + /** Customer requested operation (picked a shipment day). */ + operationRequestedToStaff(b: Booking): void { + this.inAppStaff( + b, + 'Operation requested', + `Booking ${this.ref(b)} requested operation — review capacity, documents and route.`, + ); + } + + /** Customer uploaded clearance documents — review is next. */ + clearanceDocsUploadedToStaff(b: Booking): void { + this.inAppStaff( + b, + 'Clearance documents uploaded', + `Customer uploaded clearance documents for booking ${this.ref(b)} — review them in the clearance queue.`, + { + type: NotificationType.CLEARANCE_REVIEW, + link: `/dashboard/bookings/${b.id}/clearance`, + }, + ); + } + + /** Customer uploaded a duty/tax payment slip — GL verifies it. */ + dutySlipUploadedToStaff(b: Booking, round: 'first' | 'second' | 'final'): void { + const label = + round === 'final' + ? 'final invoice' + : round === 'second' + ? 'additional duty & tax' + : 'duty & tax'; + this.inAppStaff( + b, + 'Payment slip uploaded', + `Customer uploaded the ${label} payment slip for booking ${this.ref(b)}.`, + { + type: NotificationType.PAYMENT_RECEIVED, + link: `/dashboard/bookings/${b.id}/clearance`, + }, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts index 3c535f450..607a0d7a4 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts @@ -30,14 +30,32 @@ describe('BookingTransitionService — acceptIntake validity window', () => { ruleEngineService as never, {} as never, // pricingService {} as never, // contractService - {} as never, // invoiceService {} as never, // filesService {} as never, // fileUploadSettingsService {} as never, // bookingBatchService bookingsService as never, { isPhasedGeneralCustomsBooking: () => false } as never, - {} as never, + {} as never, // workflowService + {} as never, // invoiceService { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, + { + accepted: jest.fn(), + approved: jest.fn(), + rejected: jest.fn(), + changesRequested: jest.fn(), + documentQueried: jest.fn(), + clearanceReady: jest.fn(), + operationChangesRequested: jest.fn(), + operationAccepted: jest.fn(), + inTransit: jest.fn(), + completed: jest.fn(), + cancelled: jest.fn(), + submittedToStaff: jest.fn(), + customerSignedToStaff: jest.fn(), + operationRequestedToStaff: jest.fn(), + clearanceDocsUploadedToStaff: jest.fn(), + dutySlipUploadedToStaff: jest.fn(), + } as never, // notifier ); return { service, bookingsRepository, ruleEngineService }; } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts index 9f9aa5713..72c83e136 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts @@ -41,14 +41,32 @@ describe('BookingTransitionService — finalizeClearance gate', () => { {} as never, // ruleEngineService {} as never, // pricingService {} as never, // contractService - {} as never, // invoiceService filesService as never, fileUploadSettingsService as never, {} as never, // bookingBatchService bookingsService as never, { isPhasedGeneralCustomsBooking: () => false } as never, - {} as never, + {} as never, // workflowService + {} as never, // invoiceService { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, + { + accepted: jest.fn(), + approved: jest.fn(), + rejected: jest.fn(), + changesRequested: jest.fn(), + documentQueried: jest.fn(), + clearanceReady: jest.fn(), + operationChangesRequested: jest.fn(), + operationAccepted: jest.fn(), + inTransit: jest.fn(), + completed: jest.fn(), + cancelled: jest.fn(), + submittedToStaff: jest.fn(), + customerSignedToStaff: jest.fn(), + operationRequestedToStaff: jest.fn(), + clearanceDocsUploadedToStaff: jest.fn(), + dutySlipUploadedToStaff: jest.fn(), + } as never, // notifier ); return { service, bookingsRepository }; } @@ -126,14 +144,32 @@ describe('BookingTransitionService — finalizeClearance customs output gate', ( {} as never, {} as never, {} as never, - {} as never, // invoiceService filesService as never, fileUploadSettingsService as never, - {} as never, + {} as never, // bookingBatchService bookingsService as never, { isPhasedGeneralCustomsBooking: () => false } as never, - {} as never, + {} as never, // workflowService + {} as never, // invoiceService { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, + { + accepted: jest.fn(), + approved: jest.fn(), + rejected: jest.fn(), + changesRequested: jest.fn(), + documentQueried: jest.fn(), + clearanceReady: jest.fn(), + operationChangesRequested: jest.fn(), + operationAccepted: jest.fn(), + inTransit: jest.fn(), + completed: jest.fn(), + cancelled: jest.fn(), + submittedToStaff: jest.fn(), + customerSignedToStaff: jest.fn(), + operationRequestedToStaff: jest.fn(), + clearanceDocsUploadedToStaff: jest.fn(), + dutySlipUploadedToStaff: jest.fn(), + } as never, // notifier ); return { service, bookingsRepository }; } @@ -197,14 +233,32 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields {} as never, {} as never, {} as never, - {} as never, // invoiceService filesService as never, fileUploadSettingsService as never, - {} as never, + {} as never, // bookingBatchService bookingsService as never, { isPhasedGeneralCustomsBooking: () => false } as never, - {} as never, + {} as never, // workflowService + {} as never, // invoiceService { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, + { + accepted: jest.fn(), + approved: jest.fn(), + rejected: jest.fn(), + changesRequested: jest.fn(), + documentQueried: jest.fn(), + clearanceReady: jest.fn(), + operationChangesRequested: jest.fn(), + operationAccepted: jest.fn(), + inTransit: jest.fn(), + completed: jest.fn(), + cancelled: jest.fn(), + submittedToStaff: jest.fn(), + customerSignedToStaff: jest.fn(), + operationRequestedToStaff: jest.fn(), + clearanceDocsUploadedToStaff: jest.fn(), + dutySlipUploadedToStaff: jest.fn(), + } as never, // notifier ); return { service, bookingsRepository, filesService }; } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts index ea3618a08..9201e4fa9 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts @@ -3,14 +3,17 @@ import { BookingTransitionService } from './booking-transition.service'; /** * Operation-request review for general-contract drawdown orders: - * - ACCEPT a train order → FULLY_EXECUTED and enqueued into the batch pool. - * - ACCEPT a road order → ROAD_DISPATCH_PENDING, NOT enqueued. + * - ACCEPT a train order → FULLY_EXECUTED with the invoice ensured; import/ + * domestic bookings wait for their booking-day window cycle (no immediate + * batch enqueue at accept time). + * - ACCEPT a road order → ROAD_DISPATCH_PENDING, never enters the train batch. * - REQUEST_CHANGES requires a note → OPERATION_CHANGES_REQUESTED. */ describe('BookingTransitionService — operation review', () => { function makeService(serviceTypeCode: string) { const booking = { id: 'b-1', + reference: 'BKG-1', status: 'OPERATION_REQUEST_PENDING', originYardId: 'o-1', destinationYardId: 'd-1', @@ -26,6 +29,14 @@ describe('BookingTransitionService — operation review', () => { }; const bookingBatchService = { enqueueRouteDayProcessing: jest.fn(), + pickExportSchedule: jest.fn(), + acceptExportBooking: jest.fn(), + }; + const invoiceService = { + ensureInvoiceForBooking: jest + .fn() + .mockResolvedValue({ id: 'inv-1', invoiceNumber: 'INV-0001' }), + updateStatus: jest.fn().mockResolvedValue(undefined), }; const service = new BookingTransitionService( @@ -33,37 +44,60 @@ describe('BookingTransitionService — operation review', () => { {} as never, // ruleEngineService {} as never, // pricingService {} as never, // contractService - {} as never, // invoiceService {} as never, // filesService {} as never, // fileUploadSettingsService bookingBatchService as never, bookingsService as never, { isPhasedGeneralCustomsBooking: () => false } as never, - {} as never, + {} as never, // workflowService + invoiceService as never, { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, + { + accepted: jest.fn(), + approved: jest.fn(), + rejected: jest.fn(), + changesRequested: jest.fn(), + documentQueried: jest.fn(), + clearanceReady: jest.fn(), + operationChangesRequested: jest.fn(), + operationAccepted: jest.fn(), + inTransit: jest.fn(), + completed: jest.fn(), + cancelled: jest.fn(), + submittedToStaff: jest.fn(), + customerSignedToStaff: jest.fn(), + operationRequestedToStaff: jest.fn(), + clearanceDocsUploadedToStaff: jest.fn(), + dutySlipUploadedToStaff: jest.fn(), + } as never, // notifier ); - return { service, bookingsRepository, bookingBatchService }; + return { service, bookingsRepository, bookingBatchService, invoiceService }; } - it('ACCEPT of a train order → FULLY_EXECUTED and enqueues the batch pool', async () => { - const { service, bookingsRepository, bookingBatchService } = + it('ACCEPT of a train order → FULLY_EXECUTED, invoice ensured, batch waits for window cycle', async () => { + const { service, bookingsRepository, bookingBatchService, invoiceService } = makeService('RAIL_CONTAINER'); await service.reviewOperationRequest('b-1', 'ACCEPT', 'staff-1'); expect(bookingsRepository.update).toHaveBeenCalledWith( 'b-1', expect.objectContaining({ status: 'FULLY_EXECUTED' }), ); - expect(bookingBatchService.enqueueRouteDayProcessing).toHaveBeenCalledTimes(1); + expect(invoiceService.ensureInvoiceForBooking).toHaveBeenCalledTimes(1); + // Import/domestic train bookings are batched by the window cycle later — + // never enqueued directly at accept time. + expect(bookingBatchService.enqueueRouteDayProcessing).not.toHaveBeenCalled(); + expect(bookingBatchService.acceptExportBooking).not.toHaveBeenCalled(); }); - it('ACCEPT of a road order → ROAD_DISPATCH_PENDING and does NOT enqueue', async () => { - const { service, bookingsRepository, bookingBatchService } = + it('ACCEPT of a road order → ROAD_DISPATCH_PENDING and does NOT enter the batch', async () => { + const { service, bookingsRepository, bookingBatchService, invoiceService } = makeService('ROAD_CONTAINER'); await service.reviewOperationRequest('b-1', 'ACCEPT', 'staff-1'); expect(bookingsRepository.update).toHaveBeenCalledWith( 'b-1', expect.objectContaining({ status: 'ROAD_DISPATCH_PENDING' }), ); + expect(invoiceService.ensureInvoiceForBooking).toHaveBeenCalledTimes(1); expect(bookingBatchService.enqueueRouteDayProcessing).not.toHaveBeenCalled(); }); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 06edbf04e..b5c277073 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -4,6 +4,7 @@ import { Inject, Injectable, Logger, + Optional, } from "@nestjs/common"; import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; @@ -15,6 +16,7 @@ import { RuleEngineService } from '../rule-engine/rule-engine.service'; import { FilesService } from '../files/files.service'; import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; import { BookingContractService } from './booking-contract.service'; +import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service'; import { BookingPricingService } from './booking-pricing.service'; import { ContainerValidationService } from './container-validation.service'; import { BookingsRepository } from './bookings.repository'; @@ -26,6 +28,7 @@ import { PriceLineItemDto } from './dto/generate-price-response.dto'; import { Booking } from './entities/booking.entity'; import { BookingsService } from './bookings.service'; import { BookingClearanceService } from '../contracts/booking-clearance.service'; +import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; import { ClearanceWorkflowService } from '../contracts/clearance-workflow.service'; import { ContractDocPhase } from '@edr/types'; @@ -53,7 +56,8 @@ export class BookingTransitionService { private readonly workflowService: ClearanceWorkflowService, private readonly invoiceService: BookingInvoiceService, private readonly containerValidationService: ContainerValidationService, - + private readonly notifier: BookingLifecycleNotifierService, + @Optional() private readonly milestoneService?: ClearanceMilestoneService, ) {} private isPhasedGeneralCustoms(booking: Booking): boolean { @@ -124,6 +128,9 @@ export class BookingTransitionService { const finalBooking = await this.bookingsService.runConsolidationOnSubmit( updated!.id, ); + if (finalBooking.status === "SUBMITTED") { + this.notifier.submittedToStaff(finalBooking); + } return { bookingId: finalBooking.id, status: finalBooking.status, @@ -204,6 +211,9 @@ export class BookingTransitionService { const finalBooking = await this.bookingsService.runConsolidationOnSubmit( updated!.id, ); + if (finalBooking.status === "SUBMITTED") { + this.notifier.submittedToStaff(finalBooking); + } return { bookingId: finalBooking.id, status: finalBooking.status, @@ -233,7 +243,9 @@ export class BookingTransitionService { const updated = await this.bookingsRepository.update(bookingId, { status: "CHANGES_REQUESTED", } as never); - return this.bookingsService.findById(updated!.id); + const fresh = await this.bookingsService.findById(updated!.id); + this.notifier.changesRequested(fresh, note); + return fresh; } /** Auto-create booking approval steps from system rules when none exist yet. */ @@ -284,7 +296,9 @@ export class BookingTransitionService { contractValidFrom: validFrom, contractValidUntil: validUntil, } as never); - return this.bookingsService.findById(updated!.id); + const fresh = await this.bookingsService.findById(updated!.id); + this.notifier.accepted(fresh); + return fresh; } async staffReject( @@ -305,7 +319,9 @@ export class BookingTransitionService { const updated = await this.bookingsRepository.update(bookingId, { status: "REJECTED", } as never); - return this.bookingsService.findById(updated!.id); + const fresh = await this.bookingsService.findById(updated!.id); + this.notifier.rejected(fresh, reason); + return fresh; } async approveStep( @@ -394,7 +410,9 @@ export class BookingTransitionService { if (allDone) { const generated = await this.contractService.generateContract(bookingId); - return this.bookingsService.findById(generated.id); + const fresh = await this.bookingsService.findById(generated.id); + this.notifier.approved(fresh); + return fresh; } return this.bookingsService.findById(bookingId); @@ -435,7 +453,9 @@ export class BookingTransitionService { const updated = await this.bookingsRepository.update(bookingId, { status: "REJECTED", } as never); - return this.bookingsService.findById(updated!.id); + const fresh = await this.bookingsService.findById(updated!.id); + this.notifier.rejected(fresh, reason); + return fresh; } async customerSign(bookingId: string): Promise { @@ -446,7 +466,9 @@ export class BookingTransitionService { status: "SIGNED_CUSTOMER", customerSignedAt: new Date(), } as never); - return this.bookingsService.findById(updated!.id); + const fresh = await this.bookingsService.findById(updated!.id); + this.notifier.customerSignedToStaff(fresh); + return fresh; } async startTransit(bookingId: string): Promise { @@ -456,7 +478,9 @@ export class BookingTransitionService { const updated = await this.bookingsRepository.update(bookingId, { status: "IN_TRANSIT", } as never); - return this.bookingsService.findById(updated!.id); + const fresh = await this.bookingsService.findById(updated!.id); + this.notifier.inTransit(fresh); + return fresh; } async complete(bookingId: string): Promise { @@ -467,7 +491,28 @@ export class BookingTransitionService { status: "COMPLETED", endDate: new Date(), } as never); - return this.bookingsService.findById(updated!.id); + const fresh = await this.bookingsService.findById(updated!.id); + this.notifier.completed(fresh); + // Customer tracking: close out the tail milestones so a finished shipment + // never shows a forever-pending timeline. EXIT_NOTE/PROCESS_COMPLETED are + // implied by delivery; a storage invoice that was never raised is skipped + // (storage billing does not apply to every shipment). All doc-trigger / + // best-effort — a booking without milestone rows is untouched. + if (this.milestoneService) { + for (const code of ["IMPORT_PROCESS_COMPLETED", "EXIT_NOTE_GENERATED"]) { + try { + await this.milestoneService.completeByDocTrigger({ bookingId }, code); + } catch { + /* tracking must never block completion */ + } + } + try { + await this.milestoneService.skipForBooking(bookingId, "STORAGE_INVOICE_RAISED"); + } catch { + /* no such milestone row (export / non-customs) — fine */ + } + } + return fresh; } async cancel(bookingId: string, reason: string): Promise { @@ -491,7 +536,9 @@ export class BookingTransitionService { const updated = await this.bookingsRepository.update(bookingId, { status: "CANCELLED", } as never); - return this.bookingsService.findById(updated!.id); + const fresh = await this.bookingsService.findById(updated!.id); + this.notifier.cancelled(fresh, reason); + return fresh; } /** @@ -723,7 +770,9 @@ export class BookingTransitionService { } as never); } - return this.bookingsService.findById(bookingId); + const fresh = await this.bookingsService.findById(bookingId); + this.notifier.clearanceDocsUploadedToStaff(fresh); + return fresh; } /** @@ -824,6 +873,9 @@ export class BookingTransitionService { } const updated = await this.bookingsService.findById(bookingId); + if (status === "QUERIED") { + this.notifier.documentQueried(updated, fileKey, note ?? ''); + } if (this.isPhasedGeneralCustoms(updated)) { const allApproved = await this.isClearanceFullyApproved(updated); if (allApproved) { @@ -912,7 +964,9 @@ export class BookingTransitionService { await this.bookingsRepository.update(bookingId, { status: "CLEARANCE_READY", } as never); - return this.bookingsService.findById(bookingId); + const fresh = await this.bookingsService.findById(bookingId); + this.notifier.clearanceReady(fresh); + return fresh; } /** @@ -957,7 +1011,9 @@ export class BookingTransitionService { status: "OPERATION_REQUEST_PENDING", scheduledDate: date, } as never); - return this.bookingsService.findById(bookingId); + const fresh = await this.bookingsService.findById(bookingId); + this.notifier.operationRequestedToStaff(fresh); + return fresh; } /** @@ -992,7 +1048,9 @@ export class BookingTransitionService { await this.bookingsRepository.update(bookingId, { status: "OPERATION_CHANGES_REQUESTED", } as never); - return this.bookingsService.findById(bookingId); + const fresh = await this.bookingsService.findById(bookingId); + this.notifier.operationChangesRequested(fresh, options.note); + return fresh; } // ACCEPT — enter the batch holding pool. @@ -1037,7 +1095,9 @@ export class BookingTransitionService { fullyExecutedAt: now, lockedAt: booking.lockedAt ?? now, } as never); - return this.bookingsService.findById(booking.id); + const roadFresh = await this.bookingsService.findById(booking.id); + this.notifier.operationAccepted(roadFresh); + return roadFresh; } await this.bookingsRepository.update(booking.id, { @@ -1072,7 +1132,9 @@ export class BookingTransitionService { // batch runs after the window closes + staff document review, never at accept // time. (Legacy pre-migration schedules with no window phase are still served // by the periodic legacy fill.) - return this.bookingsService.findById(booking.id); + const trainFresh = await this.bookingsService.findById(booking.id); + this.notifier.operationAccepted(trainFresh); + return trainFresh; } async enrichBookingResponse(booking: Booking): Promise< diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 2cb10ce8e..61dc78e13 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -18,7 +18,10 @@ import { BookingInvoiceService } from './booking-invoice.service'; // import { BookingPaymentService } from './booking-payment.service'; import { BookingPricingService } from './booking-pricing.service'; import { BookingReferenceDataService } from './booking-reference-data.service'; +import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service'; import { BookingTransitionService } from './booking-transition.service'; +import { NotificationsModule } from '../notifications/notifications.module'; +import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; import { BookingsController } from './bookings.controller'; // import { PayController } from './pay.controller'; import { BookingsRepository } from './bookings.repository'; @@ -64,6 +67,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; CustomerTruckContainer, ]), BillingModule, + NotificationsModule, + NotificationInboxModule, forwardRef(() => FirstMileModule), forwardRef(() => TrainSchedulingModule), forwardRef(() => ContractsModule), @@ -90,6 +95,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; ContainerValidationService, BookingReferenceDataService, BookingPricingService, + BookingLifecycleNotifierService, BookingTransitionService, BookingContractService, BookingInvoiceService, @@ -107,6 +113,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; BookingsRepository, BookingPricingService, BookingInvoiceService, + BookingLifecycleNotifierService, CustomerTruckService, ContainerReceiptService, ], diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index d4791baf7..1fb37dc31 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -1388,6 +1388,17 @@ export class BookingsService { ); } + // Surface the assigned train's operational status so the portal stepper + // can show the Arrival stage: the booking status stays IN_TRANSIT from + // dispatch until delivery, so arrival is only knowable from the schedule. + if (booking.trainScheduleId) { + const schedule = await this.dataSource + .getRepository(TrainSchedule) + .findOne({ where: { id: booking.trainScheduleId } }); + (booking as Booking & { trainScheduleStatus?: string | null }).trainScheduleStatus = + schedule?.status ?? null; + } + return booking; } diff --git a/apps/edr-freight-api/src/modules/compliance/compliance.controller.ts b/apps/edr-freight-api/src/modules/compliance/compliance.controller.ts new file mode 100644 index 000000000..2a5715647 --- /dev/null +++ b/apps/edr-freight-api/src/modules/compliance/compliance.controller.ts @@ -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); + } +} diff --git a/apps/edr-freight-api/src/modules/compliance/compliance.module.ts b/apps/edr-freight-api/src/modules/compliance/compliance.module.ts new file mode 100644 index 000000000..1477fbc8b --- /dev/null +++ b/apps/edr-freight-api/src/modules/compliance/compliance.module.ts @@ -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 {} diff --git a/apps/edr-freight-api/src/modules/compliance/compliance.repository.ts b/apps/edr-freight-api/src/modules/compliance/compliance.repository.ts new file mode 100644 index 000000000..e9764f8a4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/compliance/compliance.repository.ts @@ -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 { + constructor( + @InjectRepository(ComplianceRecord) + private readonly complianceRepository: Repository, + ) { + super(complianceRepository); + } + + async findWithFilters(filter: { vehicleId?: string; type?: ComplianceType } = {}) { + const where: FindOptionsWhere = {}; + if (filter.vehicleId) where.vehicleId = filter.vehicleId; + if (filter.type) where.type = filter.type; + + return this.complianceRepository.find({ + where, + order: { expiryDate: 'ASC' }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/compliance/compliance.service.ts b/apps/edr-freight-api/src/modules/compliance/compliance.service.ts new file mode 100644 index 000000000..ec6e2a803 --- /dev/null +++ b/apps/edr-freight-api/src/modules/compliance/compliance.service.ts @@ -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, + @InjectRepository(Driver) + private readonly driverRepo: Repository, + ) {} + + async create(dto: CreateComplianceRecordDto): Promise { + 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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/apps/edr-freight-api/src/modules/compliance/dto/create-compliance-record.dto.ts b/apps/edr-freight-api/src/modules/compliance/dto/create-compliance-record.dto.ts new file mode 100644 index 000000000..8b716ef15 --- /dev/null +++ b/apps/edr-freight-api/src/modules/compliance/dto/create-compliance-record.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/compliance/entities/compliance-record.entity.ts b/apps/edr-freight-api/src/modules/compliance/entities/compliance-record.entity.ts new file mode 100644 index 000000000..04355c1f9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/compliance/entities/compliance-record.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts index 2b0126003..7b5cb77d6 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts @@ -85,6 +85,13 @@ function makeService(overrides?: { milestoneService as never, dropdownSettingsService as never, glOperationsService as never, + { + dutyAdvised: jest.fn(), + clearanceReady: jest.fn(), + documentQueried: jest.fn(), + dutySlipUploadedToStaff: jest.fn(), + clearanceDocsUploadedToStaff: jest.fn(), + } as never, // notifier ); return { @@ -115,12 +122,18 @@ describe('BookingClearanceService', () => { it('records duty advice when duty applies', async () => { const { service, milestoneService } = makeService(); - await service.adviseDuty('b-general', { - dutyRequired: true, - amount: 1500, - currency: 'ETB', - declarationSerial: 'DS-1', - }); + await service.adviseDuty( + 'b-general', + { + dutyRequired: true, + amount: 1500, + currency: 'ETB', + declarationSerial: 'DS-1', + }, + undefined, + // The duty notice attachment is now mandatory when duty applies. + { fieldname: 'duty_tax_notice' } as Express.Multer.File, + ); expect(milestoneService.adviseDuty).toHaveBeenCalledWith( 'b-general', diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts index 61ac93925..59dad2248 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts @@ -12,6 +12,7 @@ import { FileUploadSettingsService } from '../file-upload-settings/file-upload-s import { FilesService } from '../files/files.service'; import { BookingsRepository } from '../bookings/bookings.repository'; import { BookingsService } from '../bookings/bookings.service'; +import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service'; import { ClearanceMilestone } from './entities/clearance-milestone.entity'; import { Booking } from '../bookings/entities/booking.entity'; import { clearanceCodesForBooking } from '../bookings/clearance.util'; @@ -100,6 +101,7 @@ export class BookingClearanceService { private readonly milestoneService: ClearanceMilestoneService, private readonly dropdownSettingsService: DropdownSettingsService, private readonly glOperationsService: GlOperationsService, + private readonly notifier: BookingLifecycleNotifierService, ) {} private async assertPhasedGeneralCustoms(booking: Booking): Promise { @@ -174,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( @@ -414,6 +437,7 @@ export class BookingClearanceService { }, userId, ); + this.notifier.dutyAdvised(booking, dto.amount, dto.currency ?? 'ETB'); } return this.bookingsService.findById(bookingId); @@ -441,6 +465,7 @@ export class BookingClearanceService { clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance, } as never); + this.notifier.dutySlipUploadedToStaff(booking, 'first'); return this.bookingsService.findById(bookingId); } diff --git a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts index 88a4ec725..4752b004f 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts @@ -10,6 +10,7 @@ import type { Freight } from '@edr/types'; import { BookingRequestRepository } from './booking-request.repository'; import { ContractsService } from './contracts.service'; import { ContractBookingService } from './contract-booking.service'; +import { ContractNotifierService } from './contract-notifier.service'; import { BookingRequest } from './entities/booking-request.entity'; import { Contract } from './entities/contract.entity'; import { CreateBookingRequestDto } from './dto/create-booking-request.dto'; @@ -26,6 +27,7 @@ export class BookingRequestService { private readonly repo: BookingRequestRepository, private readonly contractsService: ContractsService, private readonly contractBookingService: ContractBookingService, + private readonly notifier: ContractNotifierService, ) {} /** Only GENERAL contracts that bundle customs use the request → GL → clearance flow. */ @@ -107,7 +109,7 @@ export class BookingRequestService { }; const reference = await this.generateReference(); - return this.repo.create({ + const request = await this.repo.create({ reference, contractId, requestedByUserId: userId ?? null, @@ -117,6 +119,8 @@ export class BookingRequestService { requestedLines, notes: dto.notes ?? null, } as never); + this.notifier.shipmentRequestedToStaff(contract, request.id, request.reference); + return request; } listForContract(contractId: string): Promise { diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts index e033f8e9b..4a58e50be 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts @@ -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 { - 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; } /** diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.spec.ts b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.spec.ts index f30b64597..75178d640 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.spec.ts @@ -48,6 +48,7 @@ function makeService(milestones: ClearanceMilestone[]) { contractsRepository as never, milestoneService as never, bookingsRepository as never, + { clearanceReady: jest.fn() } as never, // notifier ); return { service, milestoneService, contractsRepository, bookingsRepository }; } diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts index 758da27ff..9b17a3e76 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts @@ -9,6 +9,7 @@ import { Contract } from './entities/contract.entity'; import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity'; import { ClearanceMilestone } from './entities/clearance-milestone.entity'; import { BookingsRepository } from '../bookings/bookings.repository'; +import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service'; import { Booking } from '../bookings/entities/booking.entity'; import type { ClearanceMetaState } from './clearance-workflow.types'; import { metaFromBooking } from './clearance-workflow.types'; @@ -34,6 +35,7 @@ export class ClearanceWorkflowService { private readonly contractsRepository: ContractsRepository, private readonly milestoneService: ClearanceMilestoneService, private readonly bookingsRepository: BookingsRepository, + private readonly notifier: BookingLifecycleNotifierService, ) {} boundaryMilestone(tradeDirection: string): string { @@ -264,6 +266,14 @@ export class ClearanceWorkflowService { status: 'CLEARANCE_READY', clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance, } as never); + // Tell the customer clearance is done and operation can be requested. Load + // failure only skips the notice — the status change above already committed. + try { + const booking = await this.bookingsRepository.findByIdWithFiles(bookingId); + if (booking) this.notifier.clearanceReady(booking); + } catch { + /* notification is best-effort */ + } } resolvePhase( diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index 2c79ba42f..3d41e68a5 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -16,6 +16,7 @@ import { BookingsService } from '../bookings/bookings.service'; import { contractClearanceCodes } from './contract-clearance.util'; import { ClearanceWorkflowService } from './clearance-workflow.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; +import { ContractNotifierService } from './contract-notifier.service'; import { GlOperationsService } from './gl-operations.service'; import { ClearanceMilestone } from './entities/clearance-milestone.entity'; import { Contract } from './entities/contract.entity'; @@ -118,6 +119,7 @@ export class ContractClearanceService { private readonly milestoneService: ClearanceMilestoneService, private readonly dropdownSettingsService: DropdownSettingsService, private readonly glOperationsService: GlOperationsService, + private readonly notifier: ContractNotifierService, ) {} private isPhasedCustoms(contract: Contract): boolean { @@ -543,7 +545,9 @@ export class ContractClearanceService { await this.workflowService.onDocumentReviewReopened(contractId); } - return this.contractsService.findById(contractId); + const updated = await this.contractsService.findById(contractId); + this.notifier.clearanceDocsUploadedToStaff(updated); + return updated; } private async assertRequiredInputsPresent( @@ -674,6 +678,7 @@ export class ContractClearanceService { status: 'AWAITING_CLEARANCE_DOCUMENTS', clearanceStatus: 'AWAITING_DOCUMENTS', } as never); + this.notifier.clearanceDocumentQueried(contract, fileKey, note ?? ''); if (cycle) { await this.contractsRepository.setCycleStatus(cycle.id, 'AWAITING_DOCUMENTS'); } @@ -1009,6 +1014,7 @@ export class ContractClearanceService { }, userId, ); + this.notifier.dutyAdvised(contract, dto.amount, dto.currency ?? 'ETB'); } return this.contractsService.findById(contractId); @@ -1045,7 +1051,9 @@ export class ContractClearanceService { }); } - return this.contractsService.findById(contractId); + const updated = await this.contractsService.findById(contractId); + this.notifier.dutySlipUploadedToStaff(updated); + return updated; } async uploadTransitPermit( @@ -1118,6 +1126,7 @@ export class ContractClearanceService { await this.workflowService.markReadyForBooking(contractId); } + this.notifier.preClearanceFinalized(contract); return this.contractsService.findById(contractId); } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts new file mode 100644 index 000000000..d4f31e570 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts @@ -0,0 +1,245 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { + NotificationAudience, + NotificationType, + NotifyInput, +} from '@edr/types'; + +import { Contract } from './entities/contract.entity'; +import { NotificationsService } from '../notifications/notifications.service'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; + +/** + * Customer + staff notifications for the contract lifecycle. Every customer + * event fans out over three channels: SMS + email (direct, via + * {@link NotificationsService}) and a persisted in-app notification (via + * {@link NotificationInboxService}) that deep-links to the contract detail page. + * Staff events go to the backoffice inbox. All sends are fire-and-forget and + * never throw — a notification failure must not break a contract transition. + */ +@Injectable() +export class ContractNotifierService { + private readonly logger = new Logger(ContractNotifierService.name); + + constructor( + private readonly notifications: NotificationsService, + private readonly inbox: NotificationInboxService, + ) {} + + private ref(c: Contract): string { + return `${c.reference}${c.isGovernment ? ' (gov)' : ''}`; + } + + /** Send SMS + email to the contract's company contact; log-only on failure. */ + private async notifyContact( + c: Contract, + message: string, + logLabel: string, + ): Promise { + this.logger.log(`${logLabel} — ${this.ref(c)}`); + const phone = c.company?.contactPersonPhone ?? c.company?.phone ?? null; + const email = c.company?.email ?? c.company?.generalManagerEmail ?? null; + + if (phone) { + try { + await this.notifications.directSend('sms', phone, message); + } catch (err) { + this.logger.warn(`SMS failed for ${this.ref(c)}: ${(err as Error).message}`); + } + } + if (email) { + try { + await this.notifications.directSend('email', email, message); + } catch (err) { + this.logger.warn(`Email failed for ${this.ref(c)}: ${(err as Error).message}`); + } + } + if (!phone && !email) { + this.logger.warn(`No contact on file for ${this.ref(c)} — notification not sent`); + } + } + + /** Persist + push an in-app item to all portal users of the contract's company. */ + private inApp( + c: Contract, + title: string, + body: string, + overrides: Partial = {}, + ): void { + if (!c.companyId) return; // government/unlinked contracts have no portal users + void this.inbox.notify({ + recipients: { companyId: c.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.CONTRACT_STATUS, + title, + body, + link: `/contracts/${c.id}`, + data: { contractId: c.id, reference: c.reference }, + ...overrides, + }); + } + + /** Persist + push an in-app item to every backoffice staff user. */ + private inAppStaff( + c: Contract, + title: string, + body: string, + overrides: Partial = {}, + ): void { + void this.inbox.notify({ + recipients: { allBackoffice: true }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.REQUEST_SUBMITTED, + title, + body, + link: `/dashboard/contract-requests/${c.id}`, + data: { contractId: c.id, reference: c.reference }, + ...overrides, + }); + } + + // ── Customer-facing lifecycle events ─────────────────────────────────────── + + /** Line staff accepted intake → contract is under approval. */ + accepted(c: Contract): void { + const msg = + `Your contract ${c.reference} has been accepted and is now under approval. ` + + `We will notify you once it is approved.`; + void this.notifyContact(c, msg, 'ACCEPTED'); + this.inApp(c, 'Contract accepted', msg); + } + + /** All approval steps complete → contract approved. */ + approved(c: Contract): void { + const msg = + `Your contract ${c.reference} has been approved. ` + + `The final document will be prepared for signing.`; + void this.notifyContact(c, msg, 'APPROVED'); + this.inApp(c, 'Contract approved', msg); + } + + /** Fully executed (all parties signed) → contract active, customer can book. */ + signedActive(c: Contract): void { + const msg = + `Your contract ${c.reference} has been signed and is now active. ` + + `You can start booking shipments from the portal.`; + void this.notifyContact(c, msg, 'SIGNED / ACTIVE'); + this.inApp(c, 'Contract active', msg); + } + + /** Staff rejected the contract. */ + rejected(c: Contract, reason: string): void { + const msg = + `Your contract ${c.reference} was rejected. Reason: ${reason}. ` + + `Please contact us for details.`; + void this.notifyContact(c, msg, 'REJECTED'); + this.inApp(c, 'Contract rejected', msg); + } + + /** Staff requested changes before approval. */ + changesRequested(c: Contract, note: string): void { + const msg = + `Changes were requested on your contract ${c.reference}: ${note}. ` + + `Please update and resubmit from the portal.`; + void this.notifyContact(c, msg, 'CHANGES REQUESTED'); + this.inApp(c, 'Contract changes requested', msg); + } + + // ── Clearance milestones needing customer action ────────────────────────── + + /** GL advised duty & tax on the contract cycle — customer pays + uploads slip. */ + dutyAdvised(c: Contract, amount: number, currency: string): void { + const msg = + `Duty & tax of ${amount} ${currency} has been advised for contract ${c.reference}. ` + + `Please pay and upload the payment slip from the portal.`; + void this.notifyContact(c, msg, 'DUTY ADVISED'); + this.inApp(c, 'Duty & tax advised', msg, { + type: NotificationType.INVOICE_ISSUED, + link: `/contracts/${c.id}/clearance`, + }); + } + + /** A clearance document was queried — customer must re-upload it. */ + clearanceDocumentQueried(c: Contract, fileKey: string, note: string): void { + const msg = + `A clearance document on contract ${c.reference} needs attention: "${fileKey}". ` + + `${note}. Please re-upload from the portal.`; + void this.notifyContact(c, msg, 'CLEARANCE DOC QUERIED'); + this.inApp(c, 'Clearance document queried', msg, { + type: NotificationType.DOCUMENT_ACTION, + link: `/contracts/${c.id}/clearance`, + }); + } + + /** Import pre-clearance finalized — the process moves to GL Djibouti collection. */ + preClearanceFinalized(c: Contract): void { + const msg = + `Pre-clearance for contract ${c.reference} is complete. ` + + `Your shipment is proceeding to document collection in Djibouti.`; + void this.notifyContact(c, msg, 'PRE-CLEARANCE FINALIZED'); + this.inApp(c, 'Pre-clearance complete', msg, { + type: NotificationType.CLEARANCE_DECISION, + link: `/contracts/${c.id}/clearance`, + }); + } + + // ── Staff-facing (backoffice inbox) ──────────────────────────────────────── + + /** Customer submitted a contract for review. */ + submittedToStaff(c: Contract): void { + this.inAppStaff( + c, + 'New contract submitted', + `Contract ${this.ref(c)} was submitted and is awaiting intake review.`, + ); + } + + /** Customer signed the contract — staff counter-sign is next. */ + customerSignedToStaff(c: Contract): void { + this.inAppStaff( + c, + 'Customer signed contract', + `Contract ${this.ref(c)} was signed by the customer and awaits the EDR counter-signature.`, + { link: `/dashboard/contract-requests/${c.id}/view` }, + ); + } + + /** Customer uploaded clearance documents — GL review is next. */ + clearanceDocsUploadedToStaff(c: Contract): void { + this.inAppStaff( + c, + 'Clearance documents uploaded', + `Customer uploaded clearance documents for contract ${this.ref(c)} — review them in the clearance queue.`, + { + type: NotificationType.CLEARANCE_REVIEW, + link: `/dashboard/contracts/clearance/${c.id}`, + }, + ); + } + + /** Customer uploaded the duty/tax payment slip — GL verifies it. */ + dutySlipUploadedToStaff(c: Contract): void { + this.inAppStaff( + c, + 'Duty slip uploaded', + `Customer uploaded the duty & tax payment slip for contract ${this.ref(c)}.`, + { + type: NotificationType.PAYMENT_RECEIVED, + link: `/dashboard/contracts/clearance/${c.id}`, + }, + ); + } + + /** Customer filed a shipment request under a GENERAL customs contract. */ + shipmentRequestedToStaff(c: Contract, requestId: string, requestRef: string): void { + this.inAppStaff( + c, + 'New shipment request', + `Shipment request ${requestRef} was filed under contract ${this.ref(c)} and awaits GL review.`, + { + link: `/dashboard/shipment-requests/${requestId}`, + data: { contractId: c.id, requestId, reference: requestRef }, + }, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index e31392666..9f12b937d 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -22,6 +22,7 @@ import { FilesService } from '../files/files.service'; import { SignaturesService } from '../signatures/signatures.service'; import { OtpService } from '../otp/otp.service'; import { ContractPricingService } from './contract-pricing.service'; +import { ContractNotifierService } from './contract-notifier.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ContractsRepository } from './contracts.repository'; import { ContractsService } from './contracts.service'; @@ -66,6 +67,7 @@ export class ContractTransitionService { private readonly pdfService: ContractPdfService, private readonly minioService: MinioService, private readonly otpService: OtpService, + private readonly notifier: ContractNotifierService, ) {} /** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */ @@ -79,7 +81,9 @@ export class ContractTransitionService { await this.contractsRepository.update(contractId, { status: 'SUBMITTED', } as never); - return this.contractsService.findById(contractId); + const updated = await this.contractsService.findById(contractId); + this.notifier.submittedToStaff(updated); + return updated; } /** Confirm a price change before submit (mirrors booking confirm-submit). */ @@ -93,7 +97,9 @@ export class ContractTransitionService { await this.contractsRepository.update(contractId, { status: 'SUBMITTED', } as never); - return this.contractsService.findById(contractId); + const updated = await this.contractsService.findById(contractId); + this.notifier.submittedToStaff(updated); + return updated; } /** @@ -130,7 +136,9 @@ export class ContractTransitionService { contractValidFrom: validFrom, contractValidUntil: validUntil, } as never); - return this.contractsService.findById(contractId); + const updated = await this.contractsService.findById(contractId); + this.notifier.accepted(updated); + return updated; } /** @@ -218,7 +226,9 @@ export class ContractTransitionService { await this.contractsRepository.update(contractId, { status: 'CHANGES_REQUESTED', } as never); - return this.contractsService.findById(contractId); + const updated = await this.contractsService.findById(contractId); + this.notifier.changesRequested(updated, note); + return updated; } async reject(contractId: string, reason: string, actorId: string): Promise { @@ -235,7 +245,47 @@ export class ContractTransitionService { await this.contractsRepository.update(contractId, { status: 'REJECTED', } as never); - return this.contractsService.findById(contractId); + const updated = await this.contractsService.findById(contractId); + this.notifier.rejected(updated, reason); + 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 { + 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. */ @@ -297,7 +347,11 @@ export class ContractTransitionService { if (Object.keys(updates).length > 0) { await this.contractsRepository.update(contractId, updates as never); } - return this.contractsService.findById(contractId); + const updated = await this.contractsService.findById(contractId); + if (allDone) { + this.notifier.approved(updated); + } + return updated; } /** @@ -535,7 +589,9 @@ export class ContractTransitionService { customerSignedAt: new Date(), } as never); await this.regenerateContractPdf(contractId, contract.reference); - return this.contractsService.findById(contractId); + const updated = await this.contractsService.findById(contractId); + this.notifier.customerSignedToStaff(updated); + return updated; } return this.counterSign(contractId, dto, options); @@ -605,7 +661,9 @@ export class ContractTransitionService { await this.contractsRepository.update(contractId, updates as never); await this.regenerateContractPdf(contractId, contract.reference); - return this.contractsService.findById(contractId); + const updated = await this.contractsService.findById(contractId); + this.notifier.signedActive(updated); + return updated; } /** Customer requests renewal → RENEWAL_DRAFT linked via renewalOfId. */ diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 8591a54d8..4ea7634b6 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -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' }) diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts index a9f9dcf5d..33a547a9f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -12,6 +12,8 @@ import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-se import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module'; import { SignaturesModule } from '../signatures/signatures.module'; import { OtpModule } from '../otp/otp.module'; +import { NotificationsModule } from '../notifications/notifications.module'; +import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; import { BookingsModule } from '../bookings/bookings.module'; import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; @@ -19,6 +21,7 @@ import { ContractsController } from './contracts.controller'; import { ContractsService } from './contracts.service'; import { ContractsRepository } from './contracts.repository'; import { ContractPricingService } from './contract-pricing.service'; +import { ContractNotifierService } from './contract-notifier.service'; import { ContractTransitionService } from './contract-transition.service'; import { ContractClearanceService } from './contract-clearance.service'; import { BookingClearanceService } from './booking-clearance.service'; @@ -75,6 +78,8 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum MinioModule, SignaturesModule, OtpModule, + NotificationsModule, + NotificationInboxModule, CompaniesModule, // BookingsModule provides BookingsRepository/BookingPricingService used by the // contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3). @@ -94,6 +99,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum ContractsService, ContractsRepository, ContractPricingService, + ContractNotifierService, ContractTransitionService, ContractClearanceService, ClearanceWorkflowService, diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index 0e81f8dca..aaf064bff 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts index 07d08d3c0..0b0fab41b 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts index 72e2d14d9..181d8b688 100644 --- a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts @@ -11,6 +11,7 @@ import { BillingService } from '../billing/billing.service'; import { InvoiceLine } from '../billing/entities/invoice-line.entity'; import { FilesService } from '../files/files.service'; import { Booking } from '../bookings/entities/booking.entity'; +import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { ImportDjiboutiOperation } from '../train-scheduling/entities/import-djibouti-operation.entity'; import { @@ -53,6 +54,7 @@ export class GlOperationsService { private readonly filesService: FilesService, private readonly milestoneService: ClearanceMilestoneService, private readonly billingService: BillingService, + private readonly notifier: BookingLifecycleNotifierService, ) {} private get bookings() { @@ -64,7 +66,11 @@ export class GlOperationsService { } private async getBooking(bookingId: string): Promise { - const booking = await this.bookings.findOne({ where: { id: bookingId } }); + // company is loaded so customer notifications have a phone/email to target. + const booking = await this.bookings.findOne({ + where: { id: bookingId }, + relations: { company: true }, + }); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); return booking; } @@ -447,6 +453,7 @@ export class GlOperationsService { void userId; const summary = await this.finalInvoiceSummary(bookingId); if (!summary) throw new NotFoundException('Final invoice could not be created.'); + this.notifier.finalInvoiceCreated(booking, input.amount, input.currency); return summary; } @@ -455,7 +462,7 @@ export class GlOperationsService { bookingId: string, file: Express.Multer.File, ): Promise<{ uploaded: boolean }> { - await this.getBooking(bookingId); + const booking = await this.getBooking(bookingId); if (!file) throw new BadRequestException('No payment slip uploaded'); const invoice = await this.billingService.findInvoice( @@ -482,6 +489,7 @@ export class GlOperationsService { code: 'final_invoice_slip', file, }); + this.notifier.dutySlipUploadedToStaff(booking, 'final'); return { uploaded: true }; } @@ -490,7 +498,7 @@ export class GlOperationsService { bookingId: string, userId?: string, ): Promise { - await this.getBooking(bookingId); + const booking = await this.getBooking(bookingId); const invoice = await this.billingService.findInvoice( Freight.InvoiceSource.Booking, bookingId, @@ -507,6 +515,7 @@ export class GlOperationsService { ); } await this.billingService.markInvoiceAsPaid(invoice.id); + this.notifier.finalInvoicePaid(booking); } void userId; @@ -575,6 +584,7 @@ export class GlOperationsService { }, userId, ); + this.notifier.secondDutyAdvised(booking, input.amount, input.currency ?? 'ETB'); return { advised: true, skipped: false }; } @@ -605,6 +615,7 @@ export class GlOperationsService { booking.tradeDirection ?? 'IMPORT', ); await this.milestoneService.completeForBooking(bookingId, 'SECOND_DUTY_PAID'); + this.notifier.dutySlipUploadedToStaff(booking, 'second'); return { milestoneCompleted: true }; } diff --git a/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts b/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts index b4da558e2..d86ee823a 100644 --- a/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts +++ b/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts @@ -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); diff --git a/apps/edr-freight-api/src/modules/drivers/drivers.module.ts b/apps/edr-freight-api/src/modules/drivers/drivers.module.ts index 9e685dcd6..1a6e29e15 100644 --- a/apps/edr-freight-api/src/modules/drivers/drivers.module.ts +++ b/apps/edr-freight-api/src/modules/drivers/drivers.module.ts @@ -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], diff --git a/apps/edr-freight-api/src/modules/drivers/drivers.service.ts b/apps/edr-freight-api/src/modules/drivers/drivers.service.ts index 6e7c1f69c..e4fa992e8 100644 --- a/apps/edr-freight-api/src/modules/drivers/drivers.service.ts +++ b/apps/edr-freight-api/src/modules/drivers/drivers.service.ts @@ -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, 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 { + await this.filesService.remove(fileId); + } + async create(dto: CreateDriverDto): Promise { if (dto.faydaVerified !== true) { throw new BadRequestException( diff --git a/apps/edr-freight-api/src/modules/files/files.service.ts b/apps/edr-freight-api/src/modules/files/files.service.ts index a5c641dd7..4966d7ff9 100644 --- a/apps/edr-freight-api/src/modules/files/files.service.ts +++ b/apps/edr-freight-api/src/modules/files/files.service.ts @@ -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 { + await this.filesRepository.softDelete(id); + } + findByResource(resourceId: string, resource: string): Promise { return this.filesRepository.findByResource(resourceId, resource); } diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts index f4935a87b..aa618cdb7 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts @@ -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', diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts index e43fbcae8..952f924d6 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts @@ -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) { diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index 5c12d94ee..00dbb80cc 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -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); } diff --git a/apps/edr-freight-api/src/modules/fuel/fuel.controller.ts b/apps/edr-freight-api/src/modules/fuel/fuel.controller.ts index 62207bfa1..2e5cca199 100644 --- a/apps/edr-freight-api/src/modules/fuel/fuel.controller.ts +++ b/apps/edr-freight-api/src/modules/fuel/fuel.controller.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/gps-tracking/dto/gps-device.dto.ts b/apps/edr-freight-api/src/modules/gps-tracking/dto/gps-device.dto.ts new file mode 100644 index 000000000..933699f0b --- /dev/null +++ b/apps/edr-freight-api/src/modules/gps-tracking/dto/gps-device.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/gps-tracking/entities/gps-device.entity.ts b/apps/edr-freight-api/src/modules/gps-tracking/entities/gps-device.entity.ts new file mode 100644 index 000000000..ac5f7dbc6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/gps-tracking/entities/gps-device.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/gps-tracking/entities/gps-position.entity.ts b/apps/edr-freight-api/src/modules/gps-tracking/entities/gps-position.entity.ts new file mode 100644 index 000000000..8c63bb78f --- /dev/null +++ b/apps/edr-freight-api/src/modules/gps-tracking/entities/gps-position.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts new file mode 100644 index 000000000..e380e541e --- /dev/null +++ b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts @@ -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); + } +} diff --git a/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.module.ts b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.module.ts new file mode 100644 index 000000000..da527fff0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.module.ts @@ -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 {} diff --git a/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.repository.ts b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.repository.ts new file mode 100644 index 000000000..326ef66ac --- /dev/null +++ b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.repository.ts @@ -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 { + constructor( + @InjectRepository(GpsDevice) repository: Repository, + ) { + super(repository); + } + + findByImei(imei: string): Promise { + return this.repository.findOne({ where: { imei } }); + } +} + +@Injectable() +export class GpsPositionRepository extends BaseRepository { + constructor( + @InjectRepository(GpsPosition) repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.service.ts b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.service.ts new file mode 100644 index 000000000..b5bea3a3f --- /dev/null +++ b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.service.ts @@ -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 { + 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 { + const device = await this.ensureDevice(imei); + await this.devices.update(device.id, { lastSeenAt: new Date(), status: 'ONLINE' }); + } + + async handleHeartbeat(imei: string, status: Gt06Status): Promise { + 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 { + 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 { + await this.devices.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/gps-tracking/gt06/gt06.codec.ts b/apps/edr-freight-api/src/modules/gps-tracking/gt06/gt06.codec.ts new file mode 100644 index 000000000..d54f906a2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/gps-tracking/gt06/gt06.codec.ts @@ -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]), + ]); +} diff --git a/apps/edr-freight-api/src/modules/gps-tracking/gt06/gt06.server.ts b/apps/edr-freight-api/src/modules/gps-tracking/gt06/gt06.server.ts new file mode 100644 index 000000000..a2095fa12 --- /dev/null +++ b/apps/edr-freight-api/src/modules/gps-tracking/gt06/gt06.server.ts @@ -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(); + + 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 { + 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['packets'][number], + ): Promise { + 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; + } + } +} diff --git a/apps/edr-freight-api/src/modules/incidents/dto/create-incident.dto.ts b/apps/edr-freight-api/src/modules/incidents/dto/create-incident.dto.ts new file mode 100644 index 000000000..5d76885b4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/incidents/dto/create-incident.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/incidents/dto/update-incident.dto.ts b/apps/edr-freight-api/src/modules/incidents/dto/update-incident.dto.ts new file mode 100644 index 000000000..b45d478e0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/incidents/dto/update-incident.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/incidents/entities/incident.entity.ts b/apps/edr-freight-api/src/modules/incidents/entities/incident.entity.ts new file mode 100644 index 000000000..2c71cc8a3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/incidents/entities/incident.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/incidents/incidents.controller.ts b/apps/edr-freight-api/src/modules/incidents/incidents.controller.ts new file mode 100644 index 000000000..ab6d5ef08 --- /dev/null +++ b/apps/edr-freight-api/src/modules/incidents/incidents.controller.ts @@ -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 }; + } +} diff --git a/apps/edr-freight-api/src/modules/incidents/incidents.module.ts b/apps/edr-freight-api/src/modules/incidents/incidents.module.ts new file mode 100644 index 000000000..872fbaab1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/incidents/incidents.module.ts @@ -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 {} diff --git a/apps/edr-freight-api/src/modules/incidents/incidents.repository.ts b/apps/edr-freight-api/src/modules/incidents/incidents.repository.ts new file mode 100644 index 000000000..1d9f17770 --- /dev/null +++ b/apps/edr-freight-api/src/modules/incidents/incidents.repository.ts @@ -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 { + constructor( + @InjectRepository(Incident) + incidentRepository: Repository, + ) { + super(incidentRepository); + } +} diff --git a/apps/edr-freight-api/src/modules/incidents/incidents.service.ts b/apps/edr-freight-api/src/modules/incidents/incidents.service.ts new file mode 100644 index 000000000..ea28a96e2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/incidents/incidents.service.ts @@ -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; + lastIncidentAt: Date | null; +} + +@Injectable() +export class IncidentsService { + constructor(private readonly incidentsRepository: IncidentsRepository) {} + + async create(dto: CreateIncidentDto): Promise { + return this.incidentsRepository.create({ + ...dto, + occurredAt: new Date(dto.occurredAt), + }); + } + + async findAll(filter: IncidentFilter = {}): Promise { + const where: FindOptionsWhere = {}; + 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 { + return this.incidentsRepository.findAll({ + where: { driverId }, + order: { occurredAt: 'DESC' }, + }); + } + + async findById(id: string): Promise { + 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 { + 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 { + await this.findById(id); + await this.incidentsRepository.softDelete(id); + } + + async statsForDriver(driverId: string): Promise { + const incidents = await this.incidentsRepository.findAll({ + where: { driverId }, + order: { occurredAt: 'DESC' }, + }); + + const byType: Record = {}; + 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, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/last-mile/dto/update-last-mile.dto.ts b/apps/edr-freight-api/src/modules/last-mile/dto/update-last-mile.dto.ts index 9d0c4262d..405273a54 100644 --- a/apps/edr-freight-api/src/modules/last-mile/dto/update-last-mile.dto.ts +++ b/apps/edr-freight-api/src/modules/last-mile/dto/update-last-mile.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts index 1f8bda8fc..5dca86cc4 100644 --- a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts index 7b14f6887..8a6ec0779 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts @@ -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', diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts index 2931a1f85..207b71a25 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts @@ -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); diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 22f25a6aa..15b8df3e7 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -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); } diff --git a/apps/edr-freight-api/src/modules/maintenance/dto/create-maintenance-depth.dto.ts b/apps/edr-freight-api/src/modules/maintenance/dto/create-maintenance-depth.dto.ts new file mode 100644 index 000000000..56c886a74 --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/dto/create-maintenance-depth.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/maintenance/entities/part.entity.ts b/apps/edr-freight-api/src/modules/maintenance/entities/part.entity.ts new file mode 100644 index 000000000..caa478d88 --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/entities/part.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/maintenance/entities/warranty.entity.ts b/apps/edr-freight-api/src/modules/maintenance/entities/warranty.entity.ts new file mode 100644 index 000000000..56c44fcbd --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/entities/warranty.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/maintenance/entities/work-order.entity.ts b/apps/edr-freight-api/src/modules/maintenance/entities/work-order.entity.ts new file mode 100644 index 000000000..224b74f74 --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/entities/work-order.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance-depth.service.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance-depth.service.ts new file mode 100644 index 000000000..212fe909a --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance-depth.service.ts @@ -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 { + 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 { + 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 { + 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 { + return this.partRepository.create({ ...dto }); + } + + async findParts(filters: { category?: string; lowStock?: boolean }) { + return this.partRepository.findFiltered(filters); + } + + async updatePart(id: string, dto: UpdatePartDto): Promise { + 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 { + 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 }; + } +} diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts index de5ff08f2..4ad8026f2 100644 --- a/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts @@ -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); + } } diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts index a0227a733..8f4fe1d0b 100644 --- a/apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts @@ -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 {} diff --git a/apps/edr-freight-api/src/modules/maintenance/part.repository.ts b/apps/edr-freight-api/src/modules/maintenance/part.repository.ts new file mode 100644 index 000000000..d6b221332 --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/part.repository.ts @@ -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 { + constructor( + @InjectRepository(Part) + private readonly partRepository: Repository, + ) { + 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(); + } +} diff --git a/apps/edr-freight-api/src/modules/maintenance/warranty.repository.ts b/apps/edr-freight-api/src/modules/maintenance/warranty.repository.ts new file mode 100644 index 000000000..e59bd358d --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/warranty.repository.ts @@ -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 { + constructor( + @InjectRepository(Warranty) + private readonly warrantyRepository: Repository, + ) { + super(warrantyRepository); + } + + async findFiltered(filters: { vehicleId?: string }) { + const where: FindOptionsWhere = {}; + if (filters.vehicleId) where.vehicleId = filters.vehicleId; + return this.warrantyRepository.find({ + where, + order: { expiryDate: 'ASC' }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/maintenance/work-order.repository.ts b/apps/edr-freight-api/src/modules/maintenance/work-order.repository.ts new file mode 100644 index 000000000..057f1fa6d --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/work-order.repository.ts @@ -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 { + constructor( + @InjectRepository(WorkOrder) + private readonly workOrderRepository: Repository, + ) { + super(workOrderRepository); + } + + async findFiltered(filters: { vehicleId?: string; status?: WorkOrderStatus }) { + const where: FindOptionsWhere = {}; + if (filters.vehicleId) where.vehicleId = filters.vehicleId; + if (filters.status) where.status = filters.status; + return this.workOrderRepository.find({ + where, + order: { openedAt: 'DESC' }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts index 265e19303..be7964a3d 100644 --- a/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts @@ -70,6 +70,18 @@ export class NotificationRecipientsService { } } + if (recipients.allBackoffice) { + try { + for (const uid of await this.backoffice.getAllCurrentEmployeeUserIds()) { + ids.add(uid); + } + } catch (err) { + this.logger.warn( + `Failed to resolve allBackoffice recipients: ${(err as Error).message}`, + ); + } + } + return [...ids]; } } diff --git a/apps/edr-freight-api/src/modules/procurement/dto/procurement.dto.ts b/apps/edr-freight-api/src/modules/procurement/dto/procurement.dto.ts new file mode 100644 index 000000000..943d79296 --- /dev/null +++ b/apps/edr-freight-api/src/modules/procurement/dto/procurement.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/procurement/entities/asset-acquisition.entity.ts b/apps/edr-freight-api/src/modules/procurement/entities/asset-acquisition.entity.ts new file mode 100644 index 000000000..d4f781c15 --- /dev/null +++ b/apps/edr-freight-api/src/modules/procurement/entities/asset-acquisition.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/procurement/entities/asset-disposal.entity.ts b/apps/edr-freight-api/src/modules/procurement/entities/asset-disposal.entity.ts new file mode 100644 index 000000000..301e3ec1c --- /dev/null +++ b/apps/edr-freight-api/src/modules/procurement/entities/asset-disposal.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/procurement/entities/vendor.entity.ts b/apps/edr-freight-api/src/modules/procurement/entities/vendor.entity.ts new file mode 100644 index 000000000..cbe394d16 --- /dev/null +++ b/apps/edr-freight-api/src/modules/procurement/entities/vendor.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/procurement/procurement.controller.ts b/apps/edr-freight-api/src/modules/procurement/procurement.controller.ts new file mode 100644 index 000000000..e5c69f37c --- /dev/null +++ b/apps/edr-freight-api/src/modules/procurement/procurement.controller.ts @@ -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); + } +} diff --git a/apps/edr-freight-api/src/modules/procurement/procurement.module.ts b/apps/edr-freight-api/src/modules/procurement/procurement.module.ts new file mode 100644 index 000000000..d4b0d8315 --- /dev/null +++ b/apps/edr-freight-api/src/modules/procurement/procurement.module.ts @@ -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 {} diff --git a/apps/edr-freight-api/src/modules/procurement/procurement.repository.ts b/apps/edr-freight-api/src/modules/procurement/procurement.repository.ts new file mode 100644 index 000000000..1a049d52b --- /dev/null +++ b/apps/edr-freight-api/src/modules/procurement/procurement.repository.ts @@ -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 { + constructor( + @InjectRepository(AssetAcquisition) + private readonly acquisitionRepository: Repository, + @InjectRepository(Vendor) + private readonly vendorRepository: Repository, + @InjectRepository(AssetDisposal) + private readonly disposalRepository: Repository, + ) { + super(acquisitionRepository); + } + + // ---- Vendors ---- + async createVendor(data: DeepPartial): Promise { + const vendor = this.vendorRepository.create(data); + return this.vendorRepository.save(vendor); + } + + async findVendors(): Promise { + return this.vendorRepository.find({ order: { createdAt: 'DESC' } }); + } + + async updateVendor(id: string, data: DeepPartial): Promise { + await this.vendorRepository.update(id, data as never); + return this.vendorRepository.findOneBy({ id }); + } + + async softDeleteVendor(id: string): Promise { + await this.vendorRepository.softDelete(id); + } + + // ---- Acquisitions ---- + async createAcquisition(data: DeepPartial): Promise { + const acquisition = this.acquisitionRepository.create(data); + return this.acquisitionRepository.save(acquisition); + } + + async findAcquisitions(vehicleId?: string): Promise { + return this.acquisitionRepository.find({ + where: vehicleId ? { vehicleId } : {}, + relations: ['vehicle', 'vendor'], + order: { acquisitionDate: 'DESC' }, + }); + } + + async findAcquisitionById(id: string): Promise { + return this.acquisitionRepository.findOne({ + where: { id }, + relations: ['vehicle', 'vendor'], + }); + } + + async updateAcquisition( + id: string, + data: DeepPartial, + ): Promise { + await this.acquisitionRepository.update(id, data as never); + return this.findAcquisitionById(id); + } + + async softDeleteAcquisition(id: string): Promise { + await this.acquisitionRepository.softDelete(id); + } + + async findLatestAcquisitionByVehicle(vehicleId: string): Promise { + return this.acquisitionRepository.findOne({ + where: { vehicleId }, + relations: ['vehicle', 'vendor'], + order: { acquisitionDate: 'DESC' }, + }); + } + + // ---- Disposals ---- + async createDisposal(data: DeepPartial): Promise { + const disposal = this.disposalRepository.create(data); + return this.disposalRepository.save(disposal); + } + + async findDisposals(): Promise { + return this.disposalRepository.find({ order: { disposalDate: 'DESC' } }); + } + + async softDeleteDisposal(id: string): Promise { + await this.disposalRepository.softDelete(id); + } + + async findLatestDisposalByVehicle(vehicleId: string): Promise { + return this.disposalRepository.findOne({ + where: { vehicleId }, + order: { disposalDate: 'DESC' }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/procurement/procurement.service.ts b/apps/edr-freight-api/src/modules/procurement/procurement.service.ts new file mode 100644 index 000000000..e799d5ff9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/procurement/procurement.service.ts @@ -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 { + return this.procurementRepository.createVendor(dto); + } + + async listVendors(): Promise { + return this.procurementRepository.findVendors(); + } + + async updateVendor(id: string, dto: UpdateVendorDto): Promise { + 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 { + return this.procurementRepository.createAcquisition(dto); + } + + async listAcquisitions(vehicleId?: string): Promise { + return this.procurementRepository.findAcquisitions(vehicleId); + } + + async getAcquisition(id: string): Promise { + return this.procurementRepository.findAcquisitionById(id); + } + + async updateAcquisition(id: string, dto: UpdateAcquisitionDto): Promise { + 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 { + return this.procurementRepository.createDisposal(dto); + } + + async listDisposals(): Promise { + 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 { + 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, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts index 905e827e8..289e502f1 100644 --- a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts @@ -78,6 +78,11 @@ describe('SchedulingRescheduleService', () => { bookingsRepository as never, trainSchedulingService as never, schedulingRescheduleRepository as never, + { + rescheduled: jest.fn(), + removedFromTrain: jest.fn(), + maintenanceMoved: jest.fn(), + } as never, // notifier ); }); diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts index dd20b100d..a9a3ae246 100644 --- a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts @@ -10,6 +10,7 @@ import { BookingsRepository } from '../bookings/bookings.repository'; import { compareSchedulingPriority } from '../scheduling/compare-scheduling-priority.util'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; +import { BookingNotifierService } from '../train-scheduling/booking-notifier.service'; import { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto'; import { SchedulingRescheduleRepository } from './scheduling-reschedule.repository'; @@ -38,6 +39,7 @@ export class SchedulingRescheduleService { private readonly bookingsRepository: BookingsRepository, private readonly trainSchedulingService: TrainSchedulingService, private readonly schedulingRescheduleRepository: SchedulingRescheduleRepository, + private readonly notifier: BookingNotifierService, ) {} /** Preview who is retained, displaced, and readmitted on a schedule. */ @@ -193,9 +195,64 @@ export class SchedulingRescheduleService { displacedBookingIds: dto.displacedBookingIds, }); + // Notify affected customers (SMS + email). Best-effort — a notification + // failure must never fail the reschedule, so each send is fire-and-forget + // inside the notifier. Government pre-empt already notifies via the batch + // displaced() path, so skip removed-from-train notices for that trigger. + // Use the new departure date when the reschedule moved it (the in-memory + // `schedule` still holds the pre-update date). + const effectiveDeparture = dto.newDepartureDate + ? new Date(dto.newDepartureDate) + : schedule.scheduledDepartureDate; + await this.notifyRescheduleOutcome(dto, effectiveDeparture); + return { plan, schedule: assignResult }; } + /** + * Fan out reschedule notifications: bookings that stayed on the train hear the + * new departure date; bookings dropped off the train (staff reschedule, not a + * government pre-empt) hear they were removed. Loads each booking with its + * company so the notifier has a phone/email to reach. + */ + private async notifyRescheduleOutcome( + dto: ExecuteRescheduleDto, + newDeparture: Date | null, + ): Promise { + const isMaintenance = dto.trigger === 'TRAIN_MAINTENANCE'; + const isGovPreempt = dto.trigger === 'GOVERNMENT_PREEMPT'; + + if (newDeparture) { + for (const bookingId of dto.finalBookingIds) { + const booking = await this.loadBookingForNotify(bookingId); + if (!booking) continue; + if (isMaintenance) { + this.notifier.maintenanceMoved(booking, newDeparture); + } else { + this.notifier.rescheduled(booking, newDeparture); + } + } + } + + // Government pre-empt displacements are already announced by the batch + // displaced() notice — don't double-notify. Staff reschedules are not. + if (!isGovPreempt) { + for (const bookingId of dto.displacedBookingIds) { + const booking = await this.loadBookingForNotify(bookingId); + if (!booking) continue; + this.notifier.removedFromTrain(booking); + } + } + } + + private async loadBookingForNotify(bookingId: string): Promise { + try { + return await this.bookingsRepository.findByIdWithFiles(bookingId); + } catch { + return null; + } + } + /** Maintenance shortcut: new departure + rebalance. */ async maintenanceReschedule( scheduleId: string, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index fd9b74d28..e2e339e9b 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -382,6 +382,18 @@ export class BookingBatchService implements OnModuleInit { this.logger.log( `Linked PAID booking ${booking.reference ?? bookingId} to schedule ${booking.trainScheduleId}`, ); + } else { + // Already linked at booking time (export FCFS: the customer books a + // specific train, so allocate() ran up front). allocate() is where the + // payment-settled tracking milestones are written, so on this branch we + // record them here — otherwise a paid, already-linked booking leaves + // FREIGHT_PAYMENT_SETTLED stuck PENDING and the clearance step never ticks. + void this.completeTrackingMilestones(bookingId, [ + "WAGON_REQUESTED", + "FREIGHT_PAYMENT_PENDING", + "FREIGHT_PAYMENT_SETTLED", + ]); + void this.markWagonAllocatedMilestone(bookingId); } const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( @@ -1517,6 +1529,12 @@ export class BookingBatchService implements OnModuleInit { "PREPAID", ); await this.notifier.payNow(booking, deadline); + // Customer tracking: a wagon slot is reserved and the freight pay window is + // open. Doc-trigger path — silent no-op for bookings without milestone rows. + void this.completeTrackingMilestones(booking.id, [ + "WAGON_REQUESTED", + "FREIGHT_PAYMENT_PENDING", + ]); } /** Allocate a booking to the schedule's train (creates the TrainScheduleBooking link). */ @@ -1548,6 +1566,15 @@ export class BookingBatchService implements OnModuleInit { this.notifier.secured(booking, reason); void this.triggerWagonAllocation(scheduleId); void this.markWagonAllocatedMilestone(booking.id); + // Customer tracking: freight payment settled (commercial pay-window path). + // Government allocations don't pay upfront — theirs stay pending. + if (reason === 'paid') { + void this.completeTrackingMilestones(booking.id, [ + 'WAGON_REQUESTED', + 'FREIGHT_PAYMENT_PENDING', + 'FREIGHT_PAYMENT_SETTLED', + ]); + } } private async markWagonAllocatedMilestone(bookingId: string): Promise { @@ -1559,6 +1586,27 @@ export class BookingBatchService implements OnModuleInit { } } + /** + * Complete customer-tracking milestones on lifecycle events via the + * doc-trigger path — a silent no-op for bookings without milestone rows + * (non-customs bookings). Never blocks the batch action. + */ + private async completeTrackingMilestones( + bookingId: string, + codes: string[], + ): Promise { + if (!this.milestoneService) return; + for (const code of codes) { + try { + await this.milestoneService.completeByDocTrigger({ bookingId }, code); + } catch (err) { + this.logger.warn( + `Milestone ${code} completion failed for booking ${bookingId}: ${(err as Error).message}`, + ); + } + } + } + /** * Expire an unpaid reservation and free its capacity. With day-level pooling we * also clear `trainScheduleId` so the booking is no longer pinned to the train diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index 49df19758..f1f63802e 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -1,13 +1,23 @@ import { Injectable, Logger } from '@nestjs/common'; +import { + NotificationAudience, + NotificationType, + NotifyInput, +} from '@edr/types'; import { Booking } from '../bookings/entities/booking.entity'; import { NotificationsService } from '../notifications/notifications.service'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { BATCH_TIMEZONE } from './booking-batch.constants'; @Injectable() export class BookingNotifierService { private readonly logger = new Logger(BookingNotifierService.name); - constructor(private readonly notifications: NotificationsService) {} + constructor( + private readonly notifications: NotificationsService, + private readonly inbox: NotificationInboxService, + ) {} private ref(b: Booking): string { return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`; @@ -41,11 +51,34 @@ export class BookingNotifierService { } } + /** Persist + push an in-app item to all portal users of the booking's company. */ + private inApp( + b: Booking, + title: string, + body: string, + overrides: Partial = {}, + ): void { + if (!b.companyId) return; // government/unlinked bookings have no portal users + void this.inbox.notify({ + recipients: { companyId: b.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.SCHEDULE_UPDATE, + title, + body, + link: `/bookings/${b.id}`, + data: { bookingId: b.id, reference: b.reference }, + ...overrides, + }); + } + async payNow(b: Booking, deadline: Date): Promise { const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000)); const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' }); const msg = `Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to secure train slot ${b.reference ?? b.id}. Deadline: ${eat} EAT.`; await this.notifyContact(b, msg, 'PAY NOW'); + this.inApp(b, 'Payment window open', msg, { + type: NotificationType.INVOICE_ISSUED, + }); } /** @@ -66,6 +99,9 @@ export class BookingNotifierService { `Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to accept and ship ${offeredWagons} wagon${offeredWagons === 1 ? '' : 's'} now ` + `(the rest returns to your contract to book later). If you do not pay, the booking stays whole and you can rebook in the next window. Deadline: ${eat} EAT.`; await this.notifyContact(b, msg, 'PAY NOW (PARTIAL)'); + this.inApp(b, 'Partial allocation offer', msg, { + type: NotificationType.INVOICE_ISSUED, + }); } secured(b: Booking, reason: 'paid' | 'gov'): void { @@ -73,11 +109,13 @@ export class BookingNotifierService { reason === 'gov' ? ' (government)' : '' }.`; void this.notifyContact(b, msg, 'ALLOCATED'); + this.inApp(b, 'Wagon allocated', msg); } expired(b: Booking): void { const msg = `Payment window expired for booking ${b.reference ?? b.id}. Reschedule or cancel — no re-approval needed.`; void this.notifyContact(b, msg, 'EXPIRED'); + this.inApp(b, 'Payment window expired', msg); } scheduleFull(b: Booking): void { @@ -100,5 +138,42 @@ export class BookingNotifierService { displaced(b: Booking): void { const msg = `Booking ${b.reference ?? b.id} was displaced by a government booking. Move to another schedule or cancel.`; void this.notifyContact(b, msg, 'DISPLACED'); + this.inApp(b, 'Booking displaced', msg); + } + + /** + * Staff rescheduled the train carrying this booking to a new departure date. + * The booking stays on the train — only the date moved. + */ + rescheduled(b: Booking, newDeparture: Date): void { + const when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE }); + const msg = `Booking ${b.reference ?? b.id} has been rescheduled. New departure date: ${when}.`; + void this.notifyContact(b, msg, 'RESCHEDULED'); + this.inApp(b, 'Booking rescheduled', msg); + } + + /** + * Booking was removed from its train during a staff reschedule (not a government + * pre-empt). It returns to eligible — the customer must rebook or reschedule. + */ + removedFromTrain(b: Booking): void { + const msg = + `Booking ${b.reference ?? b.id} has been removed from its train during rescheduling. ` + + `Please rebook or select a new schedule from the portal.`; + void this.notifyContact(b, msg, 'REMOVED FROM TRAIN'); + this.inApp(b, 'Removed from train', msg); + } + + /** + * The train carrying this booking was moved for maintenance to a new departure + * date. The booking stays on the train — only the date moved. + */ + maintenanceMoved(b: Booking, newDeparture: Date): void { + const when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE }); + const msg = + `The train for booking ${b.reference ?? b.id} was rescheduled for maintenance. ` + + `New departure date: ${when}.`; + void this.notifyContact(b, msg, 'MAINTENANCE RESCHEDULE'); + this.inApp(b, 'Train maintenance reschedule', msg); } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.spec.ts new file mode 100644 index 000000000..a439e442d --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.spec.ts @@ -0,0 +1,109 @@ +import { BOOKING_WINDOW_WS_EVENTS, BOOKING_WINDOW_WS_NAMESPACE } from '@edr/types'; +import { INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { io, type Socket } from 'socket.io-client'; + +import { WsAuthService } from '../notification-inbox/ws-auth.service'; +import { BookingWindowGateway } from './booking-window.gateway'; +import type { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; + +/** + * End-to-end proof the booking-window socket works: boots a real Nest app with + * the gateway, connects a real socket.io client to the namespace, emits a phase + * change, and asserts the client receives the exact payload. If this passes, + * any "no live update" report is environmental (stale server process, wrong + * checkout running, client not connecting) — not the gateway. + */ +describe('BookingWindowGateway (e2e)', () => { + let app: INestApplication; + let gateway: BookingWindowGateway; + let client: Socket; + let baseUrl: string; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [ + BookingWindowGateway, + // Accept any token — auth plumbing is covered by the real WsAuthService. + { provide: WsAuthService, useValue: { resolveUserId: async () => 'user-1' } }, + ], + }).compile(); + + app = moduleRef.createNestApplication(); + await app.listen(0); + const address = app.getHttpServer().address() as { port: number }; + baseUrl = `http://127.0.0.1:${address.port}`; + gateway = app.get(BookingWindowGateway); + }); + + afterAll(async () => { + client?.disconnect(); + await app?.close(); + }); + + it('authenticated client receives the phase event with the schedule state', async () => { + client = io(`${baseUrl}/${BOOKING_WINDOW_WS_NAMESPACE}`, { + auth: { token: 'any' }, + transports: ['websocket'], + }); + await new Promise((resolve, reject) => { + client.on('connect', () => resolve()); + client.on('connect_error', (err) => reject(err)); + }); + + const received = new Promise>((resolve) => { + client.on(BOOKING_WINDOW_WS_EVENTS.PHASE, (payload) => resolve(payload)); + }); + + gateway.emitPhase({ + id: 'sched-1', + originStationId: 'yard-a', + destinationStationId: 'yard-b', + direction: 'IMPORT', + windowPhase: 'OPEN', + bookingWindowStatus: 'OPEN', + bookingCycleNo: 2, + windowOpensAt: new Date('2026-07-06T16:15:00Z'), + windowClosesAt: new Date('2026-07-06T16:18:00Z'), + docReviewEndsAt: null, + paymentPhaseEndsAt: null, + scheduledDepartureDate: new Date('2026-07-09T05:53:00Z'), + } as unknown as TrainSchedule); + + const payload = await received; + expect(payload).toMatchObject({ + scheduleId: 'sched-1', + phase: 'OPEN', + bookingWindowStatus: 'OPEN', + bookingCycleNo: 2, + windowOpensAt: '2026-07-06T16:15:00.000Z', + }); + }); + + it('rejects a client whose token does not resolve to a user', async () => { + const moduleRef = await Test.createTestingModule({ + providers: [ + BookingWindowGateway, + { provide: WsAuthService, useValue: { resolveUserId: async () => null } }, + ], + }).compile(); + const rejectingApp = moduleRef.createNestApplication(); + await rejectingApp.listen(0); + const addr = rejectingApp.getHttpServer().address() as { port: number }; + + const rejected = io(`http://127.0.0.1:${addr.port}/${BOOKING_WINDOW_WS_NAMESPACE}`, { + auth: { token: 'bad' }, + transports: ['websocket'], + reconnection: false, + }); + const outcome = await new Promise((resolve) => { + rejected.on('disconnect', () => resolve('disconnected')); + rejected.on('connect_error', () => resolve('rejected')); + // The server accepts the transport then drops it in handleConnection. + setTimeout(() => resolve(rejected.connected ? 'still-connected' : 'disconnected'), 500); + }); + rejected.disconnect(); + await rejectingApp.close(); + expect(outcome).not.toBe('still-connected'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.ts index d1c1d34a1..699471d90 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.ts @@ -41,6 +41,9 @@ export class BookingWindowGateway implements OnGatewayConnection { return; } socket.data.userId = userId; + // Log at info so "is anyone actually connected?" is answerable from the + // API log when diagnosing missing live updates. + this.logger.log(`Booking-window client connected (user ${userId})`); } /** Push a schedule's current window state to every connected client. */ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index 12a78047c..429a03a05 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -2,12 +2,17 @@ import { Injectable, Logger, NotFoundException, OnModuleInit } from '@nestjs/com import { Cron } from '@nestjs/schedule'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; -import { TrainScheduleStatus as TrainScheduleStatusEnum } from '@edr/types'; +import { + NotificationAudience, + NotificationType, + TrainScheduleStatus as TrainScheduleStatusEnum, +} from '@edr/types'; import { Booking } from '../bookings/entities/booking.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; import { NotificationsService } from '../notifications/notifications.service'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { BookingBatchService } from './booking-batch.service'; import { BookingWindowGateway } from './booking-window.gateway'; import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service'; @@ -41,6 +46,7 @@ export class BookingWindowService implements OnModuleInit { private readonly bookingBatchService: BookingBatchService, private readonly trainSchedulingService: TrainSchedulingService, private readonly notifications: NotificationsService, + private readonly inbox: NotificationInboxService, private readonly gateway: BookingWindowGateway, ) {} @@ -380,22 +386,26 @@ export class BookingWindowService implements OnModuleInit { */ private async notifyWindowOpened(schedule: TrainSchedule): Promise { try { - const rows: Array<{ phone: string | null; email: string | null }> = - await this.dataSource.query( - `SELECT DISTINCT - COALESCE(co.contact_person_phone, co.phone) AS phone, - COALESCE(co.email, co.general_manager_email) AS email - FROM freight.contract_routes cr - JOIN freight.contracts c - ON c.id = cr.contract_id - AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED') - AND c.deleted_at IS NULL - JOIN freight.companies co ON co.id = c.company_id - WHERE cr.origin_yard_id = $1 - AND cr.destination_yard_id = $2 - AND cr.deleted_at IS NULL`, - [schedule.originStationId, schedule.destinationStationId], - ); + const rows: Array<{ + company_id: string; + phone: string | null; + email: string | null; + }> = await this.dataSource.query( + `SELECT DISTINCT + c.company_id, + COALESCE(co.contact_person_phone, co.phone) AS phone, + COALESCE(co.email, co.general_manager_email) AS email + FROM freight.contract_routes cr + JOIN freight.contracts c + ON c.id = cr.contract_id + AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED') + AND c.deleted_at IS NULL + JOIN freight.companies co ON co.id = c.company_id + WHERE cr.origin_yard_id = $1 + AND cr.destination_yard_id = $2 + AND cr.deleted_at IS NULL`, + [schedule.originStationId, schedule.destinationStationId], + ); if (!rows.length) return; const closes = schedule.windowClosesAt @@ -410,6 +420,7 @@ export class BookingWindowService implements OnModuleInit { const seenPhone = new Set(); const seenEmail = new Set(); + const seenCompany = new Set(); for (const r of rows) { if (r.phone && !seenPhone.has(r.phone)) { seenPhone.add(r.phone); @@ -423,9 +434,23 @@ export class BookingWindowService implements OnModuleInit { .directSend('email', r.email, msg) .catch((e) => this.logger.warn(`Window-open email failed: ${(e as Error).message}`)); } + // In-app inbox item for every portal user of each eligible company, + // deep-linking to the new-booking page. + if (r.company_id && !seenCompany.has(r.company_id)) { + seenCompany.add(r.company_id); + void this.inbox.notify({ + recipients: { companyId: r.company_id }, + audience: NotificationAudience.PORTAL, + type: NotificationType.SCHEDULE_UPDATE, + title: 'Booking window open', + body: msg, + link: '/bookings/new', + data: { trainScheduleId: schedule.id }, + }); + } } this.logger.log( - `Notified ${seenPhone.size} phone / ${seenEmail.size} email contacts of open window for schedule ${schedule.id}`, + `Notified ${seenPhone.size} phone / ${seenEmail.size} email / ${seenCompany.size} companies (in-app) of open window for schedule ${schedule.id}`, ); } catch (err) { this.logger.warn( diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts index e01e0d629..792fb7c64 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -33,6 +33,7 @@ import { WsAuthService } from '../notification-inbox/ws-auth.service'; import { BookingSplitService } from './booking-split.service'; import { BookingBatchOffer } from './entities/booking-batch-offer.entity'; import { NotificationsModule } from '../notifications/notifications.module'; +import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; import { ContractsModule } from '../contracts/contracts.module'; @Module({ @@ -56,6 +57,7 @@ import { ContractsModule } from '../contracts/contracts.module'; forwardRef(() => BookingsModule), BillingModule, NotificationsModule, + NotificationInboxModule, LocomotivesModule, WagonTypesModule, TrainSetsModule, @@ -76,6 +78,11 @@ import { ContractsModule } from '../contracts/contracts.module'; BookingSplitService, IntercityService, ], - exports: [TrainSchedulingService, BookingBatchService, BookingWindowService], + exports: [ + TrainSchedulingService, + BookingBatchService, + BookingWindowService, + BookingNotifierService, + ], }) export class TrainSchedulingModule {} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 16d5052bf..67eac7bd1 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -12,6 +12,7 @@ import { Injectable, Logger, NotFoundException, + Optional, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectDataSource } from '@nestjs/typeorm'; @@ -21,6 +22,7 @@ import { BookingsRepository } from '../bookings/bookings.repository'; import { Booking } from '../bookings/entities/booking.entity'; import { BookingContainer } from '../bookings/entities/booking-container.entity'; import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity'; +import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; import { Container } from '../container-management/entities/container.entity'; import { Locomotive } from '../locomotives/entities/locomotive.entity'; import { LocomotivesRepository } from '../locomotives/locomotives.repository'; @@ -274,9 +276,45 @@ export class TrainSchedulingService { private readonly warehouseInventoryService: WarehouseInventoryService, private readonly pdfDocuments: WarehouseReleaseDocumentService, private readonly bookingWindowGateway: BookingWindowGateway, + @Optional() private readonly milestoneService?: ClearanceMilestoneService, private readonly configService?: ConfigService, ) {} + /** + * Complete customer-tracking clearance milestones for every booking on a + * schedule when a physical lifecycle event fires (dispatch, arrive, load, + * unload, gatepass). Uses the doc-trigger path, which is a silent no-op for + * bookings without milestone rows (non-customs bookings), so this is safe to + * call for every direction and flow. Never blocks the operational action. + */ + private async completeMilestonesForScheduleBookings( + scheduleId: string, + codes: string[], + ): Promise { + if (!this.milestoneService || codes.length === 0) return; + try { + const rows: Array<{ booking_id: string }> = await this.dataSource.query( + `SELECT tsb.booking_id + FROM freight.train_schedule_bookings tsb + WHERE tsb.train_schedule_id = $1 + AND tsb.deleted_at IS NULL`, + [scheduleId], + ); + for (const { booking_id } of rows) { + for (const code of codes) { + await this.milestoneService.completeByDocTrigger( + { bookingId: booking_id }, + code, + ); + } + } + } catch (err) { + this.logger.warn( + `Milestone completion (${codes.join(', ')}) failed for schedule ${scheduleId}: ${(err as Error).message}`, + ); + } + } + /** * Push a schedule's current booking-window state over the socket so the * portal home card and backoffice GL/batch views update in real time — @@ -1121,26 +1159,32 @@ export class TrainSchedulingService { { country: schedule.destinationCountry }, ); if (direction === 'IMPORT') { + const result = await this.warehouseInventoryService.autoUnloadArrivedBookings( + scheduleId, + 'SYSTEM_TRAIN_ARRIVAL', + ); + // Customer tracking: cargo is off the train at the destination yard. + void this.completeMilestonesForScheduleBookings(scheduleId, ['OFFLOADED']); return { direction, action: 'IMPORT_AUTO_UNLOAD', status: 'COMPLETED', - result: await this.warehouseInventoryService.autoUnloadArrivedBookings( - scheduleId, - 'SYSTEM_TRAIN_ARRIVAL', - ), + result, }; } if (direction === 'EXPORT' && this.isDjiboutiPortDestination(`${schedule.destinationCode ?? ''} ${schedule.destinationName ?? ''}`)) { + const result = await this.warehouseInventoryService.autoUnloadExportAtDjibouti( + scheduleId, + 'SYSTEM_TRAIN_ARRIVAL', + ); + // Customer tracking: cargo is off the train at the Djibouti port. + void this.completeMilestonesForScheduleBookings(scheduleId, ['OFFLOADED']); return { direction, action: 'EXPORT_DJIBOUTI_AUTO_UNLOAD', status: 'COMPLETED', - result: await this.warehouseInventoryService.autoUnloadExportAtDjibouti( - scheduleId, - 'SYSTEM_TRAIN_ARRIVAL', - ), + result, }; } @@ -1443,6 +1487,20 @@ export class TrainSchedulingService { // Dispatch closed the window — drop it from portal/GL cards right away. void this.emitWindowState(scheduleId); + // Customer tracking: cargo is on the departing train — loading milestones + // plus the direction's "departed" handoff milestone. + if (schedule.direction === 'IMPORT' || schedule.direction === 'EXPORT') { + void this.completeMilestonesForScheduleBookings(scheduleId, [ + // CARGO_ARRIVED is export-only (cargo reached the origin yard) — the + // doc-trigger path no-ops it for import bookings. + 'CARGO_ARRIVED', + 'READY_FOR_LOADING', + 'LOADED', + schedule.direction === 'IMPORT' + ? 'DEPARTED_FROM_DJIBOUTI' + : 'DEPARTED_TO_DJIBOUTI', + ]); + } return this.getTrainScheduleById(scheduleId); } @@ -1599,6 +1657,13 @@ export class TrainSchedulingService { LoadingStatus.Loaded, ); } + // Customer tracking: staff confirmed cargo is on the wagons (CARGO_ARRIVED + // is the export-side "cargo reached origin yard" step that precedes it). + void this.completeMilestonesForScheduleBookings(scheduleId, [ + 'CARGO_ARRIVED', + 'READY_FOR_LOADING', + 'LOADED', + ]); return this.getTrainScheduleById(scheduleId); } @@ -1662,9 +1727,9 @@ export class TrainSchedulingService { performedBy: 'DOCUMENT_GENERATION', }); const html = this.buildImportLoadListHtml(loadList); - // Generic render — NOT the release-order fallback (would mislabel this as a - // gate-clearance / release order when Chromium is unavailable). - const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Import marshalling / load list'); + // Styled table-aware fallback (marshalling grid) when Chromium is unavailable — + // NOT the release-order fallback (would mislabel this as a gate-clearance order). + const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Import marshalling / load list'); const reference = loadList.trainNumber ?? loadList.trainScheduleId; return { filename: `import-marshalling-${this.safeDocumentName(reference)}.pdf`, @@ -1682,8 +1747,8 @@ export class TrainSchedulingService { } const html = this.buildExportLoadListHtml(schedule); - // Generic render — NOT the release-order fallback (see importLoadListDocument). - const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Export marshalling / load list'); + // Styled table-aware fallback (marshalling grid) — see importLoadListDocument. + const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Export marshalling / load list'); const reference = schedule.trainNumber ?? schedule.id; return { filename: `export-marshalling-${this.safeDocumentName(reference)}.pdf`, @@ -2417,6 +2482,13 @@ export class TrainSchedulingService { } }); + // Customer tracking: the train reached the corridor's far end. + if (schedule.direction === 'IMPORT' || schedule.direction === 'EXPORT') { + void this.completeMilestonesForScheduleBookings(scheduleId, [ + schedule.direction === 'IMPORT' ? 'ARRIVED_ETHIOPIA' : 'ARRIVED_AT_DJIBOUTI', + ]); + } + const detail = await this.getTrainScheduleById(scheduleId); const warehouseAutomation = await this.runWarehouseArrivalAutomation(scheduleId); return Object.assign(detail, { warehouseAutomation }); diff --git a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts index 9158ecca0..33d441ecb 100644 --- a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts +++ b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts @@ -65,4 +65,12 @@ export class CreateVehicleDto { @IsOptional() @IsUUID() locationId?: string; + + @IsOptional() + @IsNumber() + pricePerKm?: number; + + @IsOptional() + @IsString() + currency?: string; } diff --git a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts index 416cddee6..534019bc4 100644 --- a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts +++ b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts @@ -88,4 +88,29 @@ export class Vehicle extends BaseEntity { @Column({ name: 'location_id', type: 'uuid', nullable: true }) locationId?: string; + + // --- Haulage pricing --- + @Column({ name: 'price_per_km', type: 'numeric', precision: 14, scale: 2, nullable: true }) + pricePerKm?: number; + + /** Currency for pricePerKm: ETB | USD */ + @Column({ name: 'currency', type: 'varchar', length: 8, default: 'ETB' }) + currency?: string; + + // --- Compliance / expiry tracking --- + @Column({ name: 'vin', type: 'varchar', nullable: true }) + vin?: string; + + /** Owned | Leased | Rented */ + @Column({ name: 'ownership', type: 'varchar', nullable: true }) + ownership?: string; + + @Column({ name: 'insurance_expiry', type: 'date', nullable: true }) + insuranceExpiry?: string; + + @Column({ name: 'registration_expiry', type: 'date', nullable: true }) + registrationExpiry?: string; + + @Column({ name: 'next_inspection_date', type: 'date', nullable: true }) + nextInspectionDate?: string; } diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts index f0a77791a..737574dd9 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts @@ -10,7 +10,8 @@ import { ParseUUIDPipe, } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { FleetManage, FleetView } from '../../common/booking-guards'; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { VehiclesService } from './vehicles.service'; import { CreateVehicleDto } from './dto/create-vehicle.dto'; import { UpdateVehicleDto } from './dto/update-vehicle.dto'; @@ -19,7 +20,7 @@ import { FleetHistoryService } from '../fleet-history/fleet-history.service'; @ApiTags('vehicles') @ApiBearerAuth() @Controller('vehicles') -@FleetView() +@BookingStaff(FREIGHT_PERMS.vehicles.view) export class VehiclesController { constructor( private readonly vehiclesService: VehiclesService, @@ -27,7 +28,7 @@ export class VehiclesController { ) {} @Post() - @FleetManage() + @BookingStaff(FREIGHT_PERMS.vehicles.create) @ApiOperation({ summary: 'Create a new vehicle' }) create(@Body() createVehicleDto: CreateVehicleDto) { return this.vehiclesService.create(createVehicleDto); @@ -68,7 +69,7 @@ export class VehiclesController { } @Patch(':id') - @FleetManage() + @BookingStaff(FREIGHT_PERMS.vehicles.update) @ApiOperation({ summary: 'Update a vehicle' }) update( @Param('id', ParseUUIDPipe) id: string, @@ -78,7 +79,7 @@ export class VehiclesController { } @Delete(':id') - @FleetManage() + @BookingStaff(FREIGHT_PERMS.vehicles.delete) @ApiOperation({ summary: 'Delete a vehicle' }) remove(@Param('id', ParseUUIDPipe) id: string) { return this.vehiclesService.remove(id); diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts index 22e9f9491..680eb2591 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts @@ -2,7 +2,7 @@ import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsArray, IsEnum, IsInt, IsNumber, IsOptional, IsString, IsUUID, Matches, Min, ValidateNested } from 'class-validator'; -import { FEE_RULE_TYPES, FeeRuleType } from '../entities/warehouse-fee-rule.entity'; +import { FEE_RULE_BASES, FEE_RULE_TYPES, FeeRuleBasis, FeeRuleType } from '../entities/warehouse-fee-rule.entity'; export class FeeRuleTierDto { @ApiProperty({ example: 4 }) @@ -82,11 +82,25 @@ export class CreateFeeRuleDto { @Min(0) freeDays!: number; - @ApiProperty() + @ApiProperty({ description: 'Day-based fees: rate/day. Double handling: flat rate per basis unit.' }) @IsNumber() @Min(0) ratePerDay!: number; + @ApiPropertyOptional({ description: 'Truck detention only: grace window in hours (default 3).' }) + @IsOptional() + @IsInt() + @Min(0) + freeHours?: number; + + @ApiPropertyOptional({ + enum: FEE_RULE_BASES, + description: 'Double-handling charge basis: PER_CONTAINER | PER_TON | PER_ITEM.', + }) + @IsOptional() + @IsEnum(FEE_RULE_BASES) + basis?: FeeRuleBasis; + @ApiPropertyOptional({ type: [FeeRuleTierDto] }) @IsOptional() @IsArray() diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts index 38dffd235..f6ad7ec8f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts @@ -1,9 +1,23 @@ import { BaseEntity } from '@edr/api-common'; import { Column, Entity, Index } from 'typeorm'; -export const FEE_RULE_TYPES = ['STORAGE_FEE', 'DEMURRAGE_FEE'] as const; +export const FEE_RULE_TYPES = [ + 'STORAGE_FEE', + 'DEMURRAGE_FEE', + 'DOUBLE_HANDLING_FEE', + 'TRUCK_DETENTION_FEE', +] as const; export type FeeRuleType = (typeof FEE_RULE_TYPES)[number]; +/** + * Charge basis for a DOUBLE_HANDLING_FEE rule (flat rate × the chosen quantity): + * - PER_CONTAINER: booking container count + * - PER_TON: cargo total in tonnes (bulk cargo) + * - PER_ITEM: cargo total item count (break-bulk cargo, e.g. machinery) + */ +export const FEE_RULE_BASES = ['PER_CONTAINER', 'PER_TON', 'PER_ITEM'] as const; +export type FeeRuleBasis = (typeof FEE_RULE_BASES)[number]; + export interface WarehouseFeeTier { fromDay: number; toDay: number | null; @@ -57,9 +71,20 @@ export class WarehouseFeeRule extends BaseEntity { @Column({ name: 'free_days', type: 'int', default: 0 }) freeDays!: number; + // Truck detention only: grace window in HOURS before detention accrues + // (contract default 3h). Null/0 → the 3-hour default. + @Column({ name: 'free_hours', type: 'int', nullable: true }) + freeHours?: number | null; + @Column({ name: 'rate_per_day', type: 'numeric', precision: 14, scale: 2, default: 0 }) ratePerDay!: number; + // Double-handling only: PER_CONTAINER | PER_TON | PER_MACHINERY. The flat rate + // (rate_per_day, reused as rate-per-unit) is multiplied by the basis quantity; + // free days and tiers do not apply. Null for the day-based fee types. + @Column({ name: 'basis', type: 'varchar', length: 20, nullable: true }) + basis?: FeeRuleBasis | null; + @Column({ name: 'tiers', type: 'jsonb', default: () => "'[]'" }) tiers!: WarehouseFeeTier[]; diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts index cfc7fbf6c..c322482b2 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts @@ -3,7 +3,7 @@ import { ExchangeService } from '@edr/api-common'; import { DataSource } from 'typeorm'; import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto'; -import { FeeRuleType, WarehouseFeeRule, WarehouseFeeTier } from './entities/warehouse-fee-rule.entity'; +import { FeeRuleBasis, FeeRuleType, WarehouseFeeRule, WarehouseFeeTier } from './entities/warehouse-fee-rule.entity'; import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository'; interface ItemAttributes { @@ -16,6 +16,8 @@ interface ItemAttributes { containerTypeCode: string | null; inventoryQuantity: number; bookingContainerCount: number; + /** Booking cargo total in the cargo's unit of measure: tonnes (PER_TON) or item count (PER_ITEM). */ + cargoQuantity: number; facilityId: string | null; warehouseId: string | null; yardId: string | null; @@ -24,6 +26,8 @@ interface ItemAttributes { export interface FeePreview { ruleType: FeeRuleType; + /** Double-handling charge basis (PER_CONTAINER | PER_TON | PER_MACHINERY); null otherwise. */ + basis: FeeRuleBasis | null; ruleId: string | null; ruleName: string | null; freeDays: number; @@ -132,7 +136,8 @@ export class WarehouseFeeService { b.trade_direction AS "tradeDirection", COALESCE(cgt.code, booking_cgt.code) AS "cargoTypeCode", COALESCE(ctt.code, booking_ctt.code) AS "containerTypeCode", - COALESCE(container_lines.container_count, 0) AS "bookingContainerCount" + COALESCE(container_lines.container_count, 0) AS "bookingContainerCount", + COALESCE(b.cargo_total_weight_vgm, 0) AS "cargoQuantity" FROM freight.warehouse_inventory inv LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id LEFT JOIN freight.bookings b ON b.id = inv.booking_id @@ -283,6 +288,10 @@ export class WarehouseFeeService { now: Date, billingCurrency: string, ): Promise { + // Double handling is a flat charge (rate × basis quantity), not day-based. + if (ruleType === 'DOUBLE_HANDLING_FEE') { + return this.computeDoubleHandling(rule, item, now, billingCurrency); + } const start = item.arrivedAt ? new Date(item.arrivedAt) : null; const endDate = item.gateClearedAt ?? item.releaseDate ?? now; const endIsOpen = !item.gateClearedAt && !item.releaseDate; @@ -321,6 +330,7 @@ export class WarehouseFeeService { return { ruleType, + basis: null, ruleId: rule?.id ?? null, ruleName: rule?.name ?? null, freeDays, @@ -340,13 +350,66 @@ export class WarehouseFeeService { }; } + /** + * Double handling — a flat one-time charge, not time-based. Amount = rate × + * the basis quantity: PER_CONTAINER (booking container count), or PER_TON / + * PER_ITEM (the booking cargo total in the cargo's unit of measure — tonnes + * for bulk, item count for break-bulk). No free days, no elapsed days, no tiers. + */ + private async computeDoubleHandling( + rule: WarehouseFeeRule | null, + item: ItemAttributes, + now: Date, + billingCurrency: string, + ): Promise { + const basis: FeeRuleBasis = rule?.basis ?? 'PER_CONTAINER'; + const rate = Number(rule?.ratePerDay ?? 0); + const ruleCurrency = rule ? this.normalizeCurrency(rule.currency) : null; + const targetCurrency = this.normalizeCurrency(billingCurrency); + const isContainer = (item.freightType ?? '').toUpperCase() === 'CONTAINER'; + const inventoryQuantity = Math.max(1, Math.round(Number(item.inventoryQuantity) || 1)); + const containerCount = isContainer + ? Math.max(1, Math.round(Number(item.bookingContainerCount) || inventoryQuantity)) + : 1; + // PER_TON (tonnes) and PER_ITEM (piece count) both read the cargo total, + // which is stored in the cargo's own unit of measure. + const cargoQuantity = Math.max(0, Number(item.cargoQuantity) || 0); + const quantity = basis === 'PER_CONTAINER' ? containerCount : cargoQuantity; + const sourceAmount = Math.round(rate * quantity * 100) / 100; + const amount = ruleCurrency ? await this.convertAmount(sourceAmount, ruleCurrency, targetCurrency) : 0; + const convertedRate = ruleCurrency ? await this.convertAmount(rate, ruleCurrency, targetCurrency) : 0; + + return { + ruleType: 'DOUBLE_HANDLING_FEE', + basis, + ruleId: rule?.id ?? null, + ruleName: rule?.name ?? null, + freeDays: 0, + ratePerDay: convertedRate, + currency: targetCurrency, + ruleCurrency, + billingCurrency: targetCurrency, + startDate: null, + endDate: now.toISOString(), + endIsOpen: false, + elapsedDays: 0, + chargeableDays: 0, + containerCount, + billableUnits: quantity, + amount, + tiers: [], + }; + } + /** Preview demurrage + storage fees for an inventory item using the most specific active rules. */ async previewForInventory(inventoryId: string, billingCurrency = 'USD'): Promise { const item = await this.loadItem(inventoryId); const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } }); const now = new Date(); - const byType: FeeRuleType[] = ['DEMURRAGE_FEE', 'STORAGE_FEE']; + // Truck detention is a per-truck last-mile charge, not a per-inventory fee — + // it is computed separately via previewTruckDetention(), not here. + const byType: FeeRuleType[] = ['DEMURRAGE_FEE', 'STORAGE_FEE', 'DOUBLE_HANDLING_FEE']; return Promise.all( byType.map((type) => this.compute( @@ -359,4 +422,111 @@ export class WarehouseFeeService { ), ); } + + /** + * Truck detention preview for an EDR last-mile leg. The vehicle should be + * returned within the rule's grace window (default 3h) of arriving; beyond + * that, detention accrues per truck per day (flat rate/day or progressive + * tiers by detention day) until it is delivered/returned (or now, if open). + */ + async previewTruckDetention(lastMileId: string, billingCurrency = 'USD'): Promise { + const [row] = await this.dataSource.query( + `SELECT lm.arrived_at AS "arrivedAt", + lm.delivered_at AS "deliveredAt", + b.freight_type AS "freightType", + b.trade_direction AS "tradeDirection", + (SELECT count(*) FROM freight.last_mile_vehicle_assignments va + WHERE va.last_mile_id = lm.id AND va.deleted_at IS NULL) AS "truckCount" + FROM freight.last_mile lm + LEFT JOIN freight.bookings b ON b.id = lm.booking_id + WHERE lm.id = $1 AND lm.deleted_at IS NULL`, + [lastMileId], + ); + if (!row) throw new NotFoundException(`Last-mile record ${lastMileId} not found`); + + const item: ItemAttributes = { + arrivedAt: null, + gateClearedAt: null, + releaseDate: null, + freightType: row.freightType ?? null, + tradeDirection: row.tradeDirection ?? null, + cargoTypeCode: null, + containerTypeCode: null, + inventoryQuantity: 1, + bookingContainerCount: 1, + cargoQuantity: 0, + facilityId: null, + warehouseId: null, + yardId: null, + zoneId: null, + }; + const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } }); + const rule = this.bestRule( + rules.filter((r) => r.ruleType === 'TRUCK_DETENTION_FEE'), + item, + ); + return this.computeTruckDetention(rule, row, new Date(), billingCurrency); + } + + private async computeTruckDetention( + rule: WarehouseFeeRule | null, + row: { arrivedAt: Date | string | null; deliveredAt: Date | string | null; truckCount: number | string }, + now: Date, + billingCurrency: string, + ): Promise { + const graceHours = rule?.freeHours && Number(rule.freeHours) > 0 ? Number(rule.freeHours) : 3; + const truckCount = Math.max(1, Math.round(Number(row.truckCount) || 1)); + const start = row.arrivedAt ? new Date(row.arrivedAt) : null; + const end = row.deliveredAt ? new Date(row.deliveredAt) : now; + const endIsOpen = !row.deliveredAt; + + let chargeableDays = 0; + if (start) { + const detentionMs = end.getTime() - start.getTime() - graceHours * 60 * 60 * 1000; + chargeableDays = detentionMs > 0 ? Math.ceil(detentionMs / MS_PER_DAY) : 0; + } + + const ratePerDay = Number(rule?.ratePerDay ?? 0); + const ruleCurrency = rule ? this.normalizeCurrency(rule.currency) : null; + const targetCurrency = this.normalizeCurrency(billingCurrency); + const hasTiers = Boolean(rule?.tiers?.length); + const tiered = this.calculateTieredAmount(rule?.tiers, chargeableDays, truckCount); + const billableUnits = hasTiers ? tiered.billableUnits : chargeableDays * truckCount; + const sourceAmount = hasTiers ? tiered.sourceAmount : Math.round(billableUnits * ratePerDay * 100) / 100; + const amount = ruleCurrency ? await this.convertAmount(sourceAmount, ruleCurrency, targetCurrency) : 0; + const sourceRatePerDay = hasTiers ? tiered.weightedRatePerDay : ratePerDay; + const convertedRatePerDay = ruleCurrency + ? await this.convertAmount(sourceRatePerDay, ruleCurrency, targetCurrency) + : 0; + const convertedTiers = ruleCurrency + ? await Promise.all( + tiered.tiers.map(async (tier) => ({ + ...tier, + ratePerDay: await this.convertAmount(tier.ratePerDay, ruleCurrency, targetCurrency), + amount: await this.convertAmount(tier.amount, ruleCurrency, targetCurrency), + })), + ) + : []; + + return { + ruleType: 'TRUCK_DETENTION_FEE', + basis: null, + ruleId: rule?.id ?? null, + ruleName: rule?.name ?? null, + freeDays: 0, + ratePerDay: convertedRatePerDay, + currency: targetCurrency, + ruleCurrency, + billingCurrency: targetCurrency, + startDate: start ? start.toISOString() : null, + endDate: end.toISOString(), + endIsOpen, + elapsedDays: chargeableDays, + chargeableDays, + containerCount: truckCount, // reused as the per-truck count + billableUnits, + amount, + tiers: hasTiers ? convertedTiers : [], + }; + } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts index 8b16e3bec..a1c5db837 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts @@ -18,6 +18,15 @@ export class WarehouseInvoiceController { return this.invoiceService.generateForInventory(id, dto); } + @Post('last-mile/:id/generate-truck-detention-invoice') + @ApiOperation({ summary: 'Generate a truck-detention invoice for a last-mile leg (per truck per day)' }) + generateTruckDetention( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: GenerateInvoiceDto, + ) { + return this.invoiceService.generateTruckDetentionInvoice(id, dto); + } + @Get('warehouse-inventory/:id/fee-invoices') @ApiOperation({ summary: 'List fee invoices for an inventory item' }) listForInventory(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index 45591638a..4683b3bda 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -182,25 +182,38 @@ export class WarehouseInvoiceService { const items = previews .filter((p) => p.amount > 0) .map((p) => { - const feeType: WarehouseFeeType = - p.ruleType === "STORAGE_FEE" - ? "STORAGE_FEE" - : isContainer - ? "CONTAINER_DEMURRAGE" - : "BULK_DEMURRAGE"; + const days = `${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)`; + const tierSuffix = p.tiers.length ? " using tiered tariff" : ` after ${p.freeDays} free`; + let feeType: WarehouseFeeType; + let description: string; + switch (p.ruleType) { + case "STORAGE_FEE": + feeType = "STORAGE_FEE"; + description = `Storage fee - ${days}${tierSuffix}`; + break; + case "DOUBLE_HANDLING_FEE": { + feeType = "DOUBLE_HANDLING"; + const unit = + p.basis === "PER_TON" + ? "ton(s)" + : p.basis === "PER_ITEM" + ? "item(s)" + : "container(s)"; + description = `Double handling - ${p.billableUnits} ${unit}`; + break; + } + case "TRUCK_DETENTION_FEE": + feeType = "TRUCK_DETENTION"; + description = `Truck detention - ${days}${tierSuffix}`; + break; + default: + feeType = isContainer ? "CONTAINER_DEMURRAGE" : "BULK_DEMURRAGE"; + description = `${isContainer ? "Container" : "Bulk"} demurrage - ${days}${tierSuffix}`; + } return { feeRuleId: p.ruleId, feeType, - description: - p.ruleType === "STORAGE_FEE" - ? `Storage fee - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)${p.tiers.length - ? " using tiered tariff" - : ` after ${p.freeDays} free` - }` - : `${isContainer ? "Container" : "Bulk"} demurrage - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)${p.tiers.length - ? " using tiered tariff" - : ` after ${p.freeDays} free` - }`, + description, quantity: p.billableUnits, unitRate: p.ratePerDay, amount: p.amount, @@ -256,6 +269,79 @@ export class WarehouseInvoiceService { return detail; } + /** + * Generate a truck-detention invoice for a last-mile leg. Unlike warehouse fees + * (per inventory item), detention is a per-truck charge on the last-mile leg, so + * it becomes a `last_mile` invoice with its own `TRUCK_DETENTION_FEE` type — kept + * separate from the delivery-fee invoice. Returns the global Invoice. + */ + async generateTruckDetentionInvoice( + lastMileId: string, + opts: { billingCurrency?: "ETB" | "USD"; confirmZero?: boolean } = {}, + ): Promise { + const [lm] = await this.dataSource.query( + `SELECT lm.id, + b.company_id AS "companyId", + b.company_profile_id AS "companyProfileId", + b.payment_currency AS "paymentCurrency" + FROM freight.last_mile lm + LEFT JOIN freight.bookings b ON b.id = lm.booking_id + WHERE lm.id = $1 AND lm.deleted_at IS NULL`, + [lastMileId], + ); + if (!lm) throw new NotFoundException(`Last-mile record ${lastMileId} not found`); + if (!lm.companyId) { + throw new BadRequestException( + "Cannot invoice truck detention: the last-mile leg has no billable company (no associated booking).", + ); + } + + const existing = await this.billing.findPayable( + "last_mile" as Freight.InvoiceSource, + lastMileId, + "TRUCK_DETENTION_FEE", + ); + if (existing) { + throw new ConflictException( + "An active truck detention invoice already exists for this last-mile leg. Cancel it before generating a new one.", + ); + } + + const billingCurrency: "ETB" | "USD" = + opts.billingCurrency ?? (lm.paymentCurrency === "ETB" ? "ETB" : "USD"); + const preview = await this.feeService.previewTruckDetention(lastMileId, billingCurrency); + if (preview.amount <= 0 && !opts.confirmZero) { + throw new BadRequestException( + "No truck detention is currently payable for this last-mile leg.", + ); + } + + const truckCount = preview.containerCount; // reused as the per-truck count + const line: InvoiceLineInput = { + chargeType: "TRUCK_DETENTION", + description: `Truck detention - ${preview.chargeableDays} day(s) x ${truckCount} truck(s)${preview.tiers.length ? " using tiered tariff" : ""}`, + quantity: preview.billableUnits, + unitRate: preview.ratePerDay, + amount: preview.amount, + currency: preview.currency, + metadata: { + feeRuleId: preview.ruleId ?? null, + chargeableDays: preview.chargeableDays ?? null, + }, + }; + + return this.billing.generateInvoice({ + source: "last_mile" as Freight.InvoiceSource, + sourceId: lastMileId, + type: "TRUCK_DETENTION_FEE", + companyId: lm.companyId, + companyProfileId: lm.companyProfileId || "", + currency: billingCurrency, + lines: [line], + status: Freight.InvoiceStatus.Issued, + }); + } + // ── Reads ──────────────────────────────────────────────────────────────── async findById(id: string): Promise { const invoice = await this.loadWarehouseInvoice(id); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.types.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.types.ts index e201241ba..418816beb 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.types.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.types.ts @@ -26,6 +26,8 @@ export const WAREHOUSE_FEE_TYPES = [ 'BULK_DEMURRAGE', 'STORAGE_FEE', 'HANDLING_FEE', + 'DOUBLE_HANDLING', + 'TRUCK_DETENTION', ] as const; export type WarehouseFeeType = (typeof WAREHOUSE_FEE_TYPES)[number]; diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts index 1d8e3d9ea..1d02633d5 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { PdfRenderService } from '../billing/documents/pdf-render.service'; +import { buildTabularFallbackPdf } from '../billing/documents/styled-pdf.util'; const MIN_VALID_PDF_BYTES = 2_000; @@ -32,6 +33,19 @@ export class WarehouseReleaseDocumentService { return this.pdf.htmlToPdfBuffer(html, { label }); } + /** + * Render a "summary tiles + one table + notice + signatures" document (the + * marshalling / load-list layout) with a STYLED table-aware fallback for when + * Chromium is unavailable — so the manifest draws as a real gridded document + * instead of a flat plain-text dump. + */ + renderTabularDocument(html: string, label = 'Document'): Promise { + return this.pdf.htmlToPdfBuffer(html, { + label, + fallback: (preparedHtml) => buildTabularFallbackPdf(preparedHtml), + }); + } + /** * Render document HTML with a STYLED hand-built fallback (the release layout, * but with a custom title + section heading) for when Chromium is unavailable. diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts index 715080612..d35587d9e 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts @@ -85,4 +85,13 @@ export class WarehouseRulesController { ) { return this.feeService.previewForInventory(id, billingCurrency); } + + @Get('last-mile/:id/truck-detention-preview') + @ApiOperation({ summary: 'Preview truck detention for a last-mile leg (per truck per day after grace)' }) + truckDetentionPreview( + @Param('id', ParseUUIDPipe) id: string, + @Query('billingCurrency') billingCurrency?: string, + ) { + return this.feeService.previewTruckDetention(id, billingCurrency); + } } diff --git a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts index e5e3bb139..1aef33801 100644 --- a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts +++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts @@ -512,6 +512,32 @@ const CONTRACT_INTAKE_SETTINGS: OnboardingDocumentSetting[] = [ const CLEARANCE_DESCRIPTION = "Operation/clearance documents collected after contract execution, by operation, freight type and customs."; +// ── Driver documents ──────────────────────────────────────────────────────── +// Configurable upload area (code "driver_docs") attached to a driver profile — +// license, national ID, contracts, training certificates, etc. +const DRIVER_DOCUMENT_FIELDS: OnboardingField[] = [ + { + fileKey: "driver_docs", + fileLabel: "Driver documents", + helpText: "License, national ID, contracts, training certificates, etc.", + isRequired: false, + isMultiple: true, + maxFiles: 20, + allowedExtensions: ["pdf", "jpg", "jpeg", "png", "doc", "docx"], + maxSizeMb: 10, + displayOrder: 1, + }, +]; + +const DRIVER_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [ + { + code: "driver_docs", + label: "Driver documents", + entity: "driver", + fields: DRIVER_DOCUMENT_FIELDS, + }, +]; + @Injectable() export class FileUploadSettingsSeeder { private readonly logger = new Logger(FileUploadSettingsSeeder.name); @@ -549,6 +575,11 @@ export class FileUploadSettingsSeeder { description: "Commercial/framework documents attached at contract submission.", })), + ...DRIVER_DOCUMENT_SETTINGS.map((s) => ({ + ...s, + description: + "Documents uploaded against a driver profile (license, ID, contracts, etc.).", + })), ]; for (const documentSetting of allSettings) { diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index 4efc5c2d4..b89807b72 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -22,6 +22,7 @@ "@tabler/icons-react": "^3.44.0", "@tanstack/react-query": "^5.100.11", "@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.1.1.tgz", + "@vis.gl/react-google-maps": "^1.8.3", "axios": "^1.7.7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -44,6 +45,7 @@ "@edr/eslint-config": "workspace:*", "@edr/tsconfig": "workspace:*", "@tailwindcss/vite": "^4.3.0", + "@types/google.maps": "^3.65.2", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.2", diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 06454e885..df8db49d4 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -94,6 +94,10 @@ import { MaintenancePage } from "./pages/fleet/MaintenancePage"; import { FinancialReportsPage } from "./pages/fleet/FinancialReportsPage"; import { FleetDashboard } from "./pages/fleet/FleetDashboard"; import { TrackingPage } from "./pages/fleet/TrackingPage"; +import CompliancePage from "./pages/fleet/CompliancePage"; +import IncidentsPage from "./pages/fleet/IncidentsPage"; +import WorkOrdersPage from "./pages/fleet/WorkOrdersPage"; +import ProcurementPage from "./pages/fleet/ProcurementPage"; import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; @@ -212,13 +216,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "First Mile", href: "/dashboard/operations/first-mile", icon: , - permission: FREIGHT_PERMS.trainScheduling.view, + permission: FREIGHT_PERMS.firstMile.view, }, { label: "Last Mile", href: "/dashboard/operations/last-mile", icon: , - permission: FREIGHT_PERMS.trainScheduling.view, + permission: FREIGHT_PERMS.lastMile.view, }, ], }, @@ -229,7 +233,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Fleet Dashboard", href: "/dashboard/fleet-dashboard", icon: , - permission: FREIGHT_PERMS.fleet.view, + permission: FREIGHT_PERMS.fleetDashboard.view, }, { label: "Routes", @@ -259,43 +263,67 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Vehicles", href: "/dashboard/vehicles", icon: , - permission: FREIGHT_PERMS.fleet.view, + permission: FREIGHT_PERMS.vehicles.view, }, { label: "Drivers", href: "/dashboard/drivers", icon: , - permission: FREIGHT_PERMS.fleet.view, + permission: FREIGHT_PERMS.drivers.view, }, { label: "Track Vehicles", href: "/dashboard/tracking", icon: , - permission: FREIGHT_PERMS.fleet.view, + permission: FREIGHT_PERMS.tracking.view, }, { label: "Fuel Purchases", href: "/dashboard/fuel-purchases", icon: , - permission: FREIGHT_PERMS.fleet.view, + permission: FREIGHT_PERMS.fuel.view, }, { label: "Fuel Analytics", href: "/dashboard/fuel-stats", icon: , - permission: FREIGHT_PERMS.fleet.view, + permission: FREIGHT_PERMS.fuel.view, }, { label: "Maintenance", href: "/dashboard/maintenance", icon: , + permission: FREIGHT_PERMS.maintenance.view, + }, + { + label: "Work Orders", + href: "/dashboard/work-orders", + icon: , + permission: FREIGHT_PERMS.maintenance.view, + }, + { + label: "Compliance & Alerts", + href: "/dashboard/compliance", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Incidents", + href: "/dashboard/incidents", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Procurement", + href: "/dashboard/procurement", + icon: , permission: FREIGHT_PERMS.fleet.view, }, { label: "Financial Reports", href: "/dashboard/financial-reports", icon: , - permission: FREIGHT_PERMS.fleet.view, + permission: FREIGHT_PERMS.fleetReports.view, }, // { // label: "Containers", @@ -868,7 +896,7 @@ const App = () => { + } @@ -876,7 +904,7 @@ const App = () => { + } @@ -972,7 +1000,7 @@ const App = () => { + } @@ -980,7 +1008,7 @@ const App = () => { + } @@ -988,7 +1016,7 @@ const App = () => { + } @@ -996,7 +1024,7 @@ const App = () => { + } @@ -1058,7 +1086,7 @@ const App = () => { + } @@ -1066,7 +1094,7 @@ const App = () => { + } @@ -1074,7 +1102,7 @@ const App = () => { + } @@ -1082,7 +1110,7 @@ const App = () => { + } @@ -1090,7 +1118,7 @@ const App = () => { + } @@ -1098,11 +1126,43 @@ const App = () => { + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> (null); + const [rejectOpen, setRejectOpen] = useState(false); + const [rejectStepRow, setRejectStepRow] = + useState(null); + const [rejectReason, setRejectReason] = useState(""); const steps = useMemo( () => @@ -60,6 +65,28 @@ export function ContractApprovalStepsCard({ ); }; + const openReject = (step: Freight.IContractApprovalStep) => { + setRejectStepRow(step); + setRejectReason(""); + setRejectOpen(true); + }; + + const closeReject = () => { + setRejectOpen(false); + setRejectStepRow(null); + setRejectReason(""); + }; + + const trimmedReason = rejectReason.trim(); + + const runReject = () => { + if (!rejectStepRow || !trimmedReason) return; + mutations.rejectStep.mutate( + { stepId: rejectStepRow.id, reason: trimmedReason }, + { onSuccess: () => closeReject() }, + ); + }; + const subtitle = summary.detail || (nextPending @@ -106,8 +133,12 @@ export function ContractApprovalStepsCard({ key={step.id} step={step} isNext={nextPending?.id === step.id} - isPending={mutations.approveStep.isPending} + isPending={ + mutations.approveStep.isPending || + mutations.rejectStep.isPending + } onApprove={() => openApprove(step)} + onReject={() => openReject(step)} /> ))} @@ -149,6 +180,54 @@ export function ContractApprovalStepsCard({ + + + + + Rejecting the{" "} + + {rejectStepRow?.requiredRole} + {" "} + step rejects contract{" "} + + {contract.reference} + {" "} + outright. The customer must create a new contract — this cannot be + undone. + +