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/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index e66decb6c..9561a7b73 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -84,6 +84,10 @@ import { VehiclesModule } from "./modules/vehicles/vehicles.module"; import { DriversModule } from "./modules/drivers/drivers.module"; import { FuelModule } from "./modules/fuel/fuel.module"; import { MaintenanceModule } from "./modules/maintenance/maintenance.module"; +import { ComplianceModule } from "./modules/compliance/compliance.module"; +import { IncidentsModule } from "./modules/incidents/incidents.module"; +import { ProcurementModule } from "./modules/procurement/procurement.module"; +import { GpsTrackingModule } from "./modules/gps-tracking/gps-tracking.module"; import { FirstMileModule } from "./modules/first-mile/first-mile.module"; import { LastMileModule } from "./modules/last-mile/last-mile.module"; import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module"; @@ -172,6 +176,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/migrations/2010000000000-AddFeeRuleVehicleType.ts b/apps/edr-freight-api/src/migrations/2010000000000-AddFeeRuleVehicleType.ts new file mode 100644 index 000000000..a71f3cb5b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2010000000000-AddFeeRuleVehicleType.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Truck-detention rules can be scoped by vehicle type (TRUCK / VAN / TRAILER / + * TANKER / FLATBED / …), so different truck types carry different detention + * rates. Null = applies to any truck type. + */ +export class AddFeeRuleVehicleType2010000000000 implements MigrationInterface { + name = 'AddFeeRuleVehicleType2010000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.warehouse_fee_rules ADD COLUMN IF NOT EXISTS vehicle_type varchar(20)`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE freight.warehouse_fee_rules DROP COLUMN IF EXISTS vehicle_type`); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts index a07087f8f..06d164bbd 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts @@ -1,6 +1,16 @@ import { Injectable } from "@nestjs/common"; import { PdfRenderService } from "./pdf-render.service"; +import { + PdfColor, + assembleSinglePagePdf, + lineOp, + rectOp, + sealOp, + textOp, + textOpRight, + wrapText, +} from "./styled-pdf.util"; export type InvoiceDocumentKind = "INVOICE" | "RECEIPT"; @@ -62,10 +72,136 @@ export class InvoiceDocumentService { const kindLabel = model.kind === "RECEIPT" ? "receipt" : "invoice"; return { filename: `${this.safeFilename(model.documentNumber)}-${kindLabel}.pdf`, - buffer: await this.pdf.htmlToPdfBuffer(html, { label: `${model.title} ${kindLabel}` }), + buffer: await this.pdf.htmlToPdfBuffer(html, { + label: `${model.title} ${kindLabel}`, + // Chromium-less fallback: draw a genuine styled invoice (header, seal, + // summary grid, line-item table, totals) from the model — not a flat + // plain-text dump — so it still reads as a proper invoice document. + fallback: () => this.buildFallbackPdf(model), + }), }; } + /** + * Vector-drawn styled invoice/receipt used when headless Chromium is + * unavailable. Mirrors the HTML layout closely enough to pass as the same + * document. Single A4 page; long summaries / line lists are capped to fit. + */ + buildFallbackPdf(model: InvoiceDocumentModel): Buffer { + const currency = (cur?: string | null) => + (cur ?? model.currency) === "ETB" ? "ETB" : (cur ?? model.currency); + const money = (amount: unknown, cur?: string | null) => + `${Number(amount ?? 0).toLocaleString()} ${currency(cur)}`; + const date = (value: unknown) => + value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-"; + + const heading = `${model.title} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}`; + const sealText = + model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR"); + const showCategory = Boolean(model.categoryHeader); + + const ops: string[] = []; + + // ── Header ──────────────────────────────────────────────────────────── + ops.push(lineOp(36, 806, 559, 806, PdfColor.teal, 2.4)); + ops.push(textOp("ETHIO-DJIBOUTI RAILWAY S.C.", 36, 790, 8.5, "F2", PdfColor.gray)); + const titleSize = heading.length > 34 ? 18 : 22; + ops.push(textOp(heading, 36, 762, titleSize, "F2", PdfColor.dark)); + + ops.push(textOpRight("DOCUMENT NO.", 559, 792, 7.5, "F2", PdfColor.gray)); + ops.push(textOpRight(model.documentNumber, 559, 776, 12, "F2", PdfColor.dark)); + ops.push(textOpRight(`Issued ${date(model.issuedAt)}`, 559, 762, 8.5, "F1", PdfColor.gray)); + ops.push( + textOpRight( + `Status ${model.status}`, + 559, + 748, + 8.5, + "F1", + model.status === "PAID" ? PdfColor.teal : PdfColor.gray, + ), + ); + ops.push(lineOp(36, 736, 470, 736, PdfColor.line, 1)); + + // ── Seal ────────────────────────────────────────────────────────────── + ops.push(sealOp(516, 706, 27, sealText.split(/\s+/), PdfColor.teal)); + + // ── Summary grid (two columns) ──────────────────────────────────────── + let y = 700; + const colX = [36, 300]; + const colW = 250; + model.summary.slice(0, 16).forEach((row, i) => { + const x = colX[i % 2]; + if (i % 2 === 0 && i > 0) y -= 27; + ops.push(textOp((row.label ?? "").toUpperCase(), x, y, 7, "F1", PdfColor.gray)); + ops.push(textOp(this.clip(row.value ?? "-", 44), x, y - 11, 9, "F2", PdfColor.dark)); + ops.push(lineOp(x, y - 15, x + colW, y - 15, PdfColor.line, 0.6)); + }); + y -= 34; + + // ── Line-item table ─────────────────────────────────────────────────── + const qtyR = 402; + const rateR = 486; + const amtR = 555; + ops.push(rectOp(36, y - 18, 523, 18, PdfColor.shade, PdfColor.line, 0.7)); + ops.push(textOp("DESCRIPTION", 40, y - 13, 8, "F2", PdfColor.gray)); + if (showCategory) { + ops.push(textOp((model.categoryHeader ?? "").toUpperCase(), 250, y - 13, 8, "F2", PdfColor.gray)); + } + ops.push(textOpRight("QTY", qtyR, y - 13, 8, "F2", PdfColor.gray)); + ops.push(textOpRight("RATE", rateR, y - 13, 8, "F2", PdfColor.gray)); + ops.push(textOpRight("AMOUNT", amtR, y - 13, 8, "F2", PdfColor.gray)); + y -= 18; + + const descChars = showCategory ? 44 : 66; + for (const item of model.lines) { + if (y < 190) break; // leave room for totals + footer + const descLines = wrapText(item.description ?? "-", descChars).slice(0, 2); + const rowH = Math.max(18, descLines.length * 10 + 8); + ops.push(rectOp(36, y - rowH, 523, rowH, "1 1 1", PdfColor.line, 0.6)); + descLines.forEach((line, k) => { + ops.push(textOp(line, 40, y - 12 - k * 10, 8, "F1", PdfColor.dark)); + }); + if (showCategory) { + ops.push(textOp(this.clip((item.category ?? "").replace(/_/g, " "), 18), 250, y - 12, 8, "F1", PdfColor.dark)); + } + ops.push(textOpRight(String(item.quantity ?? 0), qtyR, y - 12, 8, "F1", PdfColor.dark)); + ops.push(textOpRight(money(item.unitRate, item.currency), rateR, y - 12, 8, "F1", PdfColor.dark)); + ops.push(textOpRight(money(item.amount, item.currency), amtR, y - 12, 8, "F1", PdfColor.dark)); + y -= rowH; + } + + // ── Totals ──────────────────────────────────────────────────────────── + let ty = y - 16; + for (const total of model.totals) { + if (ty < 88) break; + if (total.grand) { + ops.push(lineOp(315, ty + 5, 559, ty + 5, PdfColor.dark, 0.9)); + ops.push(textOp(total.label, 320, ty - 9, 11, "F2", PdfColor.dark)); + ops.push(textOpRight(money(total.amount), 555, ty - 9, 12, "F2", PdfColor.dark)); + ty -= 24; + } else { + ops.push(textOp(total.label, 320, ty - 8, 9.5, "F1", PdfColor.gray)); + ops.push(textOpRight(money(total.amount), 555, ty - 8, 10, "F1", PdfColor.dark)); + ty -= 17; + } + } + + // ── Footer ──────────────────────────────────────────────────────────── + ops.push(lineOp(36, 64, 250, 64, PdfColor.dark, 0.8)); + ops.push(textOp("Prepared by EDR finance", 36, 52, 7.5, "F1", PdfColor.gray)); + ops.push(lineOp(340, 64, 559, 64, PdfColor.dark, 0.8)); + ops.push(textOp("Authorized seal / signature", 340, 52, 7.5, "F1", PdfColor.gray)); + + return assembleSinglePagePdf(ops); + } + + /** Truncate to `max` chars with an ellipsis. */ + private clip(value: string, max: number): string { + const text = String(value ?? ""); + return text.length > max ? `${text.slice(0, max - 3)}...` : text; + } + buildHtml(model: InvoiceDocumentModel): string { const esc = (value: unknown) => String(value ?? "-") 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 new file mode 100644 index 000000000..b4f168e4e --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/documents/styled-pdf.util.ts @@ -0,0 +1,322 @@ +/** + * Minimal hand-built PDF primitives shared by the Chromium-less document + * fallbacks (invoices, receipts). These draw a genuine vector layout — boxes, + * rules, right-aligned money, a round seal — so a document still looks like a + * real document when headless Chromium is unavailable, instead of degrading to + * a flat plain-text dump. Coordinates are PDF user space (origin bottom-left, + * A4 = 595 x 842 pt). Fonts: F1 = Helvetica, F2 = Helvetica-Bold. + */ + +export const MIN_VALID_PDF_BYTES = 2_000; + +/** Colours as PDF "r g b" triples in the 0..1 range. */ +export const PdfColor = { + teal: "0.06 0.46 0.43", + dark: "0.06 0.09 0.16", + gray: "0.39 0.45 0.55", + line: "0.80 0.84 0.89", + shade: "0.96 0.97 0.98", + tint: "0.94 0.99 0.98", +} as const; + +export function escapePdfText(value: string): string { + return value + .replace(/\\/g, "\\\\") + .replace(/\(/g, "\\(") + .replace(/\)/g, "\\)") + .replace(/[^\x20-\x7e]/g, " "); +} + +/** Approximate rendered width of Helvetica text (slightly over-estimated so + * right-aligned text never crosses its column edge). */ +export function textWidth(text: string, size: number): number { + return text.length * size * 0.52; +} + +export function textOp( + text: string, + x: number, + y: number, + size: number, + font: "F1" | "F2" = "F1", + color: string = PdfColor.dark, +): string { + return `BT\n${color} rg\n/${font} ${size} Tf\n${x} ${y} Td\n(${escapePdfText(text)}) Tj\nET`; +} + +/** Right-align `text` so it ends at `rightX`. */ +export function textOpRight( + text: string, + rightX: number, + y: number, + size: number, + font: "F1" | "F2" = "F1", + color: string = PdfColor.dark, +): string { + return textOp(text, rightX - textWidth(text, size), y, size, font, color); +} + +export function lineOp( + x1: number, + y1: number, + x2: number, + y2: number, + color: string = PdfColor.line, + width = 0.8, +): string { + return `q\n${color} RG\n${width} w\n${x1} ${y1} m\n${x2} ${y2} l\nS\nQ`; +} + +export function rectOp( + x: number, + y: number, + width: number, + height: number, + fillColor = "1 1 1", + strokeColor: string = PdfColor.line, + lineWidth = 0.7, +): string { + return `q\n${fillColor} rg\n${strokeColor} RG\n${lineWidth} w\n${x} ${y} ${width} ${height} re\nB\nQ`; +} + +function circlePath(cx: number, cy: number, r: number): string { + const k = 0.5522847498; + const c = r * k; + return [ + `${cx + r} ${cy} m`, + `${cx + r} ${cy + c} ${cx + c} ${cy + r} ${cx} ${cy + r} c`, + `${cx - c} ${cy + r} ${cx - r} ${cy + c} ${cx - r} ${cy} c`, + `${cx - r} ${cy - c} ${cx - c} ${cy - r} ${cx} ${cy - r} c`, + `${cx + c} ${cy - r} ${cx + r} ${cy - c} ${cx + r} ${cy} c`, + "h", + ].join("\n"); +} + +/** A double-ring round rubber-stamp seal carrying up to three centred lines. */ +export function sealOp( + cx: number, + cy: number, + r: number, + lines: string[], + color: string = PdfColor.teal, +): string { + const rows = lines.slice(0, 3); + const ops = [ + "q", + `${color} RG`, + `${color} rg`, + "2 w", + circlePath(cx, cy, r), + "S", + "0.7 w", + circlePath(cx, cy, r - 6), + "S", + ]; + const startY = cy + (rows.length - 1) * 6; + rows.forEach((text, i) => { + const size = i === 0 ? 10 : 7.5; + ops.push(textOpRight(text, cx + textWidth(text, size) / 2, startY - i * 12 - 3, size, "F2", color)); + }); + ops.push("Q"); + 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[] = []; + for (const raw of String(text ?? "").split("\n")) { + const words = raw.split(/\s+/).filter(Boolean); + let line = ""; + for (const word of words) { + const next = line ? `${line} ${word}` : word; + if (next.length > maxChars && line) { + out.push(line); + line = word; + } else { + line = next; + } + } + if (line) out.push(line); + } + return out.length ? out : [""]; +} + +/** 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 ${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`, + ]; + + let pdf = "%PDF-1.4\n"; + const offsets: number[] = [0]; + objects.forEach((object, index) => { + offsets.push(Buffer.byteLength(pdf, "latin1")); + pdf += `${index + 1} 0 obj\n${object}\nendobj\n`; + }); + while (Buffer.byteLength(pdf, "latin1") < MIN_VALID_PDF_BYTES) { + pdf += "% fallback padding\n"; + } + const xrefOffset = Buffer.byteLength(pdf, "latin1"); + pdf += `xref\n0 ${objects.length + 1}\n`; + pdf += "0000000000 65535 f \n"; + for (const offset of offsets.slice(1)) { + pdf += `${String(offset).padStart(10, "0")} 00000 n \n`; + } + pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`; + return Buffer.from(pdf, "latin1"); +} 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/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/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/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 8a8492a6b..326f8e204 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 @@ -1766,9 +1766,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`, @@ -1786,8 +1786,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`, 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..a688f3e1a 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,30 @@ 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({ description: 'Truck detention only: scope by vehicle type (TRUCK | VAN | TRAILER | …). Null = any.' }) + @IsOptional() + @IsString() + vehicleType?: string; + + @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..a233143cf 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; @@ -41,6 +55,11 @@ export class WarehouseFeeRule extends BaseEntity { @Column({ name: 'container_type', type: 'varchar', length: 40, nullable: true }) containerType?: string | null; + // Truck detention only: scope by vehicle type (TRUCK | VAN | TRAILER | TANKER + // | FLATBED | …). Null = any truck type. + @Column({ name: 'vehicle_type', type: 'varchar', length: 20, nullable: true }) + vehicleType?: string | null; + @Column({ name: 'facility_id', type: 'uuid', nullable: true }) facilityId?: string | null; @@ -57,9 +76,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..14a3375c7 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 { @@ -14,8 +14,12 @@ interface ItemAttributes { tradeDirection: string | null; cargoTypeCode: string | null; containerTypeCode: string | null; + /** Vehicle type of the truck (truck detention scoping); null otherwise. */ + vehicleType: 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 +28,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; @@ -48,6 +54,16 @@ export interface FeePreview { ratePerDay: number; amount: number; }>; + /** Truck detention: per-vehicle-type breakdown — each truck-type group billed by its own matching rule. */ + groups?: Array<{ + vehicleType: string | null; + truckCount: number; + chargeableDays: number; + ratePerDay: number; + amount: number; + ruleId: string | null; + ruleName: string | null; + }>; } const MS_PER_DAY = 24 * 60 * 60 * 1000; @@ -132,7 +148,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 @@ -189,6 +206,7 @@ export class WarehouseFeeService { if (!check(rule.tradeDirection, item.tradeDirection, { allowBoth: true })) return null; if (!check(rule.cargoTypeCode, item.cargoTypeCode)) return null; if (!check(rule.containerType, item.containerTypeCode)) return null; + if (!check(rule.vehicleType, item.vehicleType)) return null; if (!check(rule.facilityId, item.facilityId)) return null; if (!check(rule.warehouseId, item.warehouseId)) return null; if (!check(rule.yardId, item.yardId)) return null; @@ -283,6 +301,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 +343,7 @@ export class WarehouseFeeService { return { ruleType, + basis: null, ruleId: rule?.id ?? null, ruleName: rule?.name ?? null, freeDays, @@ -340,13 +363,68 @@ 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); + // Double handling applies to IMPORT only — no charge for export/domestic. + const isImport = (item.tradeDirection ?? '').toUpperCase() === 'IMPORT'; + const quantity = !isImport ? 0 : 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 +437,197 @@ 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 [leg] = 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" + 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 (!leg) throw new NotFoundException(`Last-mile record ${lastMileId} not found`); + + // Truck detention applies to IMPORT only — no charge for export/domestic. + if ((leg.tradeDirection ?? '').toUpperCase() !== 'IMPORT') { + const cur = this.normalizeCurrency(billingCurrency); + return { + ruleType: 'TRUCK_DETENTION_FEE', + basis: null, + ruleId: null, + ruleName: null, + freeDays: 0, + ratePerDay: 0, + currency: cur, + ruleCurrency: null, + billingCurrency: cur, + startDate: leg.arrivedAt ? new Date(leg.arrivedAt).toISOString() : null, + endDate: (leg.deliveredAt ? new Date(leg.deliveredAt) : new Date()).toISOString(), + endIsOpen: !leg.deliveredAt, + elapsedDays: 0, + chargeableDays: 0, + containerCount: 0, + billableUnits: 0, + amount: 0, + tiers: [], + groups: [], + }; + } + + // Group the leg's vehicles by type so each truck type is billed by its own + // matching rule (rates differ by truck type). Falls back to one untyped group. + const groupRows: Array<{ vehicleType: string | null; truckCount: number | string }> = + await this.dataSource.query( + `SELECT v.vehicle_type AS "vehicleType", count(*)::int AS "truckCount" + FROM freight.last_mile_vehicle_assignments va + JOIN freight.vehicles v ON v.id = va.vehicle_id AND v.deleted_at IS NULL + WHERE va.last_mile_id = $1 AND va.deleted_at IS NULL + GROUP BY v.vehicle_type`, + [lastMileId], + ); + const groups = groupRows.length ? groupRows : [{ vehicleType: null, truckCount: 1 }]; + + const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } }); + const detentionRules = rules.filter((r) => r.ruleType === 'TRUCK_DETENTION_FEE'); + const now = new Date(); + const targetCurrency = this.normalizeCurrency(billingCurrency); + + const computed = await Promise.all( + groups.map(async (g) => { + const item: ItemAttributes = { + arrivedAt: null, + gateClearedAt: null, + releaseDate: null, + freightType: leg.freightType ?? null, + tradeDirection: leg.tradeDirection ?? null, + cargoTypeCode: null, + containerTypeCode: null, + vehicleType: g.vehicleType ?? null, + inventoryQuantity: 1, + bookingContainerCount: 1, + cargoQuantity: 0, + facilityId: null, + warehouseId: null, + yardId: null, + zoneId: null, + }; + const rule = this.bestRule(detentionRules, item); + const c = await this.computeTruckDetention( + rule, + { arrivedAt: leg.arrivedAt, deliveredAt: leg.deliveredAt, truckCount: g.truckCount }, + now, + billingCurrency, + ); + return { vehicleType: g.vehicleType ?? null, truckCount: Math.max(1, Math.round(Number(g.truckCount) || 1)), c }; + }), + ); + + const totalAmount = Math.round(computed.reduce((s, x) => s + x.c.amount, 0) * 100) / 100; + const totalTrucks = computed.reduce((s, x) => s + x.truckCount, 0); + const totalBillable = computed.reduce((s, x) => s + x.c.billableUnits, 0); + const chargeableDays = computed[0]?.c.chargeableDays ?? 0; + const single = computed.length === 1 ? computed[0].c : null; + const anyRuleName = computed.find((x) => x.c.ruleId)?.c.ruleName ?? null; + + return { + ruleType: 'TRUCK_DETENTION_FEE', + basis: null, + ruleId: single?.ruleId ?? null, + ruleName: single ? single.ruleName : computed.length > 1 && anyRuleName ? 'Per truck-type rules' : anyRuleName, + freeDays: 0, + ratePerDay: single?.ratePerDay ?? 0, + currency: targetCurrency, + ruleCurrency: single?.ruleCurrency ?? null, + billingCurrency: targetCurrency, + startDate: leg.arrivedAt ? new Date(leg.arrivedAt).toISOString() : null, + endDate: (leg.deliveredAt ? new Date(leg.deliveredAt) : now).toISOString(), + endIsOpen: !leg.deliveredAt, + elapsedDays: chargeableDays, + chargeableDays, + containerCount: totalTrucks, + billableUnits: totalBillable, + amount: totalAmount, + tiers: single ? single.tiers : [], + groups: computed.map((x) => ({ + vehicleType: x.vehicleType, + truckCount: x.truckCount, + chargeableDays: x.c.chargeableDays, + ratePerDay: x.c.ratePerDay, + amount: x.c.amount, + ruleId: x.c.ruleId, + ruleName: x.c.ruleName, + })), + }; + } + + 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-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index cd1aa432c..84baaf4da 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -100,6 +100,27 @@ export class WarehouseInventoryController { return this.inventoryService.loadedExport(); } + @Get('loadable-trains') + @ApiOperation({ summary: 'EXPORT trains (pre-dispatch) with inventory waiting to be loaded' }) + loadableTrains() { + return this.inventoryService.loadableTrains(); + } + + @Get('train/:scheduleId/loadable-items') + @ApiOperation({ summary: 'Container/cargo inventory assigned to a train, with allocated wagons' }) + trainLoadableItems(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) { + return this.inventoryService.trainLoadableItems(scheduleId); + } + + @Post('train/:scheduleId/load') + @ApiOperation({ summary: 'Load selected inventory items onto their allocated wagons for a train' }) + loadItemsOntoTrain( + @Param('scheduleId', ParseUUIDPipe) scheduleId: string, + @Body() dto: { inventoryIds: string[]; performedBy?: string }, + ) { + return this.inventoryService.loadItemsOntoTrain(scheduleId, dto.inventoryIds ?? [], dto.performedBy); + } + @Post('bulk-dispatch-export') @ApiOperation({ summary: 'Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED)' }) bulkDispatchExport(@Body() dto: { inventoryIds: string[]; performedBy?: string }) { @@ -333,6 +354,12 @@ export class WarehouseInventoryController { return this.handoverService.list(bookingId); } + @Get('bookings/:bookingId/container-items') + @ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' }) + containerItems(@Param('bookingId', ParseUUIDPipe) bookingId: string) { + return this.inventoryService.containerItems(bookingId); + } + @Post(':id/deliver') @ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' }) deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index ee3e7c9c8..d2562fe93 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -273,6 +273,45 @@ export interface BulkDispatchResult { results: { inventoryId: string; status: string; reason?: string }[]; } +/** An EXPORT train (pre-dispatch schedule) that has inventory waiting to be loaded. */ +export interface LoadableTrainRow { + scheduleId: string; + trainNumber: string | null; + origin: string | null; + destination: string | null; + status: string; + departureTime: string | Date | null; + /** Received/ready inventory not yet loaded onto this train. */ + readyCount: number; + /** Inventory already loaded onto this train. */ + loadedCount: number; +} + +/** A warehouse-inventory item (container/cargo) assigned to a train, with its allocated wagon. */ +export interface TrainLoadableItemRow { + id: string; + bookingId: string | null; + bookingReference: string | null; + customerName: string | null; + containerNumber: string | null; + cargoType: string | null; + weight: number | null; + grnNumber: string | null; + inspectionStatus: string | null; + status: string; + wagonId: string | null; + wagonNumber: string | null; + sequenceNo: number | null; + /** True only when the item is READY_FOR_LOADING and has an allocated wagon. */ + loadable: boolean; +} + +export interface TrainLoadResult { + loadedCount: number; + skippedCount: number; + results: { inventoryId: string; status: string; reason?: string }[]; +} + export interface AutoUnloadArrivedResult { unloadedCount: number; skippedCount: number; @@ -1070,6 +1109,169 @@ export class WarehouseInventoryService { return this.exportInventoryByStatus('LOADED'); } + // ── Per-train loading (Load to Train tab) ───────────────────────────────── + // Loading follows wagon allocation: staff pick an allocated EXPORT train, see + // the arrived containers/cargoes assigned to it, and load the ready ones onto + // their already-allocated wagons. Reuses the single-item load() machinery. + + /** Pre-dispatch EXPORT trains that have inventory waiting to be (or already) loaded. */ + async loadableTrains(): Promise { + const rows: Array< + LoadableTrainRow & { originCountry: string | null; destinationCountry: string | null } + > = await this.dataSource.query( + `SELECT ts.id AS "scheduleId", + ts.train_number AS "trainNumber", + oy.code AS "origin", + dy.code AS "destination", + oy.country AS "originCountry", + dy.country AS "destinationCountry", + ts.status AS "status", + ts.scheduled_departure_date AS "departureTime", + (SELECT count(*) FROM freight.train_schedule_bookings tsb + JOIN freight.warehouse_inventory inv + ON inv.booking_id = tsb.booking_id AND inv.deleted_at IS NULL + WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL + AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING')) AS "readyCount", + (SELECT count(*) FROM freight.train_schedule_bookings tsb + JOIN freight.warehouse_inventory inv + ON inv.booking_id = tsb.booking_id AND inv.deleted_at IS NULL + WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL + AND inv.status = 'LOADED') AS "loadedCount" + FROM freight.train_schedules ts + LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id + LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id + WHERE ts.deleted_at IS NULL + AND ts.status = ANY($1) + AND EXISTS ( + SELECT 1 FROM freight.train_schedule_bookings tsb2 + JOIN freight.warehouse_inventory inv2 + ON inv2.booking_id = tsb2.booking_id AND inv2.deleted_at IS NULL + WHERE tsb2.train_schedule_id = ts.id AND tsb2.deleted_at IS NULL + AND inv2.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED') + ) + ORDER BY ts.scheduled_departure_date ASC NULLS LAST`, + [['DRAFT', 'SCHEDULED']], + ); + + return rows + .filter( + (r) => + deriveTradeDirection({ country: r.originCountry }, { country: r.destinationCountry }) === 'EXPORT', + ) + .map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => ({ + ...rest, + readyCount: Number(rest.readyCount) || 0, + loadedCount: Number(rest.loadedCount) || 0, + })); + } + + /** + * Container/cargo inventory items assigned to a train, with the wagon each is + * allocated to. Covers the arrived-but-not-loaded set (RECEIVED..READY_FOR_LOADING) + * plus already-LOADED items, so the "Received" and "Loaded" stage tabs both fill. + */ + async trainLoadableItems(scheduleId: string): Promise { + const rows: Array> = await this.dataSource.query( + `SELECT inv.id AS "id", + inv.booking_id AS "bookingId", + b.reference AS "bookingReference", + company.name AS "customerName", + ct.container_number AS "containerNumber", + COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType", + inv.weight AS "weight", + substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') AS "grnNumber", + inv.inspection_status AS "inspectionStatus", + inv.status AS "status", + wl.wagon_id AS "wagonId", + wl.wagon_number AS "wagonNumber", + wl.sequence_no AS "sequenceNo" + FROM freight.train_schedule_bookings tsb + JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id + JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL + JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL + LEFT JOIN freight.companies company ON company.id = b.company_id + LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id + LEFT JOIN freight.containers ct ON ct.id = inv.container_id + LEFT JOIN LATERAL ( + SELECT w.id AS wagon_id, w.wagon_number, tsw.sequence_no + FROM freight.wagon_booking_allocations wba + JOIN freight.train_set_wagons tsw + ON tsw.id = wba.train_set_wagon_id + AND tsw.train_set_id = ts.train_set_id + AND tsw.deleted_at IS NULL + JOIN freight.wagons w ON w.id = tsw.physical_wagon_id AND w.deleted_at IS NULL + WHERE wba.booking_id = b.id AND wba.deleted_at IS NULL + ORDER BY tsw.sequence_no ASC NULLS LAST + LIMIT 1 + ) wl ON true + WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL + AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED') + ORDER BY wl.sequence_no ASC NULLS LAST, b.reference ASC NULLS LAST, ct.container_number ASC NULLS LAST`, + [scheduleId], + ); + + return rows.map((r) => ({ + ...r, + loadable: r.status === 'READY_FOR_LOADING' && Boolean(r.wagonId), + })); + } + + /** + * Load the selected inventory items onto their allocated wagons for the given + * train. Each item must be assigned to this train, READY_FOR_LOADING, and have + * an allocated wagon; others are skipped with a reason. When every inventory + * item of a booking is loaded, its train_schedule_bookings.loading_status flips + * to LOADED so the train's confirm-loading/dispatch step reflects reality. + */ + async loadItemsOntoTrain( + scheduleId: string, + inventoryIds: string[], + performedBy?: string, + ): Promise { + const result: TrainLoadResult = { loadedCount: 0, skippedCount: 0, results: [] }; + const items = await this.trainLoadableItems(scheduleId); + const byId = new Map(items.map((i) => [i.id, i])); + const affectedBookingIds = new Set(); + + for (const inventoryId of inventoryIds) { + const skip = (reason: string) => { + result.skippedCount += 1; + result.results.push({ inventoryId, status: 'SKIPPED', reason }); + }; + const item = byId.get(inventoryId); + if (!item) { skip('Not assigned to this train'); continue; } + if (item.status === 'LOADED') { skip('Already loaded'); continue; } + if (item.status !== 'READY_FOR_LOADING') { skip(`Not ready for loading (status ${item.status})`); continue; } + if (!item.wagonId) { skip('No wagon allocated — allocate a wagon first'); continue; } + + try { + await this.load(inventoryId, { wagonId: item.wagonId, loadedBy: performedBy }); + result.loadedCount += 1; + result.results.push({ inventoryId, status: 'LOADED' }); + if (item.bookingId) affectedBookingIds.add(item.bookingId); + } catch (error) { + skip(error instanceof Error ? error.message : 'Load failed'); + } + } + + // Flip a booking's train loading_status to LOADED once no un-loaded inventory remains. + for (const bookingId of affectedBookingIds) { + await this.dataSource.query( + `UPDATE freight.train_schedule_bookings tsb + SET loading_status = 'LOADED', updated_at = NOW() + WHERE tsb.train_schedule_id = $1 AND tsb.booking_id = $2 AND tsb.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM freight.warehouse_inventory inv + WHERE inv.booking_id = $2 AND inv.deleted_at IS NULL + AND inv.status NOT IN ('LOADED', 'DISPATCHED') + )`, + [scheduleId, bookingId], + ); + } + + return result; + } + /** Shared query for the import queues — IMPORT inventory at the given statuses, inspection columns. */ private async importQueueByStatuses(statuses: string[]): Promise { const rows: Array< @@ -2308,6 +2510,95 @@ export class WarehouseInventoryService { }; } + /** + * Per-container (or bulk) items of a booking with their lifecycle stage and + * reference sources — drives the container-level detail datatable (stage tabs, + * multiselect load-to-truck, per-item actions). + */ + async containerItems(bookingId: string): Promise< + Array<{ + containerNumber: string; + goods: string | null; + stage: 'PENDING' | 'RECEIVED' | 'GRN' | 'LOADED' | 'LEFT' | 'DELIVERED'; + grnNumber: string | null; + truckAssignmentId: string | null; + truckPlate: string | null; + truckArrived: boolean; + truckLeft: boolean; + bookingReference: string | null; + contractId: string | null; + hasLastMile: boolean; + }> + > { + const rows: Array<{ + containerNumber: string; + goods: string | null; + received: boolean; + grnNumber: string | null; + truckAssignmentId: string | null; + truckPlate: string | null; + truckArrived: boolean; + truckLeft: boolean; + bookingReference: string | null; + contractId: string | null; + hasLastMile: boolean; + delivered: boolean; + }> = await this.dataSource.query( + `SELECT bcu.container_number AS "containerNumber", + COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods, + bcu.received_to_port AS received, + bcu.grn_number AS "grnNumber", + ctc.assignment_id AS "truckAssignmentId", + a.plate_number AS "truckPlate", + (a.arrived_at IS NOT NULL) AS "truckArrived", + (a.departed_at IS NOT NULL) AS "truckLeft", + b.reference AS "bookingReference", + b.contract_id AS "contractId", + (b.last_mile_delivery_address IS NOT NULL) AS "hasLastMile", + COALESCE(inv.status = 'DELIVERED', false) AS delivered + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + JOIN freight.bookings b ON b.id = bc.booking_id + LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id + LEFT JOIN freight.customer_truck_containers ctc + ON ctc.container_number = bcu.container_number + AND ctc.booking_id = b.id AND ctc.deleted_at IS NULL + LEFT JOIN freight.customer_truck_assignments a + ON a.id = ctc.assignment_id AND a.deleted_at IS NULL + LEFT JOIN freight.containers cont ON cont.container_number = bcu.container_number + LEFT JOIN freight.warehouse_inventory inv + ON inv.container_id = cont.id AND inv.deleted_at IS NULL + WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL + ORDER BY bcu.container_number`, + [bookingId], + ); + + return rows.map((r) => ({ + containerNumber: r.containerNumber, + goods: r.goods, + stage: r.delivered + ? 'DELIVERED' + : r.truckLeft + ? 'LEFT' + : r.truckAssignmentId + ? 'LOADED' + : r.grnNumber + ? 'GRN' + : r.received + ? 'RECEIVED' + : 'PENDING', + grnNumber: r.grnNumber, + truckAssignmentId: r.truckAssignmentId, + truckPlate: r.truckPlate, + truckArrived: r.truckArrived, + truckLeft: r.truckLeft, + bookingReference: r.bookingReference, + contractId: r.contractId, + hasLastMile: r.hasLastMile, + })); + } + /** * Per-truck exit paper: one paper covering the containers loaded on a specific * customer truck (used when multiple trucks leave separately). Gated on the 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..9ee1dc398 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,104 @@ 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.", + ); + } + + // One line per truck-type group (each billed by its own matching rule). Groups + // with no matching rule bill 0 and are dropped. Falls back to a single line. + const groups = preview.groups && preview.groups.length ? preview.groups : null; + const lines: InvoiceLineInput[] = groups + ? groups + .filter((g) => g.amount > 0) + .map((g) => ({ + chargeType: "TRUCK_DETENTION", + description: `Truck detention${g.vehicleType ? ` (${g.vehicleType})` : ""} - ${g.chargeableDays} day(s) x ${g.truckCount} truck(s)`, + quantity: g.truckCount * g.chargeableDays, + unitRate: g.ratePerDay, + amount: g.amount, + currency: preview.currency, + metadata: { + feeRuleId: g.ruleId ?? null, + chargeableDays: g.chargeableDays, + vehicleType: g.vehicleType ?? null, + }, + })) + : [ + { + chargeType: "TRUCK_DETENTION", + description: `Truck detention - ${preview.chargeableDays} day(s) x ${preview.containerCount} truck(s)`, + quantity: preview.billableUnits, + unitRate: preview.ratePerDay, + amount: preview.amount, + currency: preview.currency, + metadata: { + feeRuleId: preview.ruleId ?? null, + chargeableDays: preview.chargeableDays ?? null, + }, + }, + ]; + if (lines.length === 0) { + throw new BadRequestException( + "No truck detention is currently payable for this last-mile leg.", + ); + } + + 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, + 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 = () => { + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + setValues((current) => ({ ...current, [field.name]: next })) + } + error={error} + > + + {(field.options ?? []).map((o) => ( + + ))} + + + ); + } + if (field.type === "select") { return (
+ + + Truck type + Trucks + Days + Rate / truck / day + Amount + + + + {preview.groups.map((g, i) => ( + + + {g.vehicleType ?? 'Unknown'} + {!g.ruleId && ( + + {' '}· no rule + + )} + + {g.truckCount} + {g.chargeableDays} + {money(g.ratePerDay, preview.currency)} + {money(g.amount, preview.currency)} + + ))} + +
+ ) : preview.tiers && preview.tiers.length > 0 ? ( + + + + From day + To day + Days + Rate / truck / day + Amount + + + + {preview.tiers.map((t, i) => ( + + {t.appliedFromDay} + {t.appliedToDay} + {t.days} + {money(t.ratePerDay, preview.currency)} + {money(t.amount, preview.currency)} + + ))} + +
+ ) : ( + + Flat {money(preview.ratePerDay, preview.currency)} per truck per day after the grace window. + + )} + + + {preview.ruleName ?? 'Detention rule'} + + {preview.billableUnits > 0 && ( + + {preview.billableUnits} billable truck-day(s) + + )} + + + )} + + + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx new file mode 100644 index 000000000..168bfd744 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx @@ -0,0 +1,211 @@ +import { + Alert, + Badge, + Button, + Checkbox, + Group, + Loader, + Modal, + Select, + Stack, + Table, + Tabs, + Text, +} from '@mantine/core'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { FileText } from 'lucide-react'; +import { useMemo, useState } from 'react'; + +import { useToast } from '@/hooks/use-toast'; +import { + warehouseService, + type ContainerItem, + type ContainerItemStage, +} from '@/services/warehouse.service'; +import { extractErrorMessage } from './options'; +import { openPdfBlob } from './pdf'; + +interface ContainerItemsModalProps { + opened: boolean; + onClose: () => void; + bookingId: string | null; + bookingReference?: string | null; +} + +const STAGE_TABS: Array<{ value: string; label: string }> = [ + { value: 'ALL', label: 'All' }, + { value: 'RECEIVED', label: 'Received' }, + { value: 'GRN', label: "GRN'd" }, + { value: 'LOADED', label: 'Loaded' }, + { value: 'LEFT', label: 'Left' }, + { value: 'DELIVERED', label: 'Delivered' }, +]; + +const STAGE_COLOR: Record = { + PENDING: 'gray', + RECEIVED: 'blue', + GRN: 'teal', + LOADED: 'grape', + LEFT: 'orange', + DELIVERED: 'green', +}; + +/** Loadable = not yet on a truck (before LOADED). */ +const isLoadable = (i: ContainerItem) => i.stage === 'PENDING' || i.stage === 'RECEIVED' || i.stage === 'GRN'; + +export function ContainerItemsModal({ opened, onClose, bookingId, bookingReference }: ContainerItemsModalProps) { + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [tab, setTab] = useState('ALL'); + const [selected, setSelected] = useState([]); + const [truckId, setTruckId] = useState(null); + + const itemsKey = ['container-items', bookingId]; + const { data: items = [], isLoading } = useQuery({ + queryKey: itemsKey, + queryFn: () => warehouseService.getContainerItems(bookingId as string), + enabled: opened && Boolean(bookingId), + }); + const { data: trucks = [] } = useQuery({ + queryKey: ['ci-trucks', bookingId], + queryFn: () => warehouseService.getCustomerTrucks(bookingId as string), + enabled: opened && Boolean(bookingId), + }); + + const visible = useMemo( + () => (tab === 'ALL' ? items : items.filter((i) => i.stage === tab)), + [items, tab], + ); + const truckOptions = trucks + .filter((t) => !(t as { departedAt?: string }).departedAt) + .map((t) => ({ value: t.id, label: `${t.plateNumber} · ${t.driverName}` })); + + const loadMutation = useMutation({ + mutationFn: () => warehouseService.loadTruck(bookingId as string, truckId as string, selected), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: itemsKey }); + setSelected([]); + toast({ title: 'Containers loaded onto truck' }); + }, + onError: (e) => toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(e) }), + }); + + const openExitPaper = async (assignmentId: string, plate: string) => { + try { + const res = await warehouseService.downloadTruckExitPaper(assignmentId); + openPdfBlob(res.data, `exit-${plate}.pdf`); + } catch (e) { + toast({ variant: 'destructive', title: 'Exit paper not ready', description: extractErrorMessage(e) }); + } + }; + + const toggle = (n: string) => setSelected((s) => (s.includes(n) ? s.filter((x) => x !== n) : [...s, n])); + + return ( + Container / bulk items {bookingReference ? `· ${bookingReference}` : ''}} + > + setTab(v ?? 'ALL')} mb="sm"> + + {STAGE_TABS.map((t) => { + const count = t.value === 'ALL' ? items.length : items.filter((i) => i.stage === t.value).length; + return ( + {count}}> + {t.label} + + ); + })} + + + + {isLoading ? ( + + + + ) : items.length === 0 ? ( + No container or bulk items on this booking. + ) : ( + + + + + + + Container + Goods + Stage + Truck + Booking + Contract + Last mile + Actions + + + + {visible.map((i) => ( + + + toggle(i.containerNumber)} + disabled={!isLoadable(i)} + /> + + {i.containerNumber} + {i.goods ?? '—'} + {i.stage} + {i.truckPlate ?? '—'} + {i.bookingReference ?? '—'} + {i.contractId ? Contract : '—'} + {i.hasLastMile ? EDR : Self-haul} + + {i.truckAssignmentId && ( + + )} + + + ))} + +
+
+ + {/* Multiselect → load onto a truck */} + + {selected.length} selected + + setFormData({ ...formData, vehicleId: val || "" })} + searchable + required + /> + + ({ value: t, label: t.replace(/_/g, " ") }))} + value={formData.type} + onChange={(val) => setFormData({ ...formData, type: (val as IncidentType) || "ACCIDENT" })} + required + /> + + setFormData({ ...formData, vehicleId: val || "" })} + clearable + searchable + /> + +