diff --git a/.gitignore b/.gitignore index 18fdae9c2..977a34353 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,6 @@ RUNNING_LOCALLY.md # Generated per-shard compose file for the integration suite (it.mjs). integration/.it-shards.yaml +branch_structure.json +temp_auto_push.bat +temp_interactive_push.bat diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index c579eb388..16ee9cd57 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -1,5 +1,17 @@ # Copy to .env for local/docker compose (not committed). PORT=3001 +# @tria-plc/auditlog's client interceptor stamps every AuditLog row's +# `application` from this env var directly, bypassing MezgebModule.forRoot's +# applicationName option (package quirk). audit.controller.ts reads the same +# var when filtering reads, so this can be anything as long as it's set. +APPLICATION_NAME=freight-api +# Also required for @tria-plc/auditlog: its producer (AuditClientModule) +# reads the RMQ URL at package IMPORT time, before MezgebModule.forRoot's +# rmqUrl option ever runs, so only an env var reaches it — an in-code +# override is too late. Without this, audit events are silently dropped +# (no error, nothing published). Point it at whatever broker/vhost your +# RabbitMQ actually has a user provisioned on. +RABBITMQ_URL=amqp://localhost:5672 # GT06 GPS tracker TCP listener port (raw TCP, must be reachable by tracker SIMs). 0 disables. GT06_TCP_PORT=5023 DB_HOST=localhost diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index d10f19f10..c4837c8a7 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -59,6 +59,7 @@ "@nestjs/typeorm": "^11.0.1", "@nestjs/websockets": "^11.1.27", "@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.6.0.tgz", + "@tria-plc/auditlog": "file:../../local-packages/tria-plc-auditlog-1.1.2.tgz", "@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-1.0.0.tgz", "amqp-connection-manager": "^5.0.0", "amqplib": "^2.0.1", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index f44d40d97..c032f2feb 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -16,6 +16,7 @@ import { import { IamBaselineSeeder, IamSeedModule } from "@edr/iam-seed"; import { IamModule } from "@tria-plc/iamapi-common"; import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module"; +import { MezgebModule } from "@tria-plc/auditlog"; import appConfig from "./config/app.config"; import databaseConfig from "./config/database.config"; @@ -102,13 +103,19 @@ 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 { LastMileRequestsModule } from "./modules/last-mile-requests/last-mile-requests.module"; import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module"; import { ImportOperationsModule } from "./modules/import-operations/import-operations.module"; import { AiModule } from "./modules/ai/ai.module"; +import { AuditModule } from "./modules/audit/audit.module"; import { LoggerMiddleware } from "./logger.middleware"; import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware"; import { PositionTypePermissionsCache } from "./common/position-type-permissions.cache"; +if (!process.env.APPLICATION_NAME) { + process.env.APPLICATION_NAME = "freight"; +} + @Module({ imports: [ ConfigModule.forRoot({ @@ -156,6 +163,19 @@ import { PositionTypePermissionsCache } from "./common/position-type-permissions return dataSource; }, }), + // Request + entity-level audit logging over RabbitMQ (@tria-plc/auditlog). + // Must come after TypeOrmModule above so it picks up this app's DataSource. + // rmqUrl falls back the same way notifications.module.ts's RABBITMQ_URL + // does: the dev broker only provisions the `edr` user on the `payment` + // vhost (docker-compose's RABBITMQ_DEFAULT_USER/VHOST), so an unset + // RABBITMQ_URL must land there too, not on guest@'/' (403 ACCESS_REFUSED). + MezgebModule.forRoot({ + applicationName: "freight-api", + rmqUrl: + process.env.RABBITMQ_URL ?? + process.env.PAYMENT_RABBITMQ_URL ?? + "amqp://localhost:5672", + }), SharedAuthModule, IamModule.forRoot({ applications: [EDR_FREIGHT_APPLICATION], @@ -223,11 +243,13 @@ import { PositionTypePermissionsCache } from "./common/position-type-permissions GpsTrackingModule, FirstMileModule, LastMileModule, + LastMileRequestsModule, InterchangeDocumentsModule, ImportOperationsModule, VerifaydaModule, FleetHistoryModule, AiModule, + AuditModule, ], providers: [ EdrOrgSeeder, diff --git a/apps/edr-freight-api/src/common/last-mile-charge.util.spec.ts b/apps/edr-freight-api/src/common/last-mile-charge.util.spec.ts new file mode 100644 index 000000000..e60bf4f4e --- /dev/null +++ b/apps/edr-freight-api/src/common/last-mile-charge.util.spec.ts @@ -0,0 +1,163 @@ +import { computeLastMileCharge } from './last-mile-charge.util'; +import type { Rate } from '../modules/rule-engine/entities/rate.entity'; + +const rate = (over: Partial): Rate => + ({ + appliesTo: 'LAST_MILE', + status: 'LIVE', + currency: 'ETB', + trigger: 'ALWAYS', + rateType: 'LAST_MILE', + ...over, + }) as Rate; + +const band20a = rate({ + rateUnit: 'PER_KM', + rateValue: 1800, + minKm: 0, + maxKm: 30, + containerType: { sizeFt: 20 } as Rate['containerType'], +}); +const band20b = rate({ + rateUnit: 'PER_KM', + rateValue: 1500, + minKm: 30, + maxKm: null, + containerType: { sizeFt: 20 } as Rate['containerType'], +}); +const band40a = rate({ + rateUnit: 'PER_KM', + rateValue: 2200, + minKm: 0, + maxKm: 30, + containerType: { sizeFt: 40 } as Rate['containerType'], +}); +const bulkRate = rate({ rateUnit: 'PER_TON_KM', rateValue: 25 }); + +describe('computeLastMileCharge', () => { + it('prices containers per band × size × quantity', () => { + const charge = computeLastMileCharge({ + freightType: 'CONTAINER', + tons: 0, + km: 13, + containers: [ + { sizeLabel: '20DC', qty: 5 }, + { sizeLabel: '40HC', qty: 1 }, + ], + liveRates: [band20a, band20b, band40a], + }); + // 13 × 1800 × 5 + 13 × 2200 × 1 + expect(charge).toMatchObject({ mode: 'CONTAINER', total: 117000 + 28600, currency: 'ETB' }); + expect(charge!.lines).toHaveLength(2); + }); + + it('band boundary is half-open: km = 30 falls in the 30+ band', () => { + const charge = computeLastMileCharge({ + freightType: 'CONTAINER', + tons: 0, + km: 30, + containers: [{ sizeLabel: '20DC', qty: 1 }], + liveRates: [band20a, band20b], + }); + expect(charge!.lines[0].unitRate).toBe(1500); + expect(charge!.total).toBe(30 * 1500); + }); + + it('returns null when a size has no matching band', () => { + const charge = computeLastMileCharge({ + freightType: 'CONTAINER', + tons: 0, + km: 50, + containers: [{ sizeLabel: '40HC', qty: 2 }], + liveRates: [band40a], // 40ft only covers 0–30 + }); + expect(charge).toBeNull(); + }); + + it('prices bulk as tons × km × rate', () => { + const charge = computeLastMileCharge({ + freightType: 'BULK', + tons: 60, + km: 26, + containers: [], + liveRates: [bulkRate], + }); + expect(charge).toMatchObject({ mode: 'BULK', total: 60 * 26 * 25, currency: 'ETB' }); + }); + + it('picks the bulk rate whose distance band holds the km (half-open boundary)', () => { + const bulkNear = rate({ rateUnit: 'PER_TON_KM', rateValue: 30, minKm: 0, maxKm: 30 }); + const bulkFar = rate({ rateUnit: 'PER_TON_KM', rateValue: 22, minKm: 30, maxKm: null }); + const near = computeLastMileCharge({ + freightType: 'BULK', + tons: 10, + km: 12, + containers: [], + liveRates: [bulkNear, bulkFar], + }); + expect(near).toMatchObject({ mode: 'BULK', total: 10 * 12 * 30 }); + const boundary = computeLastMileCharge({ + freightType: 'BULK', + tons: 10, + km: 30, + containers: [], + liveRates: [bulkNear, bulkFar], + }); + expect(boundary).toMatchObject({ total: 10 * 30 * 22 }); + }); + + it('bulk falls back to the legacy bandless rate when no band holds the km, null when nothing covers it', () => { + const bulkNear = rate({ rateUnit: 'PER_TON_KM', rateValue: 30, minKm: 0, maxKm: 30 }); + const fallback = computeLastMileCharge({ + freightType: 'BULK', + tons: 10, + km: 50, + containers: [], + liveRates: [bulkNear, bulkRate], // bulkRate has no band + }); + expect(fallback).toMatchObject({ total: 10 * 50 * 25 }); + expect( + computeLastMileCharge({ + freightType: 'BULK', + tons: 10, + km: 50, + containers: [], + liveRates: [bulkNear], + }), + ).toBeNull(); + }); + + it('returns null on mixed currencies, unknown km, and uncovered freight types', () => { + const usd40 = rate({ ...band40a, currency: 'USD' }); + expect( + computeLastMileCharge({ + freightType: 'CONTAINER', + tons: 0, + km: 10, + containers: [ + { sizeLabel: '20DC', qty: 1 }, + { sizeLabel: '40HC', qty: 1 }, + ], + liveRates: [band20a, usd40], + }), + ).toBeNull(); + expect( + computeLastMileCharge({ + freightType: 'BULK', + tons: 10, + km: 0, + containers: [], + liveRates: [bulkRate], + }), + ).toBeNull(); + expect( + computeLastMileCharge({ + freightType: 'BREAK_BULK', + tons: 10, + km: 10, + containers: [], + liveRates: [bulkRate], + }), + ).toBeNull(); + }); +}); diff --git a/apps/edr-freight-api/src/common/last-mile-charge.util.ts b/apps/edr-freight-api/src/common/last-mile-charge.util.ts new file mode 100644 index 000000000..055e8e5f9 --- /dev/null +++ b/apps/edr-freight-api/src/common/last-mile-charge.util.ts @@ -0,0 +1,212 @@ +import type { DataSource } from 'typeorm'; +import type { Rate } from '../modules/rule-engine/entities/rate.entity'; +import { bookingContainerSizes } from './truck-load.util'; + +/** One priced line of a rule-based last-mile charge. */ +export interface LastMileChargeLine { + description: string; + quantity: number; + unitRate: number; + amount: number; +} + +/** A fully-resolved rule-based last-mile charge. */ +export interface LastMileCharge { + mode: 'BULK' | 'CONTAINER'; + total: number; + currency: string; + lines: LastMileChargeLine[]; +} + +/** What the last-mile leg is hauling, in the shape the rate rules price. */ +export interface LastMileShipmentShape { + freightType: string | null; + tons: number; + containers: Array<{ sizeLabel: string; qty: number }>; +} + +const round2 = (n: number): number => Math.round(n * 100) / 100; + +/** + * Price a last-mile leg off the LIVE rate rules. Pure — pass the live rates in. + * + * BULK: the PER_TON_KM rate whose distance band holds the km (a legacy + * bandless row — NULL minKm — is the fallback and prices every distance) → + * price = tons × km × rate. + * CONTAINER: per container size, the PER_KM rate whose distance band holds the + * km → price = km × rate × quantity, summed across sizes. + * Bands are half-open [minKm, maxKm), NULL maxKm = open-ended. + * + * Returns null whenever the rules don't fully cover the shipment (no rate, a + * container size without a matching band, mixed currencies, km/tons unknown) — + * callers keep their existing pricing as the fallback. Never throws. + */ +export function computeLastMileCharge(input: { + freightType: string | null; + tons: number; + km: number; + containers: Array<{ sizeLabel: string; qty: number }>; + /** LIVE rates with the containerType relation loaded (findLiveRatesDetailed). */ + liveRates: Rate[]; +}): LastMileCharge | null { + const { freightType, tons, km, containers, liveRates } = input; + if (!km || km <= 0) return null; + + const candidates = liveRates.filter( + (rate) => rate.appliesTo === 'LAST_MILE' && rate.status === 'LIVE', + ); + + if (freightType === 'BULK') { + if (!tons || tons <= 0) return null; + const bulkRates = candidates.filter((r) => r.rateUnit === 'PER_TON_KM'); + const rate = + bulkRates.find( + (r) => + r.minKm !== null && + r.minKm !== undefined && + Number(r.minKm) <= km && + (r.maxKm === null || r.maxKm === undefined || km < Number(r.maxKm)), + ) ?? bulkRates.find((r) => r.minKm === null || r.minKm === undefined); + if (!rate) return null; + const unitRate = Number(rate.rateValue); + const amount = round2(tons * km * unitRate); + return { + mode: 'BULK', + total: amount, + currency: rate.currency, + lines: [ + { + description: `Last-mile bulk delivery — ${tons} t × ${km} km × ${unitRate}/t·km`, + quantity: tons, + unitRate, + amount, + }, + ], + }; + } + + if (freightType === 'CONTAINER') { + if (!containers.length) return null; + const lines: LastMileChargeLine[] = []; + const currencies = new Set(); + for (const group of containers) { + const rate = candidates.find( + (r) => + r.rateUnit === 'PER_KM' && + r.minKm !== null && + r.minKm !== undefined && + r.containerType?.sizeFt !== null && + r.containerType?.sizeFt !== undefined && + group.sizeLabel.includes(String(r.containerType.sizeFt)) && + Number(r.minKm) <= km && + (r.maxKm === null || r.maxKm === undefined || km < Number(r.maxKm)), + ); + // A size the rules don't cover means the rule set can't price this job. + if (!rate) return null; + const unitRate = Number(rate.rateValue); + const amount = round2(km * unitRate * group.qty); + currencies.add(rate.currency); + lines.push({ + description: `Last-mile delivery — ${group.qty} × ${group.sizeLabel} container, ${km} km @ ${unitRate}/km`, + quantity: group.qty, + unitRate, + amount, + }); + } + // A charge can't mix birr and dollar lines on one invoice. + if (currencies.size !== 1) return null; + return { + mode: 'CONTAINER', + total: round2(lines.reduce((sum, line) => sum + line.amount, 0)), + currency: [...currencies][0], + lines, + }; + } + + return null; +} + +/** + * Rule-based charge for an operational last-mile record: prices what its + * trucks actually haul (last_mile_vehicle_containers / weighed net tons) + * against the given km. Shared by setDistances (writes remainingPayment) and + * DELIVERY_FEE invoicing so the two never disagree on the math. Null = the + * rules don't cover this job — callers keep the per-vehicle price/km path. + */ +export async function ruleBasedLastMileCharge( + dataSource: DataSource, + liveRates: Rate[], + lastMileId: string, + km: number, +): Promise { + if (!km || km <= 0) return null; + const [record]: Array<{ bookingId: string }> = await dataSource.query( + `SELECT booking_id AS "bookingId" + FROM freight.last_mile + WHERE id = $1 AND deleted_at IS NULL`, + [lastMileId], + ); + if (!record) return null; + + const containerRows: Array<{ containerNumber: string }> = await dataSource.query( + `SELECT container_number AS "containerNumber" + FROM freight.last_mile_vehicle_containers + WHERE last_mile_id = $1 AND deleted_at IS NULL`, + [lastMileId], + ); + const shape = await lastMileShipmentShape( + dataSource, + record.bookingId, + containerRows.map((r) => r.containerNumber), + ); + + // Bulk: bill the weighed tonnage on this record's trucks when known, + // falling back to the booking's declared VGM total. + const [tonsRow]: Array<{ tons: string | null }> = await dataSource.query( + `SELECT SUM(net_weight_tons) AS "tons" + FROM freight.last_mile_vehicle_assignments + WHERE last_mile_id = $1 AND deleted_at IS NULL`, + [lastMileId], + ); + const weighedTons = Number(tonsRow?.tons ?? 0); + + return computeLastMileCharge({ + ...shape, + tons: weighedTons > 0 ? weighedTons : shape.tons, + km, + liveRates, + }); +} + +/** + * Load a booking's shipment shape for the charge resolver: freight type, bulk + * tonnage, and the container numbers grouped into size × quantity. + */ +export async function lastMileShipmentShape( + dataSource: DataSource, + bookingId: string, + containerNumbers: string[], +): Promise { + const [booking]: Array<{ freightType: string | null; tons: string | null }> = + await dataSource.query( + `SELECT freight_type AS "freightType", cargo_total_weight_vgm AS "tons" + FROM freight.bookings + WHERE id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + const sizes = await bookingContainerSizes( + dataSource, + bookingId, + containerNumbers.map((n) => n.trim().toUpperCase()), + ); + const bySize = new Map(); + for (const size of sizes) { + if (!size) continue; + bySize.set(size, (bySize.get(size) ?? 0) + 1); + } + return { + freightType: booking?.freightType ?? null, + tons: Number(booking?.tons ?? 0), + containers: [...bySize.entries()].map(([sizeLabel, qty]) => ({ sizeLabel, qty })), + }; +} diff --git a/apps/edr-freight-api/src/common/mile-distance.util.spec.ts b/apps/edr-freight-api/src/common/mile-distance.util.spec.ts new file mode 100644 index 000000000..75801a20b --- /dev/null +++ b/apps/edr-freight-api/src/common/mile-distance.util.spec.ts @@ -0,0 +1,17 @@ +import { haversineKm } from './mile-distance.util'; + +describe('haversineKm', () => { + it('is zero for the same point', () => { + expect(haversineKm(8.9, 38.6, 8.9, 38.6)).toBe(0); + }); + + it('matches one degree of longitude at the equator (~111.19 km)', () => { + expect(haversineKm(0, 0, 0, 1)).toBeCloseTo(111.19, 1); + }); + + it('Sebeta yard → Indode yard is roughly 26 km', () => { + const km = haversineKm(8.9096, 38.636, 8.7386, 38.7913); + expect(km).toBeGreaterThan(20); + expect(km).toBeLessThan(35); + }); +}); diff --git a/apps/edr-freight-api/src/common/mile-distance.util.ts b/apps/edr-freight-api/src/common/mile-distance.util.ts new file mode 100644 index 000000000..822a13350 --- /dev/null +++ b/apps/edr-freight-api/src/common/mile-distance.util.ts @@ -0,0 +1,48 @@ +import { DataSource } from 'typeorm'; + +/** Great-circle distance in km between two WGS84 points (haversine). */ +export function haversineKm(lat1: number, lng1: number, lat2: number, lng2: number): number { + const toRad = (d: number) => (d * Math.PI) / 180; + const dLat = toRad(lat2 - lat1); + const dLng = toRad(lng2 - lng1); + const a = + Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2; + return 2 * 6371 * Math.asin(Math.sqrt(a)); +} + +/** + * Estimated road-leg distance for a booking's first/last mile: straight-line km + * from the yard (freight.yard_locations) to the customer's pickup/delivery GPS + * point on the booking. FIRST = origin yard → pickup point, LAST = destination + * yard → delivery point. Null when either end has no coordinates. + * + * ponytail: haversine straight-line, not road routing — plug a routing API in + * here if real road km is ever required. + */ +export async function estimateMileKm( + dataSource: DataSource, + bookingId: string, + mile: 'FIRST' | 'LAST', +): Promise { + const [row] = await dataSource.query( + mile === 'LAST' + ? `SELECT b.last_mile_delivery_lat AS lat, b.last_mile_delivery_lng AS lng, + l.latitude AS yard_lat, l.longitude AS yard_lng + FROM freight.bookings b + LEFT JOIN freight.yard_locations l + ON l.yard_id = b.destination_yard_id AND l.deleted_at IS NULL + WHERE b.id = $1 AND b.deleted_at IS NULL` + : `SELECT b.first_mile_pickup_lat AS lat, b.first_mile_pickup_lng AS lng, + l.latitude AS yard_lat, l.longitude AS yard_lng + FROM freight.bookings b + LEFT JOIN freight.yard_locations l + ON l.yard_id = b.origin_yard_id AND l.deleted_at IS NULL + WHERE b.id = $1 AND b.deleted_at IS NULL`, + [bookingId], + ); + if (!row || row.lat == null || row.lng == null || row.yard_lat == null || row.yard_lng == null) { + return null; + } + const km = haversineKm(Number(row.yard_lat), Number(row.yard_lng), Number(row.lat), Number(row.lng)); + return Math.round(km * 100) / 100; +} diff --git a/apps/edr-freight-api/src/config/database.config.ts b/apps/edr-freight-api/src/config/database.config.ts index f699b193d..5de529d15 100644 --- a/apps/edr-freight-api/src/config/database.config.ts +++ b/apps/edr-freight-api/src/config/database.config.ts @@ -56,6 +56,11 @@ import { EmployeePositionActivePeriod } from "@tria-plc/iamapi-common/entities/i import { UnitConfiguration } from "@tria-plc/iamapi-common/entities/iam/organization-structure/unit-configuration.entity"; import { Site } from "@tria-plc/iamapi-common/entities/iam/site/site.entity"; import { SiteSetting } from "@tria-plc/iamapi-common/entities/iam/site/site-setting.entity"; +import { AuditLog, AuditLogCommand } from "@tria-plc/auditlog"; + +// @tria-plc/auditlog's entities live in node_modules, same as the iam ones — +// the glob below only matches this app's own src/**/*.entity.ts. +const auditEntities = [AuditLog, AuditLogCommand]; const iamEntities = [ UnitSetting, @@ -177,7 +182,11 @@ export function buildDataSourceOptions(): DataSourceOptions { return { ...buildConnectionOptions(), schema: "public", - entities: [__dirname + "/../**/*.entity.{ts,js}", ...iamEntities], + entities: [ + __dirname + "/../**/*.entity.{ts,js}", + ...iamEntities, + ...auditEntities, + ], migrations: [], }; } diff --git a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts index 48eadb6c7..c62a875bf 100644 --- a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts @@ -43,6 +43,7 @@ const UNIT_LABELS: Record = { PER_TON: 'per ton', PER_CONTAINER: 'per container', PER_KM: 'per km', + PER_TON_KM: 'per ton per km', PER_INVOICE: 'per invoice', FLAT: 'flat', }; diff --git a/apps/edr-freight-api/src/contracts/templates/last-mile.hbs b/apps/edr-freight-api/src/contracts/templates/last-mile.hbs new file mode 100644 index 000000000..9437bb18b --- /dev/null +++ b/apps/edr-freight-api/src/contracts/templates/last-mile.hbs @@ -0,0 +1,114 @@ + + + + + Last-Mile Delivery Contract — {{bookingReference}} + + + +
+
+
EDR
+
+

Ethio-Djibouti Standard Gauge Railway Share Company

+

Last-Mile Delivery Contract

+
+
+ +

Last-Mile Delivery Contract

+

Booking {{bookingReference}} — {{companyName}}

+ +

Shipment Details

+ + + + {{#if containerCount}} + + + {{/if}} + {{#if cargoDescription}} + + {{/if}} + {{#if deliveryAddress}} + + {{/if}} + {{#if trainDepartureDate}} + + {{/if}} + + + +
Client{{companyName}}
Booking Reference{{bookingReference}}
Number of Containers{{containerCount}}
Containers{{containerList}}
Cargo Description{{cargoDescription}}
Delivery Address{{deliveryAddress}}
Train Departure from Djibouti{{trainDepartureDate}}
Last-Mile Delivery Date{{deliveryDate}}
Request Date{{requestDate}}
Approval Date{{approvalDate}}
+ +

Rates

+ + + + + + {{#each rateLines}} + + {{/each}} + {{#if estimatedKm}} + + {{/if}} + + + + +
DescriptionAmount{{#if currency}} ({{currency}}){{/if}}
{{description}}{{amount}}
Estimated distance{{estimatedKm}} km
Advance payable on signing{{advanceAmount}}{{#if currency}} {{currency}}{{/if}}
+ +

Terms

+
+

1. The Service Provider shall deliver the goods identified above from the arrival yard to the Client's delivery address on or about the last-mile delivery date stated above.

+

2. The Client shall pay the advance stated above upon signing this contract. The final delivery fee is computed on completion per the Service Provider's published last-mile rates and actual distance.

+

3. The Client shall ensure access and receipt of the goods at the delivery address. Waiting time and truck detention beyond free time may incur additional charges per the applicable tariff.

+

4. This contract is governed by the laws applicable to the Ethio-Djibouti Standard Gauge Railway Share Company's freight services.

+
+ +

Signatures

+
+
+

Client

+ {{#if signature}} + Customer signature +
{{signature.signerDisplayName}} — signed {{signature.signedAt}}
+ {{#if signature.consentText}}{{/if}} + {{else}} +

Awaiting customer signature.

+
Name, signature & date
+ {{/if}} +
+
+

Service Provider

+

Ethio-Djibouti Standard Gauge Railway Share Company

+
Authorized representative
+
+
+
+ + diff --git a/apps/edr-freight-api/src/main.ts b/apps/edr-freight-api/src/main.ts index a4fbefdd2..c9a718f5d 100644 --- a/apps/edr-freight-api/src/main.ts +++ b/apps/edr-freight-api/src/main.ts @@ -10,6 +10,7 @@ import { ResponseTransformInterceptor, createValidationPipe, } from "@edr/api-common"; +import { getAuditLoggerConfig } from "@tria-plc/auditlog"; import { AppModule } from "./app.module"; @@ -160,6 +161,13 @@ export async function createFreightApp(): Promise { app.useGlobalFilters(new HttpExceptionFilter()); app.useGlobalInterceptors(new ResponseTransformInterceptor()); + // Audit listener: consumes the RMQ events MezgebModule's client interceptor + // (app.module.ts) emits and persists them via the AuditLogController / + // AuditLogCommandController @EventPattern handlers. Same queue config the + // client side uses, reused from the package so the two never drift apart. + app.connectMicroservice(getAuditLoggerConfig()); + await app.startAllMicroservices(); + const config = new DocumentBuilder() .setTitle("EDR Freight API") .setDescription("API for the EDR Freight Management application") diff --git a/apps/edr-freight-api/src/migrations/3250000000000-CreateLastMileRequests.ts b/apps/edr-freight-api/src/migrations/3250000000000-CreateLastMileRequests.ts new file mode 100644 index 000000000..0763e32a3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3250000000000-CreateLastMileRequests.ts @@ -0,0 +1,87 @@ +import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm'; + +/** + * Create freight.last_mile_requests — the pre-approval confirmation stage that + * sits in front of freight.last_mile: a train departs Djibouti, the customer + * confirms which containers go via EDR last-mile, and the Truck & Machinery + * chief approves/rejects before a freight.last_mile execution record exists. + */ +export class CreateLastMileRequests3250000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable('freight.last_mile_requests'); + if (exists) return; + + await queryRunner.createTable( + new Table({ + name: 'freight.last_mile_requests', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, default: 'gen_random_uuid()' }, + { name: 'booking_id', type: 'uuid', isNullable: false }, + { name: 'train_schedule_id', type: 'uuid', isNullable: false }, + { + name: 'status', + type: 'varchar', + length: '30', + default: `'AWAITING_CONFIRMATION'`, + isNullable: false, + }, + { name: 'requested_container_numbers', type: 'text', isArray: true, isNullable: true }, + { name: 'reminder_sent_at', type: 'timestamptz', isNullable: true }, + { name: 'submitted_by_user_id', type: 'uuid', isNullable: true }, + { name: 'submitted_at', type: 'timestamptz', isNullable: true }, + { name: 'reviewed_by_staff_id', type: 'uuid', isNullable: true }, + { name: 'reviewed_at', type: 'timestamptz', isNullable: true }, + { name: 'rejection_reason', type: 'text', isNullable: true }, + { name: 'resulting_last_mile_id', type: 'uuid', isNullable: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + + await queryRunner.createForeignKey( + 'freight.last_mile_requests', + new TableForeignKey({ + columnNames: ['booking_id'], + referencedTableName: 'freight.bookings', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + await queryRunner.createForeignKey( + 'freight.last_mile_requests', + new TableForeignKey({ + columnNames: ['train_schedule_id'], + referencedTableName: 'freight.train_schedules', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + await queryRunner.createForeignKey( + 'freight.last_mile_requests', + new TableForeignKey({ + columnNames: ['resulting_last_mile_id'], + referencedTableName: 'freight.last_mile', + referencedColumnNames: ['id'], + onDelete: 'SET NULL', + }), + ); + + // One request per booking per departure — remind()/submit() are idempotent on this. + await queryRunner.query( + `CREATE UNIQUE INDEX IF NOT EXISTS "IDX_last_mile_requests_booking_schedule" ON "freight"."last_mile_requests" ("booking_id", "train_schedule_id") WHERE "deleted_at" IS NULL`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_last_mile_requests_status" ON "freight"."last_mile_requests" ("status")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable('freight.last_mile_requests'); + if (exists) { + await queryRunner.dropTable('freight.last_mile_requests'); + } + } +} diff --git a/apps/edr-freight-api/src/migrations/3270000000000-YardGpsLocation.ts b/apps/edr-freight-api/src/migrations/3270000000000-YardGpsLocation.ts new file mode 100644 index 000000000..18e71ae4c --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3270000000000-YardGpsLocation.ts @@ -0,0 +1,53 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Creates freight.yard_locations (GPS per yard, decimal degrees WGS84) and + * seeds the five facility yards: Sebeta, GMP/Indode (KALITY), Mojo, Adama, + * Dire Dawa. Also normalizes has_facility — only those five load/unload cargo. + * + * Coordinates are approximate — adjust rows directly if surveyed values arrive. + */ +export class YardGpsLocation3270000000000 implements MigrationInterface { + name = 'YardGpsLocation3270000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.yard_locations ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + yard_id uuid NOT NULL UNIQUE REFERENCES freight.yards(id) ON DELETE CASCADE, + latitude double precision NOT NULL, + longitude double precision NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + + // LEGACY_DEST is the historical code for Sebeta in some environments. + await queryRunner.query(` + INSERT INTO freight.yard_locations (yard_id, latitude, longitude) + SELECT y.id, v.lat, v.lng + FROM (VALUES + ('SEBETA', 8.9096, 38.6360), + ('LEGACY_DEST', 8.9096, 38.6360), + ('KALITY', 8.7386, 38.7913), + ('MOJO', 8.5794, 39.1200), + ('ADAMA', 8.5622, 39.2440), + ('DIRE_DAWA', 9.6009, 41.8103) + ) AS v(code, lat, lng) + JOIN freight.yards y ON y.code = v.code AND y.deleted_at IS NULL + ON CONFLICT (yard_id) DO NOTHING + `); + + await queryRunner.query(` + UPDATE freight.yards + SET has_facility = (code IN ('SEBETA', 'LEGACY_DEST', 'KALITY', 'MOJO', 'ADAMA', 'DIRE_DAWA')) + WHERE deleted_at IS NULL + AND has_facility <> (code IN ('SEBETA', 'LEGACY_DEST', 'KALITY', 'MOJO', 'ADAMA', 'DIRE_DAWA')) + `); + } + + public async down(): Promise { + // Additive table + data normalization; no rollback. + } +} diff --git a/apps/edr-freight-api/src/migrations/3280000000000-LastMileRateBands.ts b/apps/edr-freight-api/src/migrations/3280000000000-LastMileRateBands.ts new file mode 100644 index 000000000..973ff00a4 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3280000000000-LastMileRateBands.ts @@ -0,0 +1,68 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Last-mile rate bands: adds min_km/max_km to freight.rates so container + * last-mile rates can be one row per (container size × distance band), + * and extends UQ_rates_pattern with the band start so sibling bands don't + * collide. Existing rows all have NULL min_km (COALESCE → -1), so the + * uniqueness semantics for every current rate are unchanged. + */ +export class LastMileRateBands3280000000000 implements MigrationInterface { + name = 'LastMileRateBands3280000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.rates ADD COLUMN IF NOT EXISTS min_km numeric(10,2) + `); + await queryRunner.query(` + ALTER TABLE freight.rates ADD COLUMN IF NOT EXISTS max_km numeric(10,2) + `); + + await queryRunner.query(` + DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'CK_rates_km_band' + AND conrelid = 'freight.rates'::regclass + ) THEN + ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_km_band" + CHECK (max_km IS NULL OR (min_km IS NOT NULL AND max_km > min_km)); + END IF; + END $$ + `); + + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern"`); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern" ON freight.rates USING btree ( + rate_type, + COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(trade_direction, ''::character varying), + COALESCE(origin_yard_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(destination_yard_id, '00000000-0000-0000-0000-000000000000'::uuid), + rate_unit, + COALESCE(min_km, '-1'::numeric) + ) WHERE ((deleted_at IS NULL) AND ((status)::text <> 'SUPERSEDED'::text)) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern"`); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern" ON freight.rates USING btree ( + rate_type, + COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(trade_direction, ''::character varying), + COALESCE(origin_yard_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(destination_yard_id, '00000000-0000-0000-0000-000000000000'::uuid), + rate_unit + ) WHERE ((deleted_at IS NULL) AND ((status)::text <> 'SUPERSEDED'::text)) + `); + await queryRunner.query(` + ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_km_band" + `); + await queryRunner.query(`ALTER TABLE freight.rates DROP COLUMN IF EXISTS max_km`); + await queryRunner.query(`ALTER TABLE freight.rates DROP COLUMN IF EXISTS min_km`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3290000000000-LastMileRequestContract.ts b/apps/edr-freight-api/src/migrations/3290000000000-LastMileRequestContract.ts new file mode 100644 index 000000000..d4971f903 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3290000000000-LastMileRequestContract.ts @@ -0,0 +1,36 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Last-mile contract fields on freight.last_mile_requests: the customer-chosen + * delivery date, the chief-approved advance amount (held until the invoice is + * generated at signing time), the rate snapshot rendered into the contract, + * and the customer signature bookkeeping. The signed PDF and signature image + * live in freight.files (resource 'last_mile_requests'). + */ +export class LastMileRequestContract3290000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.last_mile_requests + ADD COLUMN IF NOT EXISTS requested_delivery_date date, + ADD COLUMN IF NOT EXISTS approved_advance_amount numeric(14,2), + ADD COLUMN IF NOT EXISTS contract_summary jsonb, + ADD COLUMN IF NOT EXISTS contract_generated_at timestamptz, + ADD COLUMN IF NOT EXISTS customer_signed_at timestamptz, + ADD COLUMN IF NOT EXISTS signer_display_name varchar(160), + ADD COLUMN IF NOT EXISTS consent_text text + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.last_mile_requests + DROP COLUMN IF EXISTS requested_delivery_date, + DROP COLUMN IF EXISTS approved_advance_amount, + DROP COLUMN IF EXISTS contract_summary, + DROP COLUMN IF EXISTS contract_generated_at, + DROP COLUMN IF EXISTS customer_signed_at, + DROP COLUMN IF EXISTS signer_display_name, + DROP COLUMN IF EXISTS consent_text + `); + } +} diff --git a/apps/edr-freight-api/src/modules/audit/audit.controller.ts b/apps/edr-freight-api/src/modules/audit/audit.controller.ts new file mode 100644 index 000000000..a7c8782b2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/audit/audit.controller.ts @@ -0,0 +1,32 @@ +import { Controller, Get, Query } from "@nestjs/common"; +import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger"; + +import { BookingStaff } from "../../common/booking-guards"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; +import { AuditService } from "./audit.service"; + +@ApiTags("audit") +@Controller("audit") +@BookingStaff(FREIGHT_PERMS.audit.view) +export class AuditController { + constructor(private readonly auditService: AuditService) {} + + @Get("logs") + @ApiOperation({ summary: "List freight-api audit log commands" }) + @ApiQuery({ name: "skip", type: Number, required: false }) + @ApiQuery({ name: "take", type: Number, required: false }) + list(@Query("skip") skip?: string, @Query("take") take?: string) { + // Same fallback chain @tria-plc/auditlog's client interceptor uses to + // stamp AuditLog.application (mezgeb/client/client-audit.interceptor.js) + // — reading it here instead of a hardcoded literal means this can't + // silently drift out of sync with whatever APPLICATION_NAME/APP_NAME + // actually is at runtime. + const application = + process.env.APPLICATION_NAME ?? process.env.APP_NAME ?? "DEFAULT"; + return this.auditService.list( + application, + skip !== undefined ? parseInt(skip, 10) : undefined, + take !== undefined ? parseInt(take, 10) : undefined, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/audit/audit.module.ts b/apps/edr-freight-api/src/modules/audit/audit.module.ts new file mode 100644 index 000000000..635973fc6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/audit/audit.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { AuditLogCommand } from "@tria-plc/auditlog"; + +import { AuditController } from "./audit.controller"; +import { AuditService } from "./audit.service"; + +@Module({ + imports: [TypeOrmModule.forFeature([AuditLogCommand])], + controllers: [AuditController], + providers: [AuditService], +}) +export class AuditModule {} diff --git a/apps/edr-freight-api/src/modules/audit/audit.service.ts b/apps/edr-freight-api/src/modules/audit/audit.service.ts new file mode 100644 index 000000000..04ea4beed --- /dev/null +++ b/apps/edr-freight-api/src/modules/audit/audit.service.ts @@ -0,0 +1,70 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { AuditLogCommand } from "@tria-plc/auditlog"; + +import { CLIENT_APP_HEADER } from "../auth/login-audience.middleware"; + +export interface AuditLogListResult { + count: number; + items: AuditLogCommand[]; +} + +/** + * Own read path onto @tria-plc/auditlog's tables, gated by AuditController's + * @BookingStaff — the package's own AuditLogCommandController (mounted at + * /api/audit-log-commands) ships with no guards at all, so it can't be used + * directly for a permission-gated UI. Query mirrors the package's + * AuditLogCommandService.buildAuditLogQuery/getAllAuditLogs exactly. + */ +@Injectable() +export class AuditService { + constructor( + @InjectRepository(AuditLogCommand) + private readonly auditLogCommandRepository: Repository, + ) {} + + async list( + application: string, + skip = 0, + take = 10, + ): Promise { + const [items, count] = await this.auditLogCommandRepository + .createQueryBuilder("audit_log_commands") + .leftJoinAndSelect("audit_log_commands.auditLog", "auditLog") + .andWhere( + "(audit_log_commands.auditLogId IS NULL OR auditLog.application = :application)", + { application }, + ) + .andWhere( + "(audit_log_commands.auditLogId IS NULL OR auditLog.status = :status)", + { status: "Commit" }, + ) + // Backoffice-only view: portal (customer-facing) writes carry the same + // request-header set by every axios call from that app — see + // login-audience.middleware.ts. Rows with no linked auditLog (child/ + // event commands with no request context) stay visible; they aren't + // attributable to any frontend, so they're not portal noise either. + .andWhere( + "(audit_log_commands.auditLogId IS NULL OR auditLog.requestHeader ->> :clientAppHeader = :clientApp)", + { clientAppHeader: CLIENT_APP_HEADER, clientApp: "backoffice" }, + ) + .select([ + "audit_log_commands.id", + "audit_log_commands.createdAt", + "audit_log_commands.deletedAt", + "audit_log_commands.entityName", + "audit_log_commands.queryMethod", + "audit_log_commands.changes", + "audit_log_commands.payload", + "auditLog.id", + "auditLog.user", + ]) + .addOrderBy("audit_log_commands.createdAt", "DESC") + .skip(skip) + .take(take) + .getManyAndCount(); + + return { count, items }; + } +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts index 1356f8cfa..6e25c72aa 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -668,3 +668,66 @@ describe("BillingService — CAC Bank (OTP debit)", () => { expect(confirmOtp).toHaveBeenCalledWith("intent-1", "123456"); }); }); + +describe("BillingService — CBE bill amounts round UP to whole birr", () => { + // CBE bills whole birr. Ceil, never Math.round: a .40 balance rounded down + // settles 0.40 short while markInvoiceAsPaid still writes paidAmount = + // totalAmount — money missing from the bank with the books saying paid. + // payInvoice and billQuery must agree, or /cbe/payment sees a mismatch. + const invoice = { + id: "inv-1", + status: Freight.InvoiceStatus.Pending, + source: Freight.InvoiceSource.Booking, + sourceId: "booking-1", + type: "PREPAID", + invoiceNumber: "INV-20260101-00001", + currency: "ETB", + // .40 — the case Math.round gets wrong (rounds down, underpays). + balanceAmount: 12345.4, + totalAmount: 12345.4, + company: { name: "Acme PLC" }, + paymentId: null, + dueAt: null, + }; + + const build = (payment: Record = {}) => { + const repo = { + findOne: jest.fn().mockResolvedValue(invoice), + update: jest.fn().mockResolvedValue(undefined), + }; + const service = new BillingService( + { getRepository: () => repo } as never, + {} as never, + {} as never, + makeEvents() as never, + payment as never, + {} as never, + {} as never, + ); + return { service, repo }; + }; + + it("opens the intent for the ceiled balance, never below it", async () => { + const initiate = jest.fn().mockResolvedValue({ + intentId: "intent-1", + immediateSuccess: false, + response: { intentId: "intent-1", status: "REQUIRES_ACTION" }, + }); + const { service } = build({ initiate }); + + await service.payInvoice("inv-1", { method: "CBE_BILL" }); + + expect(initiate).toHaveBeenCalledWith( + expect.objectContaining({ amountMinor: 12346 }), + ); + }); + + it("quotes the same ceiled amount on bill-query as payInvoice opened", async () => { + const { service } = build(); + + await expect(service.billQuery("booking-1")).resolves.toMatchObject({ + stillPayable: true, + currentAmountMinor: 12346, + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 776f967be..5051afd6e 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -10,6 +10,7 @@ import { import { EventEmitter2 } from "@nestjs/event-emitter"; import { DataSource, EntityManager, In } from "typeorm"; +import { Booking } from "../bookings/entities/booking.entity"; import { CompaniesService } from "../companies/companies.service"; import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util"; import { PaymentService } from "../payment/payment.service"; @@ -1190,7 +1191,11 @@ export class BillingService { // service branches on a domain-specific reference type. referenceType: PaymentReferenceType.SHIPMENT, orderRef: invoice.invoiceNumber.replace(/-/g, "_"), - amountMinor: Math.round(Number(invoice.balanceAmount)), + // Whole birr, always UP. CBE bills this amount verbatim, so it must never + // land below the outstanding balance — Math.round would let a .40 balance + // settle 0.40 short. Ceil overcharges by <1 birr instead, and the same + // ceil in billQuery keeps the quoted and debited amounts identical. + amountMinor: Math.ceil(Number(invoice.balanceAmount)), currency: invoice.currency, reason: `Payment for invoice ${invoice.invoiceNumber}`, method: opts.method ?? "TELEBIRR", @@ -1209,6 +1214,17 @@ export class BillingService { .getRepository(Invoice) .update({ id: invoice.id }, { paymentId: result.intentId }); + // CBE_BILL: the bill reference IS the booking's PNR — the number the customer pays against + // at any CBE channel. Persist it on the booking so it survives the initiate response and + // shows on the booking/contract everywhere. The payment service reissues the same reference + // while the bill stays open, so re-initiating overwrites with an identical value. + const billReference = result.response.clientAction?.billReference; + if (billReference && invoice.source === Freight.InvoiceSource.Booking) { + await this.dataSource + .getRepository(Booking) + .update({ id: invoice.sourceId }, { pnrCode: billReference }); + } + // Settlement is driven by the payment API (webhook/outbox → payment.succeeded); // billing must not simulate it. Kept for local demos only. // An OTP intent (CAC Bank) is NOT paid yet — the payer still has to enter the @@ -1324,7 +1340,9 @@ export class BillingService { }); if (open) { - const balance = Math.round(Number(open.balanceAmount ?? open.totalAmount)); + // Ceil, matching payInvoice — the amount CBE quotes at the counter has to + // be the amount the intent was opened for, or /cbe/payment sees a mismatch. + const balance = Math.ceil(Number(open.balanceAmount ?? open.totalAmount)); const expired = open.dueAt && open.dueAt.getTime() < Date.now(); return { stillPayable: balance > 0 && !expired, @@ -1359,7 +1377,7 @@ export class BillingService { return { stillPayable: false, payerName: latest.company?.name ?? null, - currentAmountMinor: Math.round(Number(latest.totalAmount)), + currentAmountMinor: Math.ceil(Number(latest.totalAmount)), currency: latest.currency, paymentReason: `Freight invoice ${latest.invoiceNumber}`, reason: closedInvoiceReason(latest.status), diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index 522bac47e..df81fd5f6 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -727,8 +727,16 @@ export class BookingPricingService { for (const leg of legs) { if (!leg.active) continue; + // New-style last-mile rates (PER_TON_KM bulk / distance-banded PER_KM) + // price the operational leg via last-mile-charge.util, not the booking + // quote — this legacy lookup must never pick one up. const rate = liveRates.find( - (r) => r.rateType === leg.rateType && r.currency === 'USD' && r.status === 'LIVE', + (r) => + r.rateType === leg.rateType && + r.currency === 'USD' && + r.status === 'LIVE' && + r.rateUnit !== 'PER_TON_KM' && + r.minKm == null, ); if (!rate) continue; diff --git a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts index 32513df4f..51dd1d61f 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts @@ -280,15 +280,104 @@ describe("Fayda identity verification binds a person to the company", () => { expect(ctx.attributes.poaFaydaSub).toBe("new-sub"); }); - it("refuses to rename a verified person by hand", async () => { - const { service } = makeService({ + it("stages nothing for a verified field an approved company resubmits", async () => { + // Approving it could not move the live row — the verified value is written + // back over it — so it must never reach a reviewer as a pending change. + const { service, deps } = makeService({ + status: CompanyStatus.Active, + attributes: { ...OWNER_VERIFIED, ownerEmail: "abebe@example.com" }, + }); + + await expect( + service.updateProfile("user-1", { + companyEmail: "someone-else@example.com", + } as never), + ).resolves.toBeDefined(); + expect(deps.changeRequestRepo.create).not.toHaveBeenCalled(); + expect(deps.changeRequestRepo.update).not.toHaveBeenCalled(); + expect(deps.companiesRepo.update).not.toHaveBeenCalled(); + }); + + // The verified value wins, and it wins by overwriting rather than by + // rejecting: nobody types these fields, so a submission that disagrees is a + // stale form echoing itself back, not an edit. Failing it would block a save + // the customer never made — and leave them no way through, since re-verifying + // returns the same value they are being 400'd for. + it("overwrites a hand-renamed verified person with the verified name", async () => { + const { service, ctx } = makeService({ attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED }, files: [paper()], }); await expect( service.updateProfile("user-1", { poaName: "Someone Else" } as never), - ).rejects.toBeInstanceOf(BadRequestException); + ).resolves.toBeDefined(); + expect(ctx.attributes.poaName).toBe(POA_VERIFIED.poaName); + }); + + // Fayda's email and phone claims are optional — a verification can prove the + // person and return neither. Holding the company mirrors to "the owner is + // verified" rather than to "the verification supplied this value" would + // clobber the fallbacks the portal is built to send (account email, eTrade's + // registered phone) with nothing at all. OWNER_VERIFIED is exactly that + // shape: a sub, no contact details. + it("keeps company contact details a Fayda verification never supplied", async () => { + const { service, deps } = makeService({ + attributes: { ...OWNER_VERIFIED }, + }); + + await expect( + service.updateProfile("user-1", { + companyEmail: "account@example.com", + companyPhone: "+251911777777", + } as never), + ).resolves.toBeDefined(); + const [, patch] = deps.companiesRepo.update.mock.calls.at(-1)!; + expect(patch.email).toBe("account@example.com"); + expect(patch.phone).toBe("+251911777777"); + }); + + it("overwrites company contact details the verification did supply", async () => { + const { service, deps } = makeService({ + attributes: { + ...OWNER_VERIFIED, + ownerEmail: "abebe@example.com", + ownerPhone: "+251911000000", + }, + }); + + await expect( + service.updateProfile("user-1", { + companyEmail: "someone-else@example.com", + companyPhone: "+251911999999", + } as never), + ).resolves.toBeDefined(); + const [, patch] = deps.companiesRepo.update.mock.calls.at(-1)!; + expect(patch.email).toBe("abebe@example.com"); + expect(patch.phone).toBe("+251911000000"); + }); + + // "Same as owner" copies `ownerEmail ?? null` onto the GM while setting + // `gmFaydaSub`. Locking that null made generalManagerEmail required by + // onboarding, hidden by the portal's link card and unwritable at once. + it("lets the GM's details be typed when the copied owner identity carried none", async () => { + const { service } = makeService({ + attributes: { + ...OWNER_VERIFIED, + gmSameAsOwner: true, + gmFaydaSub: "owner-sub", + generalManagerName: "Abebe Bikila", + generalManagerEmail: null, + generalManagerPhone: null, + }, + }); + + await expect( + service.updateProfile("user-1", { + generalManagerEmail: "gm@example.com", + generalManagerPhone: "+251911888888", + } as never), + ).resolves.toBeDefined(); }); it("never locks or gates the general manager — it is not the verified subject", async () => { diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index f21263c7f..eb6b28b44 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -739,6 +739,39 @@ export class CompaniesService { return out; } + /** + * `UpdateProfileDto` keys this company's completed verifications own — the + * ones `mapProfileDtoToCompanyUpdates` overwrites with the verified value + * whatever a request submits for them. + * + * A key only lands here once there is a verified value to hold it to: Fayda's + * email and phone claims are optional, and a verification that returned + * neither owns nothing to overwrite with. + * + * The map is the enforcement; this is the list used to keep those keys out of + * a change request in the first place. If the two ever drift the map still + * wins — the cost is a staged field that approving turns out not to move. + */ + private faydaOwnedKeys(company: Company): string[] { + const attrs = company.attributes ?? {}; + const held = (key: string) => { + const v = attrs[key]; + return v !== null && v !== undefined && v !== ""; + }; + + const keys: string[] = []; + if (attrs.ownerFaydaSub) { + // The Company-column mirrors of the owner's verified contact details. + if (held("ownerEmail")) keys.push("companyEmail"); + if (held("ownerPhone")) keys.push("companyPhone"); + } + for (const subject of IDENTITY_SUBJECTS) { + if (!attrs[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue; + keys.push(...IDENTITY_OWNED_FIELDS[subject].filter(held)); + } + return keys; + } + /** * Translate an UpdateProfileDto (or a staged change-request snapshot) into a * `Company` patch: scalar columns plus a merged `attributes` blob (contact/GM/ @@ -829,49 +862,46 @@ export class CompaniesService { // lets the customer type them once verified) — lock them the same way // ownerEmail/ownerPhone themselves are locked below, once there is a // verified owner to lock them to. + // + // Keyed on the verified VALUE, not on `ownerFaydaSub`: Fayda's email and + // phone claims are optional, so a verification can prove the person while + // supplying neither (see completeIdentityVerification's conditional + // spreads). The portal falls back to the account email / eTrade's + // registered phone in exactly that case and submits it on every save of + // the company step — locking against an absent value would 400 that + // forever, and re-verifying could never clear it because Fayda still has + // nothing to return. if (attrUpdates.ownerFaydaSub) { - if ( - dto.companyEmail !== undefined && - dto.companyEmail !== attrUpdates.ownerEmail - ) { - throw new BadRequestException( - "companyEmail is set by the owner's Fayda verification and cannot be edited. Re-verify to change it.", - ); - } - if ( - dto.companyPhone !== undefined && - normalizeE164(dto.companyPhone) !== - normalizeE164(String(attrUpdates.ownerPhone ?? "")) - ) { - throw new BadRequestException( - "companyPhone is set by the owner's Fayda verification and cannot be edited. Re-verify to change it.", - ); - } + if (attrUpdates.ownerEmail && dto.companyEmail !== undefined) + companyUpdates.email = attrUpdates.ownerEmail; + if (attrUpdates.ownerPhone && dto.companyPhone !== undefined) + companyUpdates.phone = normalizeE164(String(attrUpdates.ownerPhone)); } // Renaming a Fayda-verified person by hand would launder the guarantee - // away, so the fields the verification owns are refused once it exists. + // away, so the verification keeps these fields: a submission that disagrees + // is overwritten with the verified value rather than rejected — the same + // doctrine `applyEtradeSourcedFields` uses for eTrade's fields, and for the + // same reason. The customer never types these (the portal derives them, and + // a stale form or a re-render can echo back something else entirely), so a + // 400 punishes a save they never made while an overwrite lands the truth. for (const subject of IDENTITY_SUBJECTS) { if (!attrUpdates[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue; for (const field of IDENTITY_OWNED_FIELDS[subject]) { - const incoming = (dto as Record)[field]; - if (incoming === undefined) continue; - // The verification itself is allowed to write them; anything else is - // compared against what is already stored, not against the value this - // same call just copied into the patch. Phones are compared normalized: - // a form that re-renders +251911000000 as 0911000000 is echoing the - // stored value back, not trying to change it. + if ((dto as Record)[field] === undefined) continue; + // The verification itself is what writes them; it must not be undone by + // the value this same call just copied into the patch. if (dto.faydaIdentity && field in dto.faydaIdentity) continue; const stored = company.attributes?.[field]; - const same = field.endsWith("Phone") - ? normalizeE164(String(incoming)) === - normalizeE164(String(stored ?? "")) - : incoming === stored; - if (!same) { - throw new BadRequestException( - `${field} is set by the Fayda verification of this company's ${IDENTITY_LABEL[subject]} and cannot be edited. Re-verify to change it.`, - ); - } + // A verification that supplied nothing for this field left no guarantee + // to protect, so it stays typeable. Matters most for the GM — + // `setGmSameAsOwner` copies `ownerEmail ?? null` onto + // `generalManagerEmail` while setting `gmFaydaSub`, and + // REQUIRED_COMPANY_INFO still demands that email, so holding a null + // here makes it required, hidden by the portal's "same as owner" card, + // and unwritable all at once. + if (stored === null || stored === undefined || stored === "") continue; + attrUpdates[field] = stored; } } @@ -977,6 +1007,11 @@ export class CompaniesService { // for review with the live row left intact. await this.assertTinAvailable(company, dto.tin); const fields = this.pickDefined(dto); + // Drop what the verifications own before anything is staged. Approving one + // of these could not change the live row — mapProfileDtoToCompanyUpdates + // writes the verified value back over it — so showing it to a reviewer + // asks them to rule on a change that does not exist. + for (const key of this.faydaOwnedKeys(company)) delete fields[key]; const selfService: Record = {}; const staged: Record = {}; for (const [key, value] of Object.entries(fields)) { @@ -2022,6 +2057,11 @@ export class CompaniesService { // replace it before the application counts as complete. const flaggedDelegation = delegationDue && delegation.flagged; + // Mirrors `poaProven` in buildCompanyIdentityState — see the note there. + const poaProven = identity.faydaRequired + ? identity.poa.verified + : identity.poa.verified || Boolean(identity.poa.name?.trim()); + const outstanding = [ ...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`), ...missingDocs.map((d) => `Upload your ${d.fileLabel}`), @@ -2039,8 +2079,19 @@ export class CompaniesService { ...(identity.faydaRequired && !identity.owner.verified ? ["Verify the company owner's identity with Fayda"] : []), - ...((poaRequired || poaProvided) && !identity.poa.verified - ? ["Verify your Power of Attorney's identity with Fayda"] + // Nationality-aware, exactly like `poaProven` in + // buildCompanyIdentityState and the check in `assertIdentityVerified`: + // Fayda is an Ethiopian national ID, so a foreign company's typed + // representative has to count. Demanding a verification here regardless + // made this list disagree with the rule actually enforced, and left a + // foreign freight forwarder unable to submit — asked for a Fayda + // verification its representative may have no way to obtain. + ...((poaRequired || poaProvided) && !poaProven + ? [ + identity.faydaRequired + ? "Verify your Power of Attorney's identity with Fayda" + : "Name your Power of Attorney, or verify them with Fayda", + ] : []), ...(identity.passportRequired && !identity.owner.passportNumber ? ["Add the company owner's passport number"] @@ -2054,7 +2105,10 @@ export class CompaniesService { const poaItemCount = delegationDue ? 1 : 0; // One item per identity credential the company has to prove: the owner // always (Fayda for Ethiopian, passport for foreign), plus the PoA once - // there is one — that one is Fayda whatever the nationality. + // there is one — Fayda for an Ethiopian company, a named representative + // for a foreign one, same rule as `poaProven` above. Counting a foreign + // company's typed PoA as unproven here left the progress bar permanently + // short of 100% on an item it had already satisfied. const ownerCredentialDue = identity.faydaRequired || identity.passportRequired; const ownerCredentialProven = identity.faydaRequired @@ -2064,7 +2118,7 @@ export class CompaniesService { (ownerCredentialDue ? 1 : 0) + (delegationDue ? 1 : 0); const missingIdentityCount = (ownerCredentialDue && !ownerCredentialProven ? 1 : 0) + - (delegationDue && !identity.poa.verified ? 1 : 0); + (delegationDue && !poaProven ? 1 : 0); const total = requiredInfo.length + requiredDocCount + @@ -2732,7 +2786,13 @@ export class CompaniesService { // The verified payload owns the person's details from here on. ...(result.fullName ? { [`${prefix}Name`]: result.fullName } : {}), ...(result.email ? { [`${prefix}Email`]: result.email } : {}), - ...(result.phoneNumber ? { [`${prefix}Phone`]: result.phoneNumber } : {}), + // Fayda returns whatever the national registry holds, which is routinely a + // local number ("0911223344"). Every typed phone in this service is stored + // E.164, and `@IsValidPhone()` rejects anything else — so a raw claim here + // becomes a value the portal reads back and cannot resubmit. + ...(result.phoneNumber + ? { [`${prefix}Phone`]: normalizeE164(result.phoneNumber) } + : {}), ...(result.address ? { [`${prefix}Address`]: result.address } : {}), }; diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts index 596644fab..dc7729479 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts @@ -1,4 +1,12 @@ -import { IsString, IsOptional, IsEmail, MaxLength, IsEnum, IsIn } from 'class-validator'; +import { + IsString, + IsOptional, + IsEmail, + MaxLength, + IsEnum, + IsIn, + Matches, +} from 'class-validator'; import { ETHIOPIAN_REGIONS, type EthiopianRegion } from '@edr/types'; import { CompanyNationality } from '../entities/company.entity'; import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; @@ -39,9 +47,13 @@ export class UpdateProfileDto { @IsTin({ message: 'TIN must be exactly 10 digits' }) tin?: string; + // Ethiopian VAT registration numbers are 10 digits, the same shape as the + // TIN. Both portal forms enforce that; without it here the API happily stored + // whatever a stale client sent, and the two layers disagreed about what the + // column may hold. @IsOptional() @IsString() - @MaxLength(50) + @Matches(/^\d{10}$/, { message: 'VAT number must be exactly 10 digits' }) vatNumber?: string; // `fanNumber` is deliberately absent: the FAN is the Fayda number of the diff --git a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts index 3f395c369..924c1a72d 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts @@ -177,8 +177,14 @@ export class ContractPricingService { } } if (contract.lastMileDeliveryAddress) { + // New-style last-mile rates (PER_TON_KM / distance-banded PER_KM) are + // priced operationally per job, not as a single contract unit price. const lm = liveRates.find( - (r) => r.rateType === 'LAST_MILE' && r.currency === 'USD', + (r) => + r.rateType === 'LAST_MILE' && + r.currency === 'USD' && + r.rateUnit !== 'PER_TON_KM' && + r.minKm == null, ); if (lm && Number(lm.rateValue) > 0) { lineItems.push({ 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 b11912b2c..a35978020 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 @@ -3,6 +3,7 @@ import { FindOptionsWhere, In, IsNull, Not } from 'typeorm'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { attachMileFinancials } from '../../common/mile-financials.util'; +import { estimateMileKm } from '../../common/mile-distance.util'; import { VehicleAvailability } from '../vehicles/entities/vehicle.entity'; import { BookingsRepository } from "../bookings/bookings.repository"; import { DriversService } from "../drivers/drivers.service"; @@ -285,7 +286,7 @@ export class FirstMileService { status: dto.status ?? "READY_TO_TRANSIT", advancedPayment: dto.advancedPayment ?? 0, remainingPayment: dto.remainingPayment ?? 0, - estimatedKm: dto.estimatedKm ?? null, + estimatedKm: dto.estimatedKm ?? (await estimateMileKm(this.dataSource, dto.bookingId, 'FIRST')), exactKm: dto.exactKm ?? null, vehicleId: dto.vehicleId ?? null, paid: (dto as any).paid ?? false, diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/dto/approve-last-mile-request.dto.ts b/apps/edr-freight-api/src/modules/last-mile-requests/dto/approve-last-mile-request.dto.ts new file mode 100644 index 000000000..56be0c545 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile-requests/dto/approve-last-mile-request.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsNumber, Min } from 'class-validator'; + +export class ApproveLastMileRequestDto { + // The approve dialog prefills this from GET :id/price-estimate (rule-based), + // but the chief can still override — the typed value is what's invoiced. + @ApiProperty({ description: 'Advance amount the customer must pay before execution proceeds', example: 3000 }) + @Transform(({ value }) => Number(value)) + @IsNumber() + @Min(0.01) + advanceAmount!: number; +} diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/dto/reject-last-mile-request.dto.ts b/apps/edr-freight-api/src/modules/last-mile-requests/dto/reject-last-mile-request.dto.ts new file mode 100644 index 000000000..10cee19c3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile-requests/dto/reject-last-mile-request.dto.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; + +export class RejectLastMileRequestDto { + @ApiProperty({ description: 'Why the request is rejected (e.g. no truck available)' }) + @IsString() + @IsNotEmpty() + @MaxLength(500) + reason!: string; +} diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/dto/sign-last-mile-contract.dto.ts b/apps/edr-freight-api/src/modules/last-mile-requests/dto/sign-last-mile-contract.dto.ts new file mode 100644 index 000000000..9ebd25d38 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile-requests/dto/sign-last-mile-contract.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator'; + +export class SignLastMileContractDto { + @ApiPropertyOptional({ + description: + 'Signature PNG as base64 (data URI or raw). Omitted = reuse the saved profile signature.', + }) + @IsOptional() + @IsString() + signatureImageBase64?: string; + + @ApiProperty({ description: 'Name shown under the signature.' }) + @IsString() + @IsNotEmpty() + @MaxLength(160) + signerDisplayName!: string; + + @ApiPropertyOptional({ description: 'The consent statement the customer agreed to.' }) + @IsOptional() + @IsString() + @MaxLength(500) + consentText?: string; +} diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/dto/submit-last-mile-request.dto.ts b/apps/edr-freight-api/src/modules/last-mile-requests/dto/submit-last-mile-request.dto.ts new file mode 100644 index 000000000..2978f8381 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile-requests/dto/submit-last-mile-request.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ArrayNotEmpty, ArrayUnique, IsArray, IsDateString, IsString } from 'class-validator'; + +export class SubmitLastMileRequestDto { + @ApiProperty({ + type: [String], + description: + 'Container numbers the customer wants delivered via EDR last-mile — pass every booking container to select "all".', + }) + @IsArray() + @ArrayNotEmpty() + @ArrayUnique() + @IsString({ each: true }) + containerNumbers!: string[]; + + @ApiProperty({ + description: + 'Requested last-mile delivery date (ISO date), chosen by the customer against the train departure from Djibouti.', + example: '2026-08-15', + }) + @IsDateString() + deliveryDate!: string; +} diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/entities/last-mile-request.entity.ts b/apps/edr-freight-api/src/modules/last-mile-requests/entities/last-mile-request.entity.ts new file mode 100644 index 000000000..b85c2ad5c --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile-requests/entities/last-mile-request.entity.ts @@ -0,0 +1,112 @@ +import { BaseEntity } from '@edr/api-common'; +import { LastMileRequestStatus } from '@edr/types'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Booking } from '../../bookings/entities/booking.entity'; +import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; +import { LastMile } from '../../last-mile/entities/last-mile.entity'; + +export const LAST_MILE_REQUEST_STATUSES = [ + LastMileRequestStatus.AwaitingConfirmation, + LastMileRequestStatus.Submitted, + LastMileRequestStatus.Approved, + LastMileRequestStatus.Rejected, +] as const; + +/** + * The pre-approval confirmation stage in front of `LastMile`: fired when a + * train departs Djibouti, filled by the customer, reviewed by the Truck & + * Machinery chief. One row per (bookingId, trainScheduleId) — a booking whose + * containers arrive across several departures gets a request per departure. + */ +@Entity({ name: 'last_mile_requests', schema: 'freight' }) +@Index(['bookingId']) +@Index(['status']) +export class LastMileRequest extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking, { nullable: false, eager: false }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; + + @Column({ name: 'train_schedule_id', type: 'uuid' }) + trainScheduleId!: string; + + @ManyToOne(() => TrainSchedule, { nullable: false, eager: false }) + @JoinColumn({ name: 'train_schedule_id' }) + trainSchedule?: TrainSchedule; + + @Column({ name: 'status', type: 'varchar', length: 30, default: LastMileRequestStatus.AwaitingConfirmation }) + status!: LastMileRequestStatus; + + /** Customer's container selection — "all" is just every booking container listed here. */ + @Column({ name: 'requested_container_numbers', type: 'text', array: true, nullable: true }) + requestedContainerNumbers?: string[] | null; + + @Column({ name: 'reminder_sent_at', type: 'timestamptz', nullable: true }) + reminderSentAt?: Date | null; + + @Column({ name: 'submitted_by_user_id', type: 'uuid', nullable: true }) + submittedByUserId?: string | null; + + @Column({ name: 'submitted_at', type: 'timestamptz', nullable: true }) + submittedAt?: Date | null; + + @Column({ name: 'reviewed_by_staff_id', type: 'uuid', nullable: true }) + reviewedByStaffId?: string | null; + + @Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true }) + reviewedAt?: Date | null; + + @Column({ name: 'rejection_reason', type: 'text', nullable: true }) + rejectionReason?: string | null; + + @Column({ name: 'resulting_last_mile_id', type: 'uuid', nullable: true }) + resultingLastMileId?: string | null; + + /** Customer-chosen last-mile delivery date (guided by the train's Djibouti departure). */ + @Column({ name: 'requested_delivery_date', type: 'date', nullable: true }) + requestedDeliveryDate?: string | null; + + /** Chief-approved advance — invoiced only after the customer signs the LM contract. */ + @Column({ + name: 'approved_advance_amount', + type: 'numeric', + precision: 14, + scale: 2, + nullable: true, + transformer: { + to: (v?: number | null) => v, + from: (v?: string | null) => (v == null ? null : Number(v)), + }, + }) + approvedAdvanceAmount?: number | null; + + /** Rate snapshot taken at approval, rendered into the contract document. */ + @Column({ name: 'contract_summary', type: 'jsonb', nullable: true }) + contractSummary?: { + estimatedKm: number | null; + mode: string | null; + currency: string | null; + total: number | null; + lines: Array<{ description: string; amount: number }>; + advanceAmount: number; + } | null; + + @Column({ name: 'contract_generated_at', type: 'timestamptz', nullable: true }) + contractGeneratedAt?: Date | null; + + @Column({ name: 'customer_signed_at', type: 'timestamptz', nullable: true }) + customerSignedAt?: Date | null; + + @Column({ name: 'signer_display_name', type: 'varchar', length: 160, nullable: true }) + signerDisplayName?: string | null; + + @Column({ name: 'consent_text', type: 'text', nullable: true }) + consentText?: string | null; + + @ManyToOne(() => LastMile, { nullable: true, eager: false }) + @JoinColumn({ name: 'resulting_last_mile_id' }) + resultingLastMile?: LastMile | null; +} diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-contract.service.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-contract.service.ts new file mode 100644 index 000000000..c30112617 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-contract.service.ts @@ -0,0 +1,318 @@ +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import * as fs from 'fs'; +import * as path from 'path'; +import Handlebars from 'handlebars'; +import { Readable } from 'stream'; +import { DataSource } from 'typeorm'; +import { LastMileRequestStatus } from '@edr/types'; + +import { ContractPdfService } from '../../contracts/contract-pdf.service'; +import { Booking } from '../bookings/entities/booking.entity'; +import { BookingsService } from '../bookings/bookings.service'; +import { FilesService } from '../files/files.service'; +import { FileRecord } from '../files/entities/file.entity'; +import { MinioService } from '../minio/minio.service'; +import { SignaturesService } from '../signatures/signatures.service'; +import { SignLastMileContractDto } from './dto/sign-last-mile-contract.dto'; +import { LastMileRequest } from './entities/last-mile-request.entity'; +import { LastMileRequestsRepository } from './last-mile-requests.repository'; +import { LastMileRequestsService } from './last-mile-requests.service'; + +const FILE_RESOURCE = 'last_mile_requests'; + +/** + * The LM contract in front of the advance payment: generated when the chief + * approves the request, viewed and signed by the customer in the portal, and + * only then invoiced (LastMileRequestsService.generateAdvanceInvoice). Single + * signer (customer), so the signature lives on the request row itself — no + * signature-rows table like bookings/CRSP contracts need for multi-role. + */ +@Injectable() +export class LastMileContractService { + private readonly logger = new Logger(LastMileContractService.name); + private compiledTemplate: Handlebars.TemplateDelegate | null = null; + + constructor( + private readonly requestsRepository: LastMileRequestsRepository, + private readonly requestsService: LastMileRequestsService, + private readonly bookingsService: BookingsService, + private readonly filesService: FilesService, + private readonly minioService: MinioService, + private readonly pdfService: ContractPdfService, + private readonly signaturesService: SignaturesService, + private readonly dataSource: DataSource, + ) {} + + async getContractView(id: string, viewerUserId?: string | null) { + const request = await this.requireApprovedRequest(id); + const booking = await this.requireBooking(request); + const view = await this.buildViewModel(request, booking); + const html = this.render(view); + const savedSignature = viewerUserId + ? ((await this.signaturesService.getForUser(viewerUserId)) ?? undefined) + : undefined; + return { + requestId: request.id, + bookingId: request.bookingId, + bookingReference: booking.reference, + status: request.status, + html, + customerSignedAt: request.customerSignedAt ?? null, + signerDisplayName: request.signerDisplayName ?? null, + canSign: !request.customerSignedAt, + savedSignature, + }; + } + + async streamContract(id: string) { + const request = await this.requireApprovedRequest(id); + const booking = await this.requireBooking(request); + const record = await this.upsertContractPdf(request, booking); + return this.filesService.streamById(record.id); + } + + async sign( + id: string, + dto: SignLastMileContractDto, + signerUserId: string | null, + ): Promise { + const request = await this.requireApprovedRequest(id); + if (request.customerSignedAt) { + throw new BadRequestException('This last-mile contract is already signed'); + } + const booking = await this.requireBooking(request); + + if (signerUserId) { + const companyId = await this.bookingsService.resolveCustomerCompanyId(signerUserId); + if (companyId && booking.companyId && companyId !== booking.companyId) { + throw new BadRequestException('This request does not belong to your company'); + } + } + + // Drawn signature wins; otherwise fall back to the saved profile signature + // (same contract-signing convention as modules/contracts). + let imageBase64 = dto.signatureImageBase64; + if (!imageBase64 && signerUserId) { + const saved = await this.signaturesService.getForUser(signerUserId); + if (saved?.signatureImageUrl?.startsWith('data:')) { + imageBase64 = saved.signatureImageUrl; + } + } + if (!imageBase64) { + throw new BadRequestException( + 'No signature image provided and no saved signature on your profile', + ); + } + + const buffer = this.decodeSignatureImage(imageBase64); + const sigFile = this.toUploadFile( + `signature-customer-${booking.reference ?? request.id}.png`, + 'image/png', + buffer, + ); + const fileRecord = await this.filesService.upsertByCode({ + resourceId: request.id, + resource: FILE_RESOURCE, + code: 'signature_customer', + file: sigFile, + }); + + await this.requestsRepository.update(id, { + customerSignedAt: new Date(), + signerDisplayName: dto.signerDisplayName, + consentText: dto.consentText ?? null, + } as Partial); + + // Best-effort: keep the reusable profile signature fresh for next time. + if (signerUserId && dto.signatureImageBase64) { + try { + await this.signaturesService.upsertForUser({ + userId: signerUserId, + signerDisplayName: dto.signerDisplayName, + signatureImageBase64: dto.signatureImageBase64, + }); + } catch (err) { + this.logger.warn(`Could not save reusable signature for user ${signerUserId}: ${err}`); + } + } + + const signed = (await this.requestsRepository.findById(id, { + relations: { booking: { company: true } }, + }))!; + + // Render + store the signed PDF, then invoice the advance. PDF failure must + // not block the invoice — the document re-renders on view/download. + try { + await this.upsertContractPdf(signed, booking, fileRecord); + } catch (err) { + this.logger.warn( + `Signed LM contract PDF deferred for ${booking.reference}: ${err}. It will render on view/download.`, + ); + } + await this.requestsService.generateAdvanceInvoice(signed); + + return signed; + } + + private async upsertContractPdf( + request: LastMileRequest, + booking: Booking, + signatureRecord?: FileRecord, + ): Promise { + const view = await this.buildViewModel(request, booking, signatureRecord); + const html = this.render(view); + const pdfBuffer = await this.pdfService.htmlToPdfBuffer(html); + const companyName = booking.company?.name ?? 'Customer'; + const fileName = `LM_${companyName.replace(/[^A-Za-z0-9._-]+/g, '_')}.pdf`; + const file = this.toUploadFile(fileName, 'application/pdf', pdfBuffer); + return this.filesService.upsertByCode({ + resourceId: request.id, + resource: FILE_RESOURCE, + code: 'contract', + file, + }); + } + + private async buildViewModel( + request: LastMileRequest, + booking: Booking, + signatureRecord?: FileRecord, + ) { + const summary = request.contractSummary; + const containers = request.requestedContainerNumbers ?? []; + const cargoDescription = + booking.cargoFreeText || booking.cargoType?.cargoTypeName || null; + + const departedRows: Array<{ departedAt: Date | null }> = await this.dataSource.query( + `SELECT departed_from_djibouti_at AS "departedAt" + FROM freight.import_djibouti_operations + WHERE train_schedule_id = $1 AND deleted_at IS NULL + LIMIT 1`, + [request.trainScheduleId], + ); + + return { + companyName: booking.company?.name ?? 'Customer', + bookingReference: booking.reference ?? request.bookingId, + containerCount: containers.length || null, + containerList: containers.join(', '), + cargoDescription, + deliveryAddress: booking.lastMileDeliveryAddress ?? null, + trainDepartureDate: this.formatDate(departedRows[0]?.departedAt), + deliveryDate: this.formatDate(request.requestedDeliveryDate) ?? '—', + requestDate: this.formatDate(request.submittedAt ?? request.reminderSentAt ?? request.createdAt) ?? '—', + approvalDate: this.formatDate(request.reviewedAt) ?? '—', + currency: summary?.currency ?? booking.paymentCurrency ?? 'ETB', + rateLines: (summary?.lines ?? []).map((l) => ({ + description: l.description, + amount: this.formatAmount(l.amount), + })), + estimatedKm: summary?.estimatedKm ?? null, + advanceAmount: this.formatAmount( + summary?.advanceAmount ?? request.approvedAdvanceAmount ?? 0, + ), + signature: request.customerSignedAt + ? { + signerDisplayName: request.signerDisplayName ?? '', + signedAt: this.formatDate(request.customerSignedAt) ?? '', + consentText: request.consentText ?? null, + imageUrl: await this.signatureImageDataUri(request, signatureRecord), + } + : null, + }; + } + + /** Signature PNG as a data URI so the PDF renderer needs no MinIO access. */ + private async signatureImageDataUri( + request: LastMileRequest, + signatureRecord?: FileRecord, + ): Promise { + try { + const record = + signatureRecord ?? + (await this.filesService.findByCode(request.id, FILE_RESOURCE, 'signature_customer')); + if (!record.url) return null; + const objectName = this.minioService.getObjectNameFromUrl(record.url); + const stream = await this.minioService.getFileStream(objectName); + const buffer = await this.streamToBuffer(stream); + return `data:image/png;base64,${buffer.toString('base64')}`; + } catch { + return null; + } + } + + private render(view: Record): string { + if (!this.compiledTemplate) { + const source = fs.readFileSync( + path.join(__dirname, '..', '..', 'contracts', 'templates', 'last-mile.hbs'), + 'utf-8', + ); + this.compiledTemplate = Handlebars.compile(source); + } + return this.compiledTemplate(view); + } + + private async requireApprovedRequest(id: string): Promise { + const request = await this.requestsService.findById(id); + if (request.status !== LastMileRequestStatus.Approved) { + throw new BadRequestException( + `The last-mile contract is available once the request is approved (current status: ${request.status})`, + ); + } + return request; + } + + private async requireBooking(request: LastMileRequest): Promise { + const booking = await this.dataSource.manager.findOne(Booking, { + where: { id: request.bookingId }, + relations: { company: true, cargoType: true }, + }); + if (!booking) throw new BadRequestException(`Booking ${request.bookingId} not found`); + return booking; + } + + private formatDate(value?: Date | string | null): string | null { + if (!value) return null; + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return null; + return date.toISOString().slice(0, 10); + } + + private formatAmount(value: number): string { + return Number(value).toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + } + + private toUploadFile(name: string, mimetype: string, buffer: Buffer): Express.Multer.File { + return { + fieldname: 'file', + originalname: name, + encoding: '7bit', + mimetype, + size: buffer.length, + buffer, + stream: Readable.from(buffer), + destination: '', + filename: '', + path: '', + }; + } + + private decodeSignatureImage(base64: string): Buffer { + const raw = base64.includes(',') ? base64.split(',')[1]! : base64; + return Buffer.from(raw, 'base64'); + } + + private streamToBuffer(stream: Readable): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + stream.on('data', (chunk: Buffer | string) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + stream.on('error', reject); + stream.on('end', () => resolve(Buffer.concat(chunks))); + }); + } +} diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts new file mode 100644 index 000000000..4c4ac88e5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts @@ -0,0 +1,132 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query, Res } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import type { Response } from 'express'; + +import { CurrentUser } from '@edr/api-common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; +import { LastMileRequestStatus } from '@edr/types'; + +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { ApproveLastMileRequestDto } from './dto/approve-last-mile-request.dto'; +import { RejectLastMileRequestDto } from './dto/reject-last-mile-request.dto'; +import { SignLastMileContractDto } from './dto/sign-last-mile-contract.dto'; +import { SubmitLastMileRequestDto } from './dto/submit-last-mile-request.dto'; +import { LastMileContractService } from './last-mile-contract.service'; +import { LastMileRequestsService } from './last-mile-requests.service'; + +@ApiTags('last-mile-requests') +@ApiBearerAuth() +@Controller('last-mile-requests') +export class LastMileRequestsController { + constructor( + private readonly requestsService: LastMileRequestsService, + private readonly contractService: LastMileContractService, + ) {} + + @Get() + @BookingStaff(FREIGHT_PERMS.lastMile.requestView) + @ApiOperation({ summary: 'List last-mile confirmation requests' }) + findAll( + @Query('status') status?: string, + @Query('bookingId') bookingId?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + return this.requestsService.findAll({ + status: status as LastMileRequestStatus | undefined, + bookingId, + page: page ? parseInt(page, 10) : undefined, + pageSize: pageSize ? parseInt(pageSize, 10) : undefined, + }); + } + + @Get('free-truck-count') + @BookingStaff(FREIGHT_PERMS.lastMile.requestView) + @ApiOperation({ summary: 'Free (ACTIVE + unassigned) trucks — informational context for approval' }) + freeTruckCount() { + return this.requestsService.freeTruckCount().then((count) => ({ count })); + } + + @Get(':id/price-estimate') + @BookingStaff(FREIGHT_PERMS.lastMile.requestView) + @ApiOperation({ + summary: + 'Rule-based last-mile price estimate (estimated km × live last-mile rates) — informational context for approval', + }) + priceEstimate(@Param('id', ParseUUIDPipe) id: string) { + return this.requestsService.priceEstimate(id); + } + + // Customer-facing like :id/submit — the service ownership-checks against the + // resolved company; staff may also open it (read-only view). + @Get(':id/contract/view') + @ApiOperation({ summary: 'LM contract view model + rendered HTML + saved signature' }) + contractView(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser) { + return this.contractService.getContractView(id, user?.id ?? null); + } + + @Get(':id/contract/document') + @ApiOperation({ summary: 'Download the LM contract PDF (LM_.pdf)' }) + async contractDocument( + @Param('id', ParseUUIDPipe) id: string, + @Res() res: Response, + ): Promise { + const { stream, record } = await this.contractService.streamContract(id); + res.setHeader('Content-Type', record.mimeType ?? 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="${record.name}"`); + stream.pipe(res); + } + + @Post(':id/contract/sign') + @ApiOperation({ summary: 'Customer agrees and signs the LM contract — then the advance invoice is issued' }) + signContract( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SignLastMileContractDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.contractService.sign(id, dto, user?.id ?? null); + } + + @Get(':id') + @BookingStaff(FREIGHT_PERMS.lastMile.requestView) + @ApiOperation({ summary: 'Get a last-mile confirmation request by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.requestsService.findById(id); + } + + // No @BookingStaff — the customer (portal) fills this, not backoffice staff. + // TODO: integrate @edr/auth — @CurrentUser is a stub until then; the service + // still cross-checks the request's booking against the resolved company. + @Post(':id/submit') + @ApiOperation({ summary: "Customer confirms which containers go via EDR last-mile" }) + submit( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SubmitLastMileRequestDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.requestsService.submit(id, user?.id ?? null, dto.containerNumbers, dto.deliveryDate); + } + + @Post(':id/approve') + @BookingStaff(FREIGHT_PERMS.lastMile.requestApprove) + @ApiOperation({ summary: 'Truck & Machinery chief approves the request — LM contract becomes signable; the advance invoice follows the customer signature' }) + approve( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: ApproveLastMileRequestDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.requestsService.approve(id, user?.id ?? null, dto.advanceAmount); + } + + @Post(':id/reject') + @BookingStaff(FREIGHT_PERMS.lastMile.requestApprove) + @ApiOperation({ summary: 'Truck & Machinery chief rejects the request with a reason' }) + reject( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: RejectLastMileRequestDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.requestsService.reject(id, user?.id ?? null, dto.reason); + } +} diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.module.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.module.ts new file mode 100644 index 000000000..c58e3d1c8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.module.ts @@ -0,0 +1,38 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { ContractPdfService } from '../../contracts/contract-pdf.service'; +import { BillingModule } from '../billing/billing.module'; +import { BookingsModule } from '../bookings/bookings.module'; +import { FilesModule } from '../files/files.module'; +import { LastMileModule } from '../last-mile/last-mile.module'; +import { MinioModule } from '../minio/minio.module'; +import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; +import { SignaturesModule } from '../signatures/signatures.module'; +import { LastMileRequest } from './entities/last-mile-request.entity'; +import { LastMileContractService } from './last-mile-contract.service'; +import { LastMileRequestsController } from './last-mile-requests.controller'; +import { LastMileRequestsRepository } from './last-mile-requests.repository'; +import { LastMileRequestsService } from './last-mile-requests.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([LastMileRequest]), + BillingModule, + forwardRef(() => BookingsModule), + LastMileModule, + NotificationInboxModule, + FilesModule, + MinioModule, + SignaturesModule, + ], + controllers: [LastMileRequestsController], + providers: [ + LastMileRequestsRepository, + LastMileRequestsService, + LastMileContractService, + ContractPdfService, + ], + exports: [LastMileRequestsService], +}) +export class LastMileRequestsModule {} diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.repository.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.repository.ts new file mode 100644 index 000000000..536a6c022 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.repository.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { BaseRepository } from '@edr/api-common'; + +import { LastMileRequest } from './entities/last-mile-request.entity'; + +@Injectable() +export class LastMileRequestsRepository extends BaseRepository { + constructor( + @InjectRepository(LastMileRequest) + repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts new file mode 100644 index 000000000..901e7b9b5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts @@ -0,0 +1,421 @@ +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { DataSource, FindOptionsWhere } from 'typeorm'; +import { Freight, LastMileRequestStatus } from '@edr/types'; + +import { + LastMileCharge, + computeLastMileCharge, + lastMileShipmentShape, +} from '../../common/last-mile-charge.util'; +import { estimateMileKm } from '../../common/mile-distance.util'; +import { usesEdrMileService } from '../../common/mile-haulage.util'; +import { RatesService } from '../rule-engine/services/rates.service'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { BookingsRepository } from '../bookings/bookings.repository'; +import { BookingsService } from '../bookings/bookings.service'; +import { Booking } from '../bookings/entities/booking.entity'; +import { BillingService } from '../billing/billing.service'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { NotificationAudience, NotificationPriority, NotificationType } from '@edr/types'; +import { LastMileService } from '../last-mile/last-mile.service'; +import { Vehicle, VehicleAvailability, VehicleStatus } from '../vehicles/entities/vehicle.entity'; +import { LastMileRequest } from './entities/last-mile-request.entity'; +import { LastMileRequestsRepository } from './last-mile-requests.repository'; + +type ListFilter = { + status?: LastMileRequestStatus; + bookingId?: string; + page?: number; + pageSize?: number; +}; + +/** Just what remind() needs off a departed schedule — deliberately not the full + * `TrainSchedule` entity so this module never has to import train-scheduling code. */ +type DepartedSchedule = { id: string; trainNumber?: string | null }; + +@Injectable() +export class LastMileRequestsService { + private readonly logger = new Logger(LastMileRequestsService.name); + + constructor( + private readonly requestsRepository: LastMileRequestsRepository, + private readonly bookingsRepository: BookingsRepository, + private readonly bookingsService: BookingsService, + private readonly lastMileService: LastMileService, + private readonly billing: BillingService, + private readonly notifications: NotificationInboxService, + private readonly ratesService: RatesService, + private readonly dataSource: DataSource, + ) {} + + /** Container numbers on the booking (upper-cased) — mirrors LastMileService's own helper. */ + private async bookingContainerNumbers(bookingId: string): Promise { + const rows: Array<{ containerNumber: string }> = await this.dataSource.query( + `SELECT bcu.container_number AS "containerNumber" + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL`, + [bookingId], + ); + return rows.map((r) => r.containerNumber.trim().toUpperCase()); + } + + /** + * Poll for trains that have departed Djibouti and remind their eligible + * bookings. Deliberately a self-contained poller (raw SQL against + * `import_djibouti_operations`/`train_schedules`, no import of train-scheduling + * module code) rather than a hook inside `TrainSchedulingService.dispatchSchedule` + * — keeps this feature decoupled from that module entirely. `remindForDeparture` + * is idempotent per (bookingId, scheduleId), so re-scanning the same recent + * window on every tick is safe — a schedule already fully reminded is a no-op. + */ + @Cron('*/2 * * * *', { name: 'last-mile-request-departure-scan' }) + async scanDepartedSchedules(): Promise { + let schedules: DepartedSchedule[] = []; + try { + schedules = await this.dataSource.query( + `SELECT ts.id AS "id", ts.train_number AS "trainNumber" + FROM freight.import_djibouti_operations op + JOIN freight.train_schedules ts + ON ts.id = op.train_schedule_id AND ts.deleted_at IS NULL + WHERE op.deleted_at IS NULL + AND op.departed_from_djibouti_at IS NOT NULL + AND op.departed_from_djibouti_at > now() - interval '14 days'`, + ); + } catch (err) { + this.logger.warn(`Failed to scan for departed schedules: ${(err as Error).message}`); + return; + } + for (const schedule of schedules) { + await this.remindForDeparture(schedule); + } + } + + /** + * Fired for a train that has departed Djibouti (import direction). For every booking + * already loaded on this schedule that bought EDR last-mile, idempotently + * creates the AWAITING_CONFIRMATION request and reminds both the customer and + * the Truck & Machinery department. Fire-and-forget per booking — one bad + * booking must never block the rest of the departure notification. + */ + async remindForDeparture(schedule: DepartedSchedule): Promise { + let bookingIds: string[] = []; + try { + const rows: Array<{ bookingId: string }> = await this.dataSource.query( + `SELECT booking_id AS "bookingId" + FROM freight.train_schedule_bookings + WHERE train_schedule_id = $1 AND loading_status = 'LOADED' AND deleted_at IS NULL`, + [schedule.id], + ); + bookingIds = rows.map((r) => r.bookingId); + } catch (err) { + this.logger.warn(`Failed to load schedule bookings for ${schedule.id}: ${(err as Error).message}`); + return; + } + if (!bookingIds.length) return; + + for (const bookingId of bookingIds) { + try { + const booking = await this.bookingsRepository.findById(bookingId); + if (!booking) continue; + if ( + !usesEdrMileService({ + tradeDirection: booking.tradeDirection, + firstMile: booking.firstMilePickupAddress ?? null, + lastMile: booking.lastMileDeliveryAddress ?? null, + }) + ) { + continue; + } + await this.remind(booking, schedule); + } catch (err) { + this.logger.warn(`Failed to remind booking ${bookingId} for schedule ${schedule.id}: ${(err as Error).message}`); + } + } + } + + private async remind(booking: Booking, schedule: DepartedSchedule): Promise { + const [existing] = await this.requestsRepository.findAll({ + where: { bookingId: booking.id, trainScheduleId: schedule.id }, + take: 1, + }); + if (existing) return; // already reminded for this departure + + const request = await this.requestsRepository.create({ + bookingId: booking.id, + trainScheduleId: schedule.id, + status: LastMileRequestStatus.AwaitingConfirmation, + reminderSentAt: new Date(), + }); + + const trainLabel = schedule.trainNumber ? `train ${schedule.trainNumber}` : 'your train'; + if (booking.companyId) { + void this.notifications.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.SCHEDULE_UPDATE, + title: 'Confirm your last-mile delivery', + body: `${trainLabel} carrying booking ${booking.reference ?? booking.id} has departed Djibouti. Confirm which containers go via EDR last-mile.`, + link: `/bookings/${booking.id}/last-mile-confirm?requestId=${request.id}`, + data: { bookingId: booking.id, requestId: request.id }, + priority: NotificationPriority.HIGH, + }); + } + void this.notifications.notify({ + recipients: { permissionKeys: [FREIGHT_PERMS.lastMile.requestReview] }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.SCHEDULE_UPDATE, + title: 'Last-mile confirmation expected', + body: `${trainLabel} carrying booking ${booking.reference ?? booking.id} has departed Djibouti — awaiting the customer's last-mile confirmation.`, + link: `/dashboard/operations/last-mile?tab=requests`, + data: { bookingId: booking.id, requestId: request.id }, + priority: NotificationPriority.HIGH, + }); + } + + async findAll(filter: ListFilter = {}): Promise<{ + data: LastMileRequest[]; + meta: { total: number; page: number; pageSize: number; totalPages: number }; + }> { + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 50; + const where: FindOptionsWhere = {}; + if (filter.status) where.status = filter.status; + if (filter.bookingId) where.bookingId = filter.bookingId; + + const [data, total] = await this.requestsRepository.findAndCount({ + where, + relations: { booking: { company: true } }, + order: { createdAt: 'DESC' }, + skip: (page - 1) * pageSize, + take: pageSize, + }); + + return { + data, + meta: { total, page, pageSize, totalPages: Math.max(1, Math.ceil(total / pageSize)) }, + }; + } + + async findById(id: string): Promise { + const record = await this.requestsRepository.findById(id, { + relations: { booking: { company: true } }, + }); + if (!record) throw new NotFoundException(`Last-mile request ${id} not found`); + return record; + } + + /** + * Rule-based price estimate for the approval dialog: estimated km (yard GPS → + * delivery point, straight-line) × the LIVE last-mile rate rules against the + * containers the customer confirmed (or the booking's bulk tonnage). All + * nulls when km or rate coverage is missing — the dialog then behaves as + * before (manually typed advance). + */ + async priceEstimate(id: string): Promise<{ + estimatedKm: number | null; + mode: LastMileCharge['mode'] | null; + currency: string | null; + total: number | null; + lines: Array<{ description: string; amount: number }>; + }> { + const request = await this.findById(id); + const estimatedKm = await estimateMileKm(this.dataSource, request.bookingId, 'LAST'); + if (!estimatedKm) { + return { estimatedKm: null, mode: null, currency: null, total: null, lines: [] }; + } + const shape = await lastMileShipmentShape( + this.dataSource, + request.bookingId, + request.requestedContainerNumbers ?? [], + ); + const charge = computeLastMileCharge({ + ...shape, + km: estimatedKm, + liveRates: await this.ratesService.findLiveRatesDetailed(), + }); + return { + estimatedKm, + mode: charge?.mode ?? null, + currency: charge?.currency ?? null, + total: charge?.total ?? null, + lines: (charge?.lines ?? []).map(({ description, amount }) => ({ description, amount })), + }; + } + + /** Free (ACTIVE + unassigned) truck count — informational only for the approval screen. */ + async freeTruckCount(): Promise { + return this.dataSource.manager.count(Vehicle, { + where: { status: VehicleStatus.ACTIVE, availability: VehicleAvailability.FREE }, + }); + } + + async submit( + id: string, + userId: string | null, + containerNumbers: string[], + deliveryDate: string, + ): Promise { + const request = await this.findById(id); + if (request.status !== LastMileRequestStatus.AwaitingConfirmation) { + throw new BadRequestException(`Request is already ${request.status.toLowerCase()}`); + } + + if (userId) { + const companyId = await this.bookingsService.resolveCustomerCompanyId(userId); + if (companyId && request.booking?.companyId && companyId !== request.booking.companyId) { + throw new BadRequestException('This request does not belong to your company'); + } + } + + const bookingNumbers = await this.bookingContainerNumbers(request.bookingId); + const selected = containerNumbers.map((n) => n.trim().toUpperCase()); + const unknown = selected.filter((n) => !bookingNumbers.includes(n)); + if (unknown.length) { + throw new BadRequestException(`Container(s) not on this booking: ${unknown.join(', ')}`); + } + + await this.requestsRepository.update(id, { + requestedContainerNumbers: selected, + requestedDeliveryDate: deliveryDate, + status: LastMileRequestStatus.Submitted, + submittedByUserId: userId, + submittedAt: new Date(), + } as Partial); + + void this.notifications.notify({ + recipients: { permissionKeys: [FREIGHT_PERMS.lastMile.requestReview] }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.REQUEST_SUBMITTED, + title: 'Last-mile request ready for review', + body: `Booking ${request.booking?.reference ?? request.bookingId} confirmed ${selected.length} container(s) for EDR last-mile.`, + link: `/dashboard/operations/last-mile?tab=requests`, + data: { bookingId: request.bookingId, requestId: request.id }, + priority: NotificationPriority.NORMAL, + }); + + return this.findById(id); + } + + async approve(id: string, staffId: string | null, advanceAmount: number): Promise { + const request = await this.findById(id); + if (request.status !== LastMileRequestStatus.Submitted) { + throw new BadRequestException(`Only a submitted request can be approved (current status: ${request.status})`); + } + const booking = request.booking ?? (await this.bookingsRepository.findById(request.bookingId)); + if (!booking) throw new NotFoundException(`Booking ${request.bookingId} not found`); + + // Idempotent per booking — reuses the record if one already exists. + const lastMile = await this.lastMileService.create({ + bookingId: request.bookingId, + status: 'PAYMENT_PENDING', + advancedPayment: 0, + }); + + // No invoice yet: the advance is invoiced by LastMileContractService.sign() + // once the customer has signed the LM contract — doc first, then payment. + // Snapshot the rate estimate now so the contract shows the numbers the + // chief actually approved against, immune to later rate edits. + const estimate = await this.priceEstimate(id); + + await this.requestsRepository.update(id, { + status: LastMileRequestStatus.Approved, + reviewedByStaffId: staffId, + reviewedAt: new Date(), + resultingLastMileId: lastMile.id, + approvedAdvanceAmount: advanceAmount, + contractSummary: { ...estimate, advanceAmount }, + contractGeneratedAt: new Date(), + } as Partial); + + if (booking.companyId) { + void this.notifications.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.CONTRACT_STATUS, + title: 'Last-mile contract ready — view and sign', + body: `Your last-mile request for booking ${booking.reference ?? booking.id} was approved. Review and sign the last-mile contract to receive your advance invoice.`, + link: `/bookings/${booking.id}/last-mile-contract?requestId=${id}`, + data: { bookingId: booking.id, requestId: id, lastMileId: lastMile.id }, + priority: NotificationPriority.HIGH, + }); + } + + return this.findById(id); + } + + /** The advance invoice, deferred from approve() until the LM contract is signed. */ + async generateAdvanceInvoice(request: LastMileRequest): Promise { + const booking = request.booking ?? (await this.bookingsRepository.findById(request.bookingId)); + if (!booking) throw new NotFoundException(`Booking ${request.bookingId} not found`); + const advanceAmount = request.approvedAdvanceAmount; + if (!advanceAmount || !request.resultingLastMileId) { + throw new BadRequestException('Request has no approved advance to invoice'); + } + + await this.billing.generateInvoice({ + // 'last_mile' (not the InvoiceSource.LastMile enum value "lastmile") to + // match the existing source string LastMileInvoiceService/LastMileService + // already query by (findBySourceIds/findPayable/attachInvoices). + source: 'last_mile' as Freight.InvoiceSource, + sourceId: request.resultingLastMileId, + type: 'LAST_MILE_ADVANCE', + companyId: booking.companyId, + companyProfileId: booking.companyProfileId || '', + currency: booking.paymentCurrency || 'ETB', + lines: [ + { + chargeType: 'LAST_MILE_ADVANCE', + description: 'Last-mile delivery advance', + amount: advanceAmount, + }, + ], + totalAmount: advanceAmount, + }); + + if (booking.companyId) { + void this.notifications.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.INVOICE_ISSUED, + title: 'Last-mile contract signed — payment due', + body: `Thank you for signing the last-mile contract for booking ${booking.reference ?? booking.id}. Pay the advance invoice to proceed.`, + link: '/billing/invoices', + data: { bookingId: booking.id, requestId: request.id, lastMileId: request.resultingLastMileId }, + priority: NotificationPriority.HIGH, + }); + } + } + + async reject(id: string, staffId: string | null, reason: string): Promise { + const request = await this.findById(id); + if (request.status !== LastMileRequestStatus.Submitted) { + throw new BadRequestException(`Only a submitted request can be rejected (current status: ${request.status})`); + } + const booking = request.booking ?? (await this.bookingsRepository.findById(request.bookingId)); + + await this.requestsRepository.update(id, { + status: LastMileRequestStatus.Rejected, + reviewedByStaffId: staffId, + reviewedAt: new Date(), + rejectionReason: reason, + } as Partial); + + if (booking?.companyId) { + void this.notifications.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.BOOKING_STATUS, + title: 'Last-mile request rejected', + body: `Your last-mile request for booking ${booking.reference ?? booking.id} was rejected: ${reason}`, + link: `/bookings/${booking.id}`, + data: { bookingId: booking.id, requestId: id }, + priority: NotificationPriority.HIGH, + }); + } + + return this.findById(id); + } +} 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 8a6ec0779..36d0567ad 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,13 +1,16 @@ import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; +import { DataSource } from 'typeorm'; import { Freight } from '@edr/types'; +import { ruleBasedLastMileCharge } from '../../common/last-mile-charge.util'; import { BillingService, GenerateInvoiceInput, InvoiceEventPayload, } from '../billing/billing.service'; import { Invoice } from '../billing/entities/invoice.entity'; +import { RatesService } from '../rule-engine/services/rates.service'; import { LastMileRepository } from './last-mile.repository'; import { LastMile } from './entities/last-mile.entity'; @@ -26,6 +29,8 @@ export class LastMileInvoiceService { constructor( private readonly billing: BillingService, private readonly lastMileRepo: LastMileRepository, + private readonly ratesService: RatesService, + private readonly dataSource: DataSource, ) {} /** @@ -54,6 +59,41 @@ export class LastMileInvoiceService { return null; } + // Rule-based pricing first (bulk per-ton-km / container distance bands + // against the exact km): when a LIVE last-mile rate covers the job, it — + // not the per-vehicle price/km — is the delivery fee, with its own + // currency and per-size breakdown. Same resolver setDistances used to + // write remainingPayment, recomputed here so a rate change between the + // two moments settles on the invoice's side. + const exactKm = Number(record.exactKm) || 0; + const rule = + exactKm > 0 + ? await ruleBasedLastMileCharge( + this.dataSource, + await this.ratesService.findLiveRatesDetailed(), + record.id, + exactKm, + ) + : null; + if (rule && rule.total > 0) { + return this.billing.generateInvoice({ + source: 'last_mile' as Freight.InvoiceSource, + sourceId: record.id, + type: 'DELIVERY_FEE', + companyId: lm.booking!.companyId, + companyProfileId: lm.booking!.companyProfileId || '', + currency: rule.currency, + lines: rule.lines.map((line) => ({ + chargeType: 'DELIVERY', + description: line.description, + quantity: line.quantity, + unitRate: line.unitRate, + amount: line.amount, + })), + totalAmount: rule.total, + }); + } + // numeric columns come back as strings — coerce before billing. const totalAmount = Number(record.remainingPayment) || 0; if (!Number.isFinite(totalAmount) || totalAmount <= 0) { diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.guard.spec.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.guard.spec.ts index 232f59d50..78fe4933f 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.guard.spec.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.guard.spec.ts @@ -42,6 +42,7 @@ function makeService(opts: { booking?: BookingRow; hasCustomerTruck?: boolean }) { query } as unknown as DataSource, { record: jest.fn() } as never, // history {} as never, // billing + { findLiveRatesDetailed: jest.fn().mockResolvedValue([]) } as never, // ratesService {} as never, // filesService ); 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 af6c920fc..8ed5ae8aa 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 @@ -13,6 +13,9 @@ import { usesEdrMileService, } from '../../common/mile-haulage.util'; import { attachMileFinancials } from '../../common/mile-financials.util'; +import { ruleBasedLastMileCharge } from '../../common/last-mile-charge.util'; +import { estimateMileKm } from '../../common/mile-distance.util'; +import { RatesService } from '../rule-engine/services/rates.service'; import { assertBulkTonnageRemains, assertTruckCountWithinContainers, @@ -69,6 +72,7 @@ export class LastMileService { private readonly dataSource: DataSource, private readonly history: FleetHistoryService, private readonly billing: BillingService, + private readonly ratesService: RatesService, private readonly filesService: FilesService, ) {} @@ -426,7 +430,7 @@ export class LastMileService { status: dto.status ?? 'READY_TO_TRANSIT', advancedPayment: dto.advancedPayment ?? 0, remainingPayment: dto.remainingPayment ?? 0, - estimatedKm: dto.estimatedKm ?? null, + estimatedKm: dto.estimatedKm ?? (await estimateMileKm(this.dataSource, dto.bookingId, 'LAST')), exactKm: dto.exactKm ?? null, vehicleId: dto.vehicleId ?? null, paid: (dto as any).paid ?? false, @@ -456,8 +460,21 @@ export class LastMileService { @OnEvent("last_mile.invoice.paid") async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise { try { - // Invoice paid → the delivery is complete. Route through update() so it - // also frees the trucks + records history (same as "Mark Delivered"). + if (payload.type === 'LAST_MILE_ADVANCE') { + // Advance paid → the leg becomes dispatchable, not delivered. + await this.update(payload.sourceId, { + status: 'READY_TO_TRANSIT', + advancedPayment: payload.totalAmount, + } as unknown as UpdateLastMileDto); + this.logger.log( + `Last-mile ${payload.sourceId} READY_TO_TRANSIT on advance invoice ${payload.invoiceId} payment`, + ); + return; + } + if (payload.type !== 'DELIVERY_FEE') return; + // Delivery-fee invoice paid → the delivery is complete. Route through + // update() so it also frees the trucks + records history (same as + // "Mark Delivered"). await this.update(payload.sourceId, { status: 'DELIVERED', paid: true } as unknown as UpdateLastMileDto); this.logger.log(`Last-mile ${payload.sourceId} marked DELIVERED on invoice ${payload.invoiceId} payment`); } catch (err) { @@ -985,9 +1002,18 @@ export class LastMileService { (s, a) => s + (Number(a.distanceKm) || 0) * (Number(a.vehicle?.pricePerKm) || 0), 0, ); + // Prefer the rule-based last-mile rate (bulk per-ton-km / container + // distance bands) over the per-vehicle price; the truck math stays as the + // fallback when no LIVE rule covers this job. + const rule = await ruleBasedLastMileCharge( + this.dataSource, + await this.ratesService.findLiveRatesDetailed(), + id, + total, + ); await this.lastMileRepository.update(id, { exactKm: total, - remainingPayment: amount, + remainingPayment: rule?.total ?? amount, } as any); return this.findById(id); } diff --git a/apps/edr-freight-api/src/modules/payment/internal-payment.dto.ts b/apps/edr-freight-api/src/modules/payment/internal-payment.dto.ts index e69a0e15a..f437000bd 100644 --- a/apps/edr-freight-api/src/modules/payment/internal-payment.dto.ts +++ b/apps/edr-freight-api/src/modules/payment/internal-payment.dto.ts @@ -1,8 +1,8 @@ import { IsEnum, IsIn, - IsInt, IsISO8601, + IsNumber, IsOptional, IsPositive, IsString, @@ -37,7 +37,9 @@ export class PaymentEventDto { @ApiProperty() @IsString() referenceId!: string; @ApiProperty() @IsString() merchantOrderId!: string; @ApiProperty({ enum: ProviderMethod }) @IsEnum(ProviderMethod) provider!: string; - @ApiProperty() @IsInt() @IsPositive() amountMinor!: number; + // Major units, fractional (payment-api stores it as double precision) — an + // invoice of 12345.67 must not be rejected by an integer-only validator. + @ApiProperty() @IsNumber() @IsPositive() amountMinor!: number; @ApiProperty() @IsString() currency!: string; @ApiPropertyOptional() @IsOptional() @IsString() providerTxnId?: string; diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts index 4f9d06870..45a5838bd 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts @@ -8,7 +8,8 @@ import { } from '../entities/rate.entity'; const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const; -const CURRENCIES = ['USD'] as const; +// ETB is accepted only for last-mile rates; the service forces USD elsewhere. +const CURRENCIES = ['USD', 'ETB'] as const; export const INTERCITY_KINDS = ['CONTAINER', 'BULK'] as const; export const CARGO_KINDS = ['CONTAINER', 'BULK'] as const; @@ -92,6 +93,28 @@ export class CreateRateDto { @IsOptional() @IsIn([...RATE_UNITS]) rateUnit?: string; + + @ApiPropertyOptional({ + description: + 'Distance band start (km, inclusive). Container last-mile rates only (rateUnit = PER_KM).', + minimum: 0, + }) + @IsOptional() + @IsNumber() + @Min(0) + @Transform(({ value }) => (value === null || value === undefined || value === '' ? undefined : Number(value))) + minKm?: number; + + @ApiPropertyOptional({ + description: + 'Distance band end (km, exclusive). Null/omitted = open-ended band. Container last-mile rates only.', + minimum: 0, + }) + @IsOptional() + @IsNumber() + @Min(0) + @Transform(({ value }) => (value === null || value === undefined || value === '' ? undefined : Number(value))) + maxKm?: number; } export class SubmitRateForApprovalDto { diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts index 1de36bcfd..a3d336605 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts @@ -93,8 +93,12 @@ function unitsForShape(input: { case 'INTERCITY': return ['PER_CONTAINER', 'PER_TON', 'PER_WAGON', 'PER_KM']; case 'FIRST_MILE': - case 'LAST_MILE': return ['PER_CONTAINER', 'PER_TON', 'PER_KM', 'FLAT']; + case 'LAST_MILE': + // PER_KM = container mode (banded by distance + container size), + // PER_TON_KM = bulk mode (tons × km × rate). Legacy units kept for + // existing rows. + return ['PER_KM', 'PER_TON_KM', 'PER_CONTAINER', 'PER_TON', 'FLAT']; default: return ['FLAT']; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts index d62d5647f..e7089f08c 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts @@ -39,6 +39,8 @@ export const RATE_UNITS = [ 'PER_ITEM', 'PER_CONTAINER', 'PER_KM', + // Last-mile bulk: price = tons × km × rateValue. + 'PER_TON_KM', 'PER_INVOICE', 'FLAT', ] as const; @@ -156,6 +158,17 @@ export class Rate extends BaseEntity { @Column({ name: 'rate_unit', type: 'varchar', length: 30 }) rateUnit!: RateUnit; + /** + * Distance band for container last-mile rates (rateUnit = PER_KM, scoped by + * containerTypeId): the rate applies when minKm <= km < maxKm (maxKm NULL = + * open-ended). NULL on every other rate shape. + */ + @Column({ name: 'min_km', type: 'numeric', precision: 10, scale: 2, nullable: true }) + minKm?: number | null; + + @Column({ name: 'max_km', type: 'numeric', precision: 10, scale: 2, nullable: true }) + maxKm?: number | null; + @Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' }) status!: RateStatus; diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/yard-location.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/yard-location.entity.ts new file mode 100644 index 000000000..3d33b1998 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/yard-location.entity.ts @@ -0,0 +1,26 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, OneToOne } from 'typeorm'; + +import { Yard } from './yard.entity'; + +/** + * GPS position of a yard (decimal degrees, WGS84). One record per yard; + * today only the five facility yards (Sebeta, GMP/Indode, Mojo, Adama, + * Dire Dawa) are seeded. + */ +@Entity({ schema: 'freight', name: 'yard_locations' }) +@Index(['yardId'], { unique: true }) +export class YardLocation extends BaseEntity { + @Column({ name: 'yard_id', type: 'uuid' }) + yardId!: string; + + @OneToOne(() => Yard, { nullable: false, onDelete: 'CASCADE' }) + @JoinColumn({ name: 'yard_id' }) + yard?: Yard; + + @Column({ name: 'latitude', type: 'double precision' }) + latitude!: number; + + @Column({ name: 'longitude', type: 'double precision' }) + longitude!: number; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts index 1570e0d23..9797ff715 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts @@ -21,6 +21,8 @@ export interface IRatesRepository { tradeDirection?: string | null; originYardId?: string | null; destinationYardId?: string | null; + /** Band start for container last-mile rates; omitted/null elsewhere. */ + minKm?: number | null; }): Promise; findAll(options?: FindManyOptions): Promise; findAndCount(options?: FindManyOptions): Promise<[Rate[], number]>; diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts index 43c716a3b..7417987dc 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts @@ -74,6 +74,7 @@ export class RatesRepository implements IRatesRepository { tradeDirection?: string | null; originYardId?: string | null; destinationYardId?: string | null; + minKm?: number | null; }): Promise { const qb = this.repo .createQueryBuilder('rate') @@ -111,6 +112,13 @@ export class RatesRepository implements IRatesRepository { } else { qb.andWhere('rate.destination_yard_id IS NULL'); } + // Band start distinguishes sibling last-mile bands, mirroring the + // COALESCE(min_km, -1) column of UQ_rates_pattern. + if (pattern.minKm !== null && pattern.minKm !== undefined) { + qb.andWhere('rate.min_km = :minKm', { minKm: pattern.minKm }); + } else { + qb.andWhere('rate.min_km IS NULL'); + } return qb.getOne(); } diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/yards.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/yards.repository.ts index 5db5b72ae..b99b33a29 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/yards.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/yards.repository.ts @@ -43,6 +43,12 @@ export class YardsRepository implements IYardsRepository { findPaged(query: ListYardsQueryDto): Promise> { const qb = this.repo .createQueryBuilder('yard') + // createQueryBuilder does NOT auto-apply the soft-delete filter that + // repo.find()/findOne() get for free — without this, a renamed/replaced + // yard (e.g. an old "DMP" superseded by a new one) still shows up + // alongside the live one in every picker built off this endpoint, and a + // route picked against the dead yard id never matches any LIVE rate. + .where('yard.deleted_at IS NULL') .orderBy(`yard.${query.sortBy ?? 'displayOrder'}`, query.sortOrder ?? 'ASC') .addOrderBy('yard.label', 'ASC'); diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts index 2e1fd8090..d487387b8 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts @@ -27,6 +27,7 @@ import { WeightLimitRule } from './entities/weight-limit-rule.entity'; import { Yard } from './entities/yard.entity'; import { YardDistance } from './entities/yard-distance.entity'; import { YardFacility } from './entities/yard-facility.entity'; +import { YardLocation } from './entities/yard-location.entity'; import { APPROVAL_RULES_REPOSITORY } from './interfaces/approval-rules.repository.interface'; import { CARGO_TYPES_REPOSITORY } from './interfaces/cargo-types.repository.interface'; @@ -88,6 +89,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. Yard, YardDistance, YardFacility, + YardLocation, ShippingLine, Rate, ApprovalRule, diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts index 36c55dad3..6dde40d7d 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts @@ -37,6 +37,10 @@ const DIFFABLE_FIELDS = [ // diffed to nothing and the submit was refused as "nothing changed". 'originYardId', 'destinationYardId', + // Container last-mile distance bands. Missing here, a band-range edit on a + // LIVE last-mile rate would diff to "nothing changed". + 'minKm', + 'maxKm', ] as const; /** diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index d8ceb97ab..971097377 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -7,6 +7,7 @@ import { NotFoundException, } from '@nestjs/common'; import { PaginatedResponse, YardCountry } from '@edr/types'; +import { IsNull, Not } from 'typeorm'; import { CreateRateDto } from '../dto/create-rate.dto'; import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto'; import { UpdateRateDto } from '../dto/update-rate.dto'; @@ -340,6 +341,113 @@ export class RatesService { return appliesTo === 'INTERCITY' ? intercityKind === 'BULK' : appliesTo === 'BULK'; } + /** + * Validate and normalise the last-mile band fields for a rate shape. + * + * Last-mile rates come in two calculation modes: bulk (PER_TON_KM — one row + * per distance band, price = tons × km × rate) and container (PER_KM — one + * row per container type per distance band, price = km × rate × quantity). + * A bandless bulk row (NULL minKm) is the legacy pre-band shape and still + * prices every distance. Every other rate shape has its band fields cleared, + * mirroring how yard scope is cleared for non-route rates. + */ + private resolveLastMileBand(input: { + appliesTo: Rate['appliesTo']; + rateUnit: Rate['rateUnit']; + containerTypeId: string | null; + minKm?: number | null; + maxKm?: number | null; + }): { minKm: number | null; maxKm: number | null } { + const { appliesTo, rateUnit, containerTypeId } = input; + if (appliesTo !== 'LAST_MILE') return { minKm: null, maxKm: null }; + + if (rateUnit === 'PER_TON_KM') { + if (containerTypeId) { + throw new BadRequestException( + 'A bulk last-mile rate (per ton per km) cannot be scoped to a container type.', + ); + } + const minKm = input.minKm ?? null; + const maxKm = input.maxKm ?? null; + if (minKm === null) { + if (maxKm !== null) { + throw new BadRequestException( + '"To km" needs a "From km" — set the band start (0 for the first tier).', + ); + } + // Legacy bandless bulk rate — prices every distance. + return { minKm: null, maxKm: null }; + } + if (maxKm !== null && maxKm <= minKm) { + throw new BadRequestException('"To km" must be greater than "From km".'); + } + return { minKm, maxKm }; + } + + if (rateUnit === 'PER_KM') { + if (!containerTypeId) { + throw new BadRequestException( + 'A container last-mile rate must name the container type it covers (20ft and 40ft price differently).', + ); + } + const minKm = input.minKm ?? null; + const maxKm = input.maxKm ?? null; + if (minKm === null) { + throw new BadRequestException( + 'A container last-mile rate needs a distance band — set "From km" (0 for the first band).', + ); + } + if (maxKm !== null && maxKm <= minKm) { + throw new BadRequestException('"To km" must be greater than "From km".'); + } + return { minKm, maxKm }; + } + + // Legacy last-mile shapes (FLAT / PER_CONTAINER / PER_TON) carry no band. + return { minKm: null, maxKm: null }; + } + + /** + * Reject a last-mile band that overlaps an existing band for the same scope — + * container bands collide per container type (PER_KM), bulk bands collide + * with each other (PER_TON_KM, no container scope). Bands are half-open + * [minKm, maxKm) with NULL maxKm = open-ended, so 0–30 and 30–∞ tile + * cleanly. Checked across every non-superseded row (DRAFT included) — two + * drafts with colliding bands would only defer the conflict to approval. + */ + private async assertNoBandOverlap(input: { + rateUnit: 'PER_KM' | 'PER_TON_KM'; + containerTypeId: string | null; + minKm: number; + maxKm: number | null; + ignoreId?: string; + }): Promise { + const siblings = await this.repository.findAll({ + where: { + rateType: 'LAST_MILE', + rateUnit: input.rateUnit, + containerTypeId: input.containerTypeId ?? IsNull(), + status: Not('SUPERSEDED'), + }, + }); + const newMax = input.maxKm ?? Number.POSITIVE_INFINITY; + for (const sibling of siblings) { + if (sibling.id === input.ignoreId) continue; + if (sibling.minKm === null || sibling.minKm === undefined) continue; // legacy row, no band + const sibMin = Number(sibling.minKm); + const sibMax = + sibling.maxKm === null || sibling.maxKm === undefined + ? Number.POSITIVE_INFINITY + : Number(sibling.maxKm); + if (input.minKm < sibMax && sibMin < newMax) { + const sibLabel = `${sibMin}–${sibMax === Number.POSITIVE_INFINITY ? 'open' : sibMax} km`; + throw new ConflictException( + `This distance band overlaps the existing ${sibLabel} band for ${input.rateUnit === 'PER_TON_KM' ? 'bulk last-mile' : 'this container type'}. Adjust the ranges so each distance falls in exactly one band.`, + ); + } + } + } + /** * Reject a second rate with the same identity pattern (rateType + scope). With * effective-date windows gone, two LIVE/DRAFT rates for the same pattern would @@ -360,6 +468,8 @@ export class RatesService { tradeDirection: string | null; originYardId: string | null; destinationYardId: string | null; + /** Band start — part of the identity for container last-mile bands only. */ + minKm?: number | null; ignoreId?: string; }): Promise { const existing = await this.repository.findByPattern(pattern); @@ -438,6 +548,21 @@ export class RatesService { cargoTypeId, ); + const { minKm, maxKm } = this.resolveLastMileBand({ + appliesTo, + rateUnit, + containerTypeId, + minKm: dto.minKm, + maxKm: dto.maxKm, + }); + if ( + appliesTo === 'LAST_MILE' && + (rateUnit === 'PER_KM' || rateUnit === 'PER_TON_KM') && + minKm !== null + ) { + await this.assertNoBandOverlap({ rateUnit, containerTypeId, minKm, maxKm }); + } + await this.assertNoDuplicatePattern({ rateType, ...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }), @@ -446,6 +571,7 @@ export class RatesService { tradeDirection, originYardId, destinationYardId, + minKm, }); return this.repository.create({ @@ -457,9 +583,13 @@ export class RatesService { tradeDirection, originYardId, destinationYardId, - currency: dto.currency ?? 'USD', + // Last-mile is the one shape sold in birr (or USD); everything else is + // USD by contract. + currency: appliesTo === 'LAST_MILE' ? (dto.currency ?? 'ETB') : 'USD', rateValue: dto.rateValue, rateUnit, + minKm, + maxKm, status: 'DRAFT', proposedByStaffId, }); @@ -622,6 +752,29 @@ export class RatesService { ); updates.rateUnit = rateUnit; + const { minKm, maxKm } = this.resolveLastMileBand({ + appliesTo, + rateUnit, + containerTypeId: updates.containerTypeId, + minKm: dto.minKm !== undefined ? dto.minKm : existing.minKm, + maxKm: dto.maxKm !== undefined ? dto.maxKm : existing.maxKm, + }); + updates.minKm = minKm; + updates.maxKm = maxKm; + if ( + appliesTo === 'LAST_MILE' && + (rateUnit === 'PER_KM' || rateUnit === 'PER_TON_KM') && + minKm !== null + ) { + await this.assertNoBandOverlap({ + rateUnit, + containerTypeId: updates.containerTypeId ?? null, + minKm, + maxKm, + ignoreId: id, + }); + } + // Guard the pattern uniqueness for the new identity, ignoring this row. await this.assertNoDuplicatePattern({ rateType, @@ -631,10 +784,14 @@ export class RatesService { tradeDirection: updates.tradeDirection, originYardId: updates.originYardId, destinationYardId: updates.destinationYardId, + minKm, ignoreId: id, }); - updates.currency = dto.currency ?? existing.currency ?? 'USD'; + updates.currency = + appliesTo === 'LAST_MILE' + ? (dto.currency ?? existing.currency ?? 'ETB') + : 'USD'; if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue; return updates; } diff --git a/apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts b/apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts index 593abb305..4c226f8ab 100644 --- a/apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts +++ b/apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts @@ -35,13 +35,24 @@ async function main() { // Demo seeders are intentionally not AppModule providers (they'd run on every // boot), so construct them against the app's DataSource instead of via DI. const dataSource = app.get(DataSource); - await new PricingDataSeeder(dataSource).run(); - await new IndodeFacilitySeeder(dataSource).run(); - await new Batch14TestDataSeeder(dataSource).run(); - await new Batch5TestDataSeeder(dataSource).run(); - await new Batch7TestDataSeeder(dataSource).run(); - await new Batch8TestDataSeeder(dataSource).run(); - await new WarehouseDemoSeeder(dataSource).run(); + + // Each bucket is independent: a seeder that has drifted from the current + // schema shouldn't stop the rest of the demo data from landing. + const step = async (name: string, run: () => Promise) => { + try { + await run(); + } catch (error) { + console.warn(` ! ${name} skipped: ${error instanceof Error ? error.message : String(error)}`); + } + }; + + await step('PricingDataSeeder', () => new PricingDataSeeder(dataSource).run()); + await step('IndodeFacilitySeeder', () => new IndodeFacilitySeeder(dataSource).run()); + await step('Batch14TestDataSeeder', () => new Batch14TestDataSeeder(dataSource).run()); + await step('Batch5TestDataSeeder', () => new Batch5TestDataSeeder(dataSource).run()); + await step('Batch7TestDataSeeder', () => new Batch7TestDataSeeder(dataSource).run()); + await step('Batch8TestDataSeeder', () => new Batch8TestDataSeeder(dataSource).run()); + await step('WarehouseDemoSeeder', () => new WarehouseDemoSeeder(dataSource).run()); console.log('Warehouse demo data seeded.'); } finally { diff --git a/apps/edr-freight-api/src/seed/edr-freight.seed.ts b/apps/edr-freight-api/src/seed/edr-freight.seed.ts index 7ebcfb89c..22f7fe5ef 100644 --- a/apps/edr-freight-api/src/seed/edr-freight.seed.ts +++ b/apps/edr-freight-api/src/seed/edr-freight.seed.ts @@ -306,4 +306,5 @@ export const EDR_FREIGHT_POSITIONS: FreightSeedPosition[] = [ { key: "operation", name: { en: "Operation" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.operation] }, { key: "operations_chief", name: { en: "Operations Chief" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.operationsChief] }, { key: "dispatcher", name: { en: "Dispatcher" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.dispatcher] }, + { key: "truck_machinery_chief", name: { en: "Truck & Machinery Chief" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.truckMachineryChief] }, ]; diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 4f3b171bb..889345733 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -227,6 +227,9 @@ export const MILE_PERMISSIONS: FreightPermissionSeed[] = [ perm('d3b00001-0001-4000-8000-000000000006', 'edr_freight_app:last_mile:assign_vehicles', 'Assign last-mile vehicles'), perm('d3b00001-0001-4000-8000-000000000007', 'edr_freight_app:last_mile:set_distances', 'Set last-mile distances'), perm('d3b00001-0001-4000-8000-000000000008', 'edr_freight_app:last_mile:generate_invoice', 'Generate last-mile invoice'), + perm('d3b00001-0001-4000-8000-000000000009', 'edr_freight_app:last_mile:request_view', 'View last-mile confirmation requests'), + perm('d3b00001-0001-4000-8000-00000000000a', 'edr_freight_app:last_mile:request_review', 'Review last-mile confirmation requests (T&M dept)'), + perm('d3b00001-0001-4000-8000-00000000000b', 'edr_freight_app:last_mile:request_approve', 'Approve/reject last-mile confirmation requests'), ]; // F. Fleet — rail assets (splits the flat fleet:view/manage) @@ -366,6 +369,7 @@ export const CONFIG_SETTINGS_PERMISSIONS: FreightPermissionSeed[] = [ perm('b4a00001-0001-4000-8000-000000000002', 'edr_freight_app:settings:file_upload:manage', 'Manage file-upload settings'), perm('b4b00001-0001-4000-8000-000000000001', 'edr_freight_app:settings:dropdown:view', 'View dropdown settings'), perm('b4b00001-0001-4000-8000-000000000002', 'edr_freight_app:settings:dropdown:manage', 'Manage dropdown settings'), + perm('b4c00001-0001-4000-8000-000000000001', 'edr_freight_app:audit:view', 'View audit logs'), ]; // M. Staff / IAM admin — NEW keys only. The employee_registration / role_assignment @@ -527,6 +531,11 @@ export const FREIGHT_PERMS = { assignVehicles: 'edr_freight_app:last_mile:assign_vehicles', setDistances: 'edr_freight_app:last_mile:set_distances', generateInvoice: 'edr_freight_app:last_mile:generate_invoice', + // Pre-approval confirmation stage (Truck & Machinery department): view/review + // a submitted request, approve/reject it. + requestView: 'edr_freight_app:last_mile:request_view', + requestReview: 'edr_freight_app:last_mile:request_review', + requestApprove: 'edr_freight_app:last_mile:request_approve', }, locomotives: { view: 'edr_freight_app:locomotives:view', @@ -700,6 +709,9 @@ export const FREIGHT_PERMS = { manage: 'edr_freight_app:settings:dropdown:manage', }, }, + audit: { + view: 'edr_freight_app:audit:view', + }, staff: { roles: { view: 'edr_freight_app:staff:roles:view', @@ -1011,6 +1023,17 @@ export const POSITION_PERMISSION_PRESETS = { FREIGHT_PERMS.trainScheduling.view, FREIGHT_PERMS.bookings.operations, ]), + // Truck & Machinery chief: reviews and approves/rejects last-mile + // confirmation requests (the pre-approval gate ahead of vehicle assignment), + // plus enough fleet visibility to judge truck availability. + truckMachineryChief: dedupe([ + FREIGHT_PERMS.lastMile.view, + FREIGHT_PERMS.lastMile.requestView, + FREIGHT_PERMS.lastMile.requestReview, + FREIGHT_PERMS.lastMile.requestApprove, + FREIGHT_PERMS.fleetDashboard.view, + FREIGHT_PERMS.vehicles.view, + ]), } as const; /** Derive the module bucket from the resource segment of a permission key. */ diff --git a/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts b/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts index 6de3bc4de..1b5d776e9 100644 --- a/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts +++ b/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts @@ -29,6 +29,7 @@ const STAFF_USERS = [ { email: 'operation@edr.local', username: 'operation', roleKey: 'edr_operations_officer', positionKey: 'operation' }, { email: 'gl-et@edr.local', username: 'gl_et', roleKey: 'edr_gl_ethiopia', positionKey: 'ethiopian_gl' }, { email: 'gl-dj@edr.local', username: 'gl_dj', roleKey: 'edr_gl_djibouti', positionKey: 'djibouti_gl' }, + { email: 'tm-chief@edr.local', username: 'tm_chief', roleKey: 'edr_operations_officer', positionKey: 'truck_machinery_chief' }, ] as const; @Injectable() diff --git a/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts index f60d7bb75..654f4f77f 100644 --- a/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts +++ b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts @@ -4,6 +4,11 @@ import { DataSource } from 'typeorm'; import { BookingContainer } from '../modules/bookings/entities/booking-container.entity'; import { Booking } from '../modules/bookings/entities/booking.entity'; +import { + CompanyProfile, + ProfileStatus, + ProfileType, +} from '../modules/companies/entities/company-profile.entity'; import { Company, CompanyStatus, CompanyType } from '../modules/companies/entities/company.entity'; import { FirstMile } from '../modules/first-mile/entities/first-mile.entity'; import { LastMile } from '../modules/last-mile/entities/last-mile.entity'; @@ -13,7 +18,8 @@ import { ServiceType } from '../modules/rule-engine/entities/service-type.entity import { Yard } from '../modules/rule-engine/entities/yard.entity'; const SERVICE_TYPE_CODE = 'RAIL_CONTAINER_PAID_MILE'; -const COMPANY_TIN = 'PAIDMILE001'; +// companies.tin is varchar(10) — an 11-char TIN 22001s the whole seeder. +const COMPANY_TIN = 'PAIDMILE01'; const COMPANY_EMAIL = 'paid-mile-demo@edr.local'; const YARDS = [ @@ -183,6 +189,22 @@ export class PaidImportExportMileDemoSeeder { manager.getRepository(ContainerType).find(), ]); + // bookings.company_profile_id is NOT NULL — the demo company needs an + // approved importer profile of its own (no unique key to upsert on). + const profileRepo = manager.getRepository(CompanyProfile); + const companyProfile = + (await profileRepo.findOne({ + where: { companyId: company.id, type: ProfileType.importer }, + })) ?? + (await profileRepo.save( + profileRepo.create({ + companyId: company.id, + type: ProfileType.importer, + status: ProfileStatus.Active, + businessLicense: 'PMD-LIC-0001', + }), + )); + const yardByCode = new Map(yards.map((yard) => [yard.code, yard])); const containerTypeByCode = new Map( containerTypes.map((containerType) => [containerType.code, containerType]), @@ -206,6 +228,7 @@ export class PaidImportExportMileDemoSeeder { { reference: demoBooking.reference, companyId: company.id, + companyProfileId: companyProfile.id, status: 'APPROVED', scheduledDate: new Date(demoBooking.scheduledDate), estimatedShipmentDate: new Date(demoBooking.scheduledDate), diff --git a/apps/edr-freight-web/backoffice/.env.example b/apps/edr-freight-web/backoffice/.env.example index c840d18a1..454817139 100644 --- a/apps/edr-freight-web/backoffice/.env.example +++ b/apps/edr-freight-web/backoffice/.env.example @@ -1,3 +1,6 @@ +# Dev server port. Default: 5283. +PORT=5283 + VITE_API_URL=http://localhost:3001 VITE_BASE_API_URL=http://localhost:3001 diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index 675eced1a..68fdb2a8f 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "vite --port 5183 --clearScreen false", + "dev": "vite --clearScreen false", "prebuild": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true});\"", "build": "vite build", "preview": "vite preview --port 5183", diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 6e0b5759f..bda88d912 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -7,6 +7,7 @@ import { FileSignature, FileText, Hammer, + History, LayoutDashboard, LayoutGrid, MapPin, @@ -76,6 +77,7 @@ import ReportsHubPage from "./pages/reports/ReportsHubPage"; import ReportPage from "./pages/reports/ReportPage"; import AiBookingMockTestPage from "./pages/ai/AiBookingMockTestPage"; import PaymentsPage from "./pages/payments/PaymentsPage"; +import AuditLogsPage from "./pages/audit/AuditLogsPage"; //import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage"; import { RequirePermission } from "./components/auth/RequirePermission"; import { @@ -582,6 +584,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.admin, }, + { + label: "Audit logs", + href: "/dashboard/audit-logs", + icon: , + permission: FREIGHT_PERMS.audit.view, + }, { label: "Configuration", href: "/dashboard/configuration", @@ -639,6 +647,8 @@ const DJ_CLEARANCE_HREF = "/dashboard/gl-djibouti/clearance"; const GL_WORKFLOW_PATH_PATTERNS: RegExp[] = [ /^\/dashboard\/contracts\/[^/]+\/create-booking(\/|$)/, /^\/dashboard\/bookings\/[^/]+\/clearance(\/|$)/, + // The ET hub's rows open the shipment clearance detail at this URL. + /^\/dashboard\/clearance\/[^/]+(\/|$)/, ]; const isEtClearanceItem = (item: SidebarItem): boolean => @@ -1595,6 +1605,14 @@ const App = () => { } /> + + + + } + /> = [ subtitle: "Manage dropdown options used across the platform", }, }, + { + prefix: "/dashboard/audit-logs", + meta: { + title: "Audit Logs", + subtitle: "Request and entity-level activity recorded across the freight API", + }, + }, { prefix: "/dashboard/configuration/contract-validity-periods", meta: { diff --git a/apps/edr-freight-web/backoffice/src/components/operations/LastMileRequestsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/operations/LastMileRequestsPanel.tsx new file mode 100644 index 000000000..eabdcbc20 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/operations/LastMileRequestsPanel.tsx @@ -0,0 +1,369 @@ +import { useEffect, useState } from "react"; +import { + Badge, + Box, + Button, + Card, + Group, + Modal, + NumberInput, + Stack, + Text, + Textarea, +} from "@mantine/core"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { ColumnDef } from "@edr/ui-common"; +import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; + +import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; +import { useToast } from "@/hooks/use-toast"; +import { useAuth } from "@/auth/useAuth"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; +import { + lastMileRequestsService, + type LastMileRequest, + type LastMileRequestStatus, +} from "@/services/last-mile-requests.service"; + +const STATUS_META: Record = { + AWAITING_CONFIRMATION: { label: "Awaiting Confirmation", color: "gray" }, + SUBMITTED: { label: "Submitted", color: "yellow" }, + APPROVED: { label: "Approved", color: "green" }, + REJECTED: { label: "Rejected", color: "red" }, +}; + +type StatusFilter = "ALL" | LastMileRequestStatus; + +const FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [ + { value: "SUBMITTED", label: "Submitted" }, + { value: "APPROVED", label: "Approved" }, + { value: "REJECTED", label: "Rejected" }, + { value: "AWAITING_CONFIRMATION", label: "Awaiting Confirmation" }, + { value: "ALL", label: "All" }, +]; + +const fmtDate = (iso?: string | null) => + iso ? new Date(iso).toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" }) : "—"; + +export function LastMileRequestsPanel() { + const { toast } = useToast(); + const qc = useQueryClient(); + const { user } = useAuth(); + const canApprove = hasPermission(user, FREIGHT_PERMS.lastMile.requestApprove); + + const [statusFilter, setStatusFilter] = useState("SUBMITTED"); + const { pagination, setPagination } = usePagination({ pageSize: 10 }); + const [approveTarget, setApproveTarget] = useState(null); + const [rejectTarget, setRejectTarget] = useState(null); + const [advanceAmount, setAdvanceAmount] = useState(""); + const [rejectReason, setRejectReason] = useState(""); + + const filter = { + ...(statusFilter !== "ALL" ? { status: statusFilter } : {}), + page: pagination.pageIndex + 1, + pageSize: pagination.pageSize, + }; + + const { data, isLoading } = useQuery({ + queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.list(filter), + queryFn: async () => (await lastMileRequestsService.list(filter)).data, + }); + const rows = data?.data ?? []; + const meta = data?.meta; + + const { data: freeTrucks } = useQuery({ + queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.freeTruckCount, + queryFn: async () => (await lastMileRequestsService.freeTruckCount()).data, + }); + + // Rule-based estimate for the approve dialog (estimated km × live last-mile + // rates). Prefills the advance once, without clobbering a typed value. + const { data: estimate } = useQuery({ + queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.priceEstimate(approveTarget?.id ?? ""), + queryFn: async () => + (await lastMileRequestsService.priceEstimate(approveTarget!.id)).data, + enabled: Boolean(approveTarget), + }); + useEffect(() => { + if (approveTarget && estimate?.total != null && advanceAmount === "") { + setAdvanceAmount(estimate.total); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [estimate, approveTarget]); + + const invalidate = () => + qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.ROOT }); + + const downloadContract = async (r: LastMileRequest) => { + try { + const { data: blob } = await lastMileRequestsService.contractDocument(r.id); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `LM_${r.booking?.company?.name?.replace(/[^A-Za-z0-9._-]+/g, "_") ?? r.id}.pdf`; + a.click(); + URL.revokeObjectURL(url); + } catch { + toast({ title: "Contract PDF not available", variant: "destructive" }); + } + }; + + const approve = useMutation({ + mutationFn: () => + lastMileRequestsService.approve(approveTarget!.id, Number(advanceAmount)), + onSuccess: () => { + void invalidate(); + toast({ title: "Request approved" }); + setApproveTarget(null); + setAdvanceAmount(""); + }, + onError: (e: unknown) => { + const description = (e as { response?: { data?: { message?: string } } })?.response?.data?.message; + toast({ title: "Approve failed", description, variant: "destructive" }); + }, + }); + + const reject = useMutation({ + mutationFn: () => lastMileRequestsService.reject(rejectTarget!.id, rejectReason.trim()), + onSuccess: () => { + void invalidate(); + toast({ title: "Request rejected" }); + setRejectTarget(null); + setRejectReason(""); + }, + onError: (e: unknown) => { + const description = (e as { response?: { data?: { message?: string } } })?.response?.data?.message; + toast({ title: "Reject failed", description, variant: "destructive" }); + }, + }); + + const columns: ColumnDef[] = [ + { + id: "booking", + header: () => Booking, + cell: ({ row }) => { + const r = row.original; + return ( + + {r.booking?.reference ?? r.bookingId} + {r.booking?.company?.name ?? "—"} + + ); + }, + }, + { + id: "containers", + header: () => Requested Containers, + cell: ({ row }) => { + const r = row.original; + const nums = r.requestedContainerNumbers; + return ( + + {nums?.length ? nums.join(", ") : "—"} + {r.requestedDeliveryDate && ( + Delivery: {r.requestedDeliveryDate} + )} + + ); + }, + }, + { + id: "submittedAt", + header: () => Submitted, + cell: ({ row }) => {fmtDate(row.original.submittedAt)}, + }, + { + id: "status", + header: () => Status, + cell: ({ row }) => { + const meta = STATUS_META[row.original.status]; + return ( + + {meta.label} + + ); + }, + }, + { + id: "contract", + header: () => LM Contract, + cell: ({ row }) => { + const r = row.original; + if (r.status !== "APPROVED") return ; + return ( + + + {r.customerSignedAt ? "Signed" : "Awaiting signature"} + + + + ); + }, + }, + ...(canApprove + ? [ + { + id: "actions", + header: () => Actions, + cell: ({ row }: { row: { original: LastMileRequest } }) => { + const r = row.original; + if (r.status !== "SUBMITTED") return null; + return ( + + + + + ); + }, + } as ColumnDef, + ] + : []), + ]; + + return ( + + + + + + {freeTrucks?.count ?? 0} truck{freeTrucks?.count === 1 ? "" : "s"} currently free + + + {FILTER_OPTIONS.map((option) => { + const active = statusFilter === option.value; + return ( + + ); + })} + + + + + ( + + )} + /> + + + { + setApproveTarget(null); + setAdvanceAmount(""); + }} + title={Approve request{approveTarget?.booking?.reference ? ` · ${approveTarget.booking.reference}` : ""}} + centered + > + + {estimate?.total != null && ( + + {estimate.lines.map((line) => ( + + {line.description} — {line.amount.toLocaleString()} + + ))} + + Estimated total: {estimate.total.toLocaleString()} {estimate.currency} + {estimate.estimatedKm != null + ? ` · ${estimate.estimatedKm} km (straight-line estimate)` + : ""} + + + )} + + + + + + + + + setRejectTarget(null)} + title={Reject request{rejectTarget?.booking?.reference ? ` · ${rejectTarget.booking.reference}` : ""}} + centered + > + +