diff --git a/CLAUDE.md b/CLAUDE.md index d67e6c3d5..b90d3b1dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,6 +24,7 @@ Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Fre | ---------------------- | ---------------------------------------------------------------------------------- | | `@edr/types` | Shared TypeScript interfaces and enums | | `@edr/api-common` | Shared NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository | +| `@edr/iam-seed` | IAM baseline seeder for the apps sharing the `iam` schema (freight + passenger) | | `@edr/ui-common` | Shared React components and theme | | `@edr/eslint-config` | Shared ESLint configurations (base/nestjs/react) | | `@edr/tsconfig` | Shared TypeScript configurations | diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index d80ce75c6..d6a39bfed 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -42,8 +42,17 @@ JWT_REFRESH_TOKEN_EXPIRES=7d # IAM seed defaults (used by @tria-plc/iamapi-common on first boot) SUPER_ADMIN_EMAIL=superadmin@tria.com SUPER_ADMIN_PHONE= +# Super-admin password. Falls back to DEFAULT_PASSWORD when empty. +SUPER_ADMIN_DEFAULT_PASSWORD= DEFAULT_PASSWORD=password@tria +# IAM baseline shared with edr-passenger-api (roles, IAM app + permissions, +# position types, organization types + default units, org/unit settings, super +# admin). Replaces the seeder that shipped inside @tria-plc/iamapi-common — see +# packages/iam-seed. Seeds by DEFAULT when unset; every write is insert-only. +# Set to false to opt out. +SEED_IAM_BASELINE=true + # Freight org + staff (bookings / rule-engine IAM) SEED_EDR_ORG=true SEED_FREIGHT_STAFF=true @@ -84,6 +93,9 @@ FAYDA_PRIVATE_KEY_BASE64= FAYDA_REDIRECT_URI=http://localhost:3001/api/fayda/verification/complete # OAuth redirect_uri for WEB clients. Defaults to FAYDA_REDIRECT_URI when unset. FAYDA_WEB_REDIRECT_URI=http://localhost:3000/callback +# OAuth redirect_uri for the customer portal (its own origin — must also be +# registered with eSignet). Defaults to FAYDA_WEB_REDIRECT_URI when unset. +FAYDA_PORTAL_REDIRECT_URI=http://localhost:5173/callback CLIENT_ASSERTION_TYPE=urn:ietf:params:oauth:client-assertion-type:jwt-bearer FAYDA_SCOPE=openid profile email phone address FAYDA_ACR_VALUES=mosip:idp:acr:generated-code diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 66909a037..0531d056b 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -35,12 +35,12 @@ "iam:migration:run": "pnpm run iam:typeorm:cli migration:run", "iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert", "iam:migration:show": "pnpm run iam:typeorm:cli migration:show", - "iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js", "migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts", "script": "ts-node -r tsconfig-paths/register src/scripts/main.ts" }, "dependencies": { "@edr/api-common": "workspace:*", + "@edr/iam-seed": "workspace:*", "@edr/payment-providers": "workspace:*", "@edr/types": "workspace:*", "@golevelup/nestjs-rabbitmq": "^5.5.0", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 3e3e29980..a3c09df97 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -2,6 +2,7 @@ import { MiddlewareConsumer, Module, OnApplicationBootstrap, + RequestMethod, } from "@nestjs/common"; import { ConfigModule, ConfigService } from "@nestjs/config"; import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm"; @@ -12,6 +13,7 @@ import { ensurePostgresSchemas, APPLICATION_SEARCH_PATH, } from "./config/ensure-postgres-schemas"; +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"; @@ -101,6 +103,7 @@ import { InterchangeDocumentsModule } from "./modules/interchange-documents/inte import { ImportOperationsModule } from "./modules/import-operations/import-operations.module"; import { AiModule } from "./modules/ai/ai.module"; import { LoggerMiddleware } from "./logger.middleware"; +import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware"; @Module({ imports: [ @@ -154,6 +157,18 @@ import { LoggerMiddleware } from "./logger.middleware"; applications: [EDR_FREIGHT_APPLICATION], permissions: EDR_FREIGHT_PERMISSIONS, }), + // Replaces the package's DataSeeder. Shared with edr-passenger-api, which + // seeds the same `iam` schema — see packages/iam-seed. + IamSeedModule.forRoot({ + superAdmin: { + username: "superadmin", + name: { am: "ሱፐር አድሚን", en: "Super Admin" }, + roleKey: "super_admin", + organizationKey: "edr_freight", + unitKey: "edr_freight_app", + fallbackEmail: "superadmin@tria.com", + }, + }), BookingsModule, ContractsModule, SignaturesModule, @@ -229,11 +244,12 @@ import { LoggerMiddleware } from "./logger.middleware"; // MarshallingDemoTrainsSeeder, ApprovedFirstLastMileDemoBookingsSeeder, PaidImportExportMileDemoSeeder, + LoginAudienceMiddleware, ], }) export class AppModule implements OnApplicationBootstrap { constructor( - // private readonly seeder: DataSeeder, + private readonly iamBaselineSeeder: IamBaselineSeeder, private readonly edrOrgSeeder: EdrOrgSeeder, private readonly freightPositionsSeeder: FreightPositionsSeeder, private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder, @@ -263,13 +279,22 @@ export class AppModule implements OnApplicationBootstrap { // Permissions foundation — keep enabled: // freightPermissionKeyMigration → renames legacy permission keys - // seeder (IAM DataSeeder) → seeds the IAM app, roles, permissions // edrOrgSeeder → seeds org/unit + the Permission catalog + // iamBaselineSeeder → @edr/iam-seed: IAM app, roles, permissions, + // position types, organization types + + // default units, org/unit settings and the + // super-admin account. Replaces the package's + // DataSeeder, and is shared with + // edr-passenger-api so one writer owns the + // `iam` schema. Runs after edrOrgSeeder + // because the super admin attaches to the + // edr_freight org/unit. + // Writes nothing unless SEED_IAM_BASELINE=true. // freightPositionsSeeder → seeds Position + PositionPermission rows // (depends on edrOrgSeeder, must run after) await this.freightPermissionKeyMigrationSeeder.run(); - // await this.seeder.run(); await this.edrOrgSeeder.run(); + await this.iamBaselineSeeder.run(); await this.freightPositionsSeeder.run(); // File upload settings — keep enabled. @@ -308,5 +333,9 @@ export class AppModule implements OnApplicationBootstrap { configure(consumer: MiddlewareConsumer) { consumer.apply(LoggerMiddleware).forRoutes("*"); + consumer.apply(LoginAudienceMiddleware).forRoutes( + { path: "auth/login", method: RequestMethod.POST }, + { path: "auth/mfa-verify", method: RequestMethod.POST }, + ); } } diff --git a/apps/edr-freight-api/src/common/grn.util.spec.ts b/apps/edr-freight-api/src/common/grn.util.spec.ts new file mode 100644 index 000000000..d95c934ca --- /dev/null +++ b/apps/edr-freight-api/src/common/grn.util.spec.ts @@ -0,0 +1,40 @@ +import { generateGrnNumber, grnOwnerSlug } from './grn.util'; + +/** + * The GRN is mapped to the goods owner for BOTH directions, so a note is + * identifiable by who owns the cargo. The reference slice stays the uniqueness + * anchor — one owner can have several bookings received the same day. + */ +const date = new Date('2026-07-27T09:15:00Z'); +const bookingId = '1a2b3c4d-1111-2222-3333-444455556666'; + +describe('GRN number', () => { + it('maps an import GRN to the owner', () => { + expect(generateGrnNumber('IMPORT', bookingId, date, 'Shafici Pharmaceutical')).toBe( + 'GRN-IMPORT-20260727-SHAFICIPHARM-1A2B3C4D', + ); + }); + + it('maps an export GRN to the owner the same way', () => { + expect(generateGrnNumber('EXPORT', bookingId, date, 'Tria Trading PLC')).toBe( + 'GRN-EXPORT-20260727-TRIATRADINGP-1A2B3C4D', + ); + }); + + it('keeps the owner-less format when there is no owner (manual walk-in)', () => { + expect(generateGrnNumber('WH', bookingId, date)).toBe('GRN-WH-20260727-1A2B3C4D'); + expect(generateGrnNumber('WH', bookingId, date, ' ')).toBe('GRN-WH-20260727-1A2B3C4D'); + }); + + it('stays unique per booking for one owner on one day', () => { + const a = generateGrnNumber('IMPORT', bookingId, date, 'Acme'); + const b = generateGrnNumber('IMPORT', 'ffffffff-9999-0000-0000-000000000000', date, 'Acme'); + expect(a).not.toBe(b); + }); + + it('strips punctuation and caps the owner segment', () => { + expect(grnOwnerSlug('Ethio-Djibouti Railway S.C.')).toBe('ETHIODJIBOUT'); + expect(grnOwnerSlug('a/b c')).toBe('ABC'); + expect(grnOwnerSlug(null)).toBeNull(); + }); +}); diff --git a/apps/edr-freight-api/src/common/grn.util.ts b/apps/edr-freight-api/src/common/grn.util.ts index 5cae30302..128e0496a 100644 --- a/apps/edr-freight-api/src/common/grn.util.ts +++ b/apps/edr-freight-api/src/common/grn.util.ts @@ -1,13 +1,41 @@ /** - * Goods Received Note number: `GRN---`. + * Goods Received Note number: `GRN----`. + * + * The GRN is mapped to the goods OWNER (the booking's customer / consignee) for + * both import and export, so a note is identifiable by who owns the cargo + * without opening it. The trailing reference slice stays as the uniqueness + * anchor — one owner can have several bookings received on the same day. + * Owner-less receipts (manual walk-ins with no booking) fall back to the + * original `GRN---` form. * * Shared so a GRN raised at a load/unload facility is indistinguishable from one * raised in a warehouse — the two live in different tables * (facility_handling_events vs warehouse_inventory), and a second generator would * eventually let their formats drift apart. */ -export function generateGrnNumber(direction: string, referenceId: string, date: Date): string { +export function generateGrnNumber( + direction: string, + referenceId: string, + date: Date, + ownerName?: string | null, +): string { const stamp = date.toISOString().slice(0, 10).replace(/-/g, ''); const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase(); - return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`; + const owner = grnOwnerSlug(ownerName); + const base = `GRN-${direction.toUpperCase()}-${stamp}`; + return owner ? `${base}-${owner}-${suffix}` : `${base}-${suffix}`; +} + +/** + * Owner name → GRN-safe token: letters/digits only, upper-cased, capped so a + * long company name can't run away with the number. Null when there is nothing + * usable, which drops the segment rather than emitting an empty `--`. + */ +export function grnOwnerSlug(ownerName?: string | null): string | null { + const slug = (ownerName ?? '') + .normalize('NFKD') + .replace(/[^a-zA-Z0-9]+/g, '') + .toUpperCase() + .slice(0, 12); + return slug || null; } diff --git a/apps/edr-freight-api/src/config/fayda.config.ts b/apps/edr-freight-api/src/config/fayda.config.ts index a25289159..30525dd01 100644 --- a/apps/edr-freight-api/src/config/fayda.config.ts +++ b/apps/edr-freight-api/src/config/fayda.config.ts @@ -15,7 +15,7 @@ export interface FaydaJwk { qi?: string; } -export type FaydaPlatform = 'WEB' | 'MOBILE'; +export type FaydaPlatform = 'WEB' | 'MOBILE' | 'PORTAL'; export interface FaydaConfig { enabled: boolean; @@ -25,8 +25,10 @@ export interface FaydaConfig { userInfoEndpoint: string; /** OAuth redirect_uri sent to eSignet for MOBILE clients. */ redirectUri: string; - /** OAuth redirect_uri sent to eSignet for WEB clients. Falls back to `redirectUri`. */ + /** OAuth redirect_uri sent to eSignet for WEB (backoffice) clients. Falls back to `redirectUri`. */ webRedirectUri: string; + /** OAuth redirect_uri sent to eSignet for the customer portal. Falls back to `webRedirectUri`. */ + portalRedirectUri: string; privateJwk: FaydaJwk; scope: string; acrValues: string; @@ -77,6 +79,7 @@ export default registerAs('fayda', (): FaydaConfig => { const sessionTtl = Number.parseInt(process.env.FAYDA_SESSION_TTL_MINUTES ?? '10', 10); const redirectUri = process.env.FAYDA_REDIRECT_URI ?? ''; const webRedirectUri = process.env.FAYDA_WEB_REDIRECT_URI || redirectUri; + const portalRedirectUri = process.env.FAYDA_PORTAL_REDIRECT_URI || webRedirectUri; if (!enabled) { return { enabled: false, @@ -86,6 +89,7 @@ export default registerAs('fayda', (): FaydaConfig => { userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT ?? '', redirectUri, webRedirectUri, + portalRedirectUri, privateJwk: { kty: 'RSA', n: '', e: '', d: '' }, scope, acrValues, @@ -117,6 +121,7 @@ export default registerAs('fayda', (): FaydaConfig => { userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT!, redirectUri, webRedirectUri, + portalRedirectUri, privateJwk: decodePrivateJwk(process.env.FAYDA_PRIVATE_KEY_BASE64!), scope, acrValues, diff --git a/apps/edr-freight-api/src/main.ts b/apps/edr-freight-api/src/main.ts index 9efc7fa21..cf4c37b2d 100644 --- a/apps/edr-freight-api/src/main.ts +++ b/apps/edr-freight-api/src/main.ts @@ -46,6 +46,9 @@ async function bootstrap() { "Accept", "Authorization", "X-Requested-With", + // Which freight frontend is calling — /auth/login uses this to reject + // cross-audience credentials (EDRFREIGHT-415). + "X-Client-App", // IAM context headers required by @tria-plc/api-common's JwtGuard "organization-unit-id", "delegator-position-id", @@ -58,6 +61,13 @@ async function bootstrap() { "x-delegator-position-id", "x-current-project-id", "x-current-position-id", + // Headers sent by the freight-backoffice OKR/objective-service client + // (withHeaders.tsx, signatureAndTeeterService.ts, useIncomingReport.ts) + // under yet another naming convention — unprefixed "tenant-key"/"unit-id", + // and "x-delegated-position-id" (delegated, not delegator). + "tenant-key", + "unit-id", + "x-delegated-position-id", ], exposedHeaders: ["Content-Disposition"], maxAge: 86400, // cache preflight for 24h to cut chatter in dev diff --git a/apps/edr-freight-api/src/migrations/2850000000000-AddBookingDoubleHandling.ts b/apps/edr-freight-api/src/migrations/2850000000000-AddBookingDoubleHandling.ts new file mode 100644 index 000000000..be98729e7 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2850000000000-AddBookingDoubleHandling.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Double handling becomes an explicit per-booking decision instead of an + * implicit "every import" charge. Warehouse staff record Yes/No after + * unloading (whether the goods actually had to be re-handled); the + * DOUBLE_HANDLING_FEE rule only bills when the answer is Yes. + * + * NULL = not decided yet → no charge, and the UI shows "not set" so the + * operator is prompted. Existing rows stay NULL deliberately: back-billing a + * fee nobody confirmed would be wrong. + */ +export class AddBookingDoubleHandling2850000000000 implements MigrationInterface { + name = 'AddBookingDoubleHandling2850000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS double_handling boolean;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS double_handling_set_at timestamptz;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS double_handling_set_by varchar(160);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS double_handling_set_by;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS double_handling_set_at;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS double_handling;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2860000000000-AddPerTruckDetentionWindow.ts b/apps/edr-freight-api/src/migrations/2860000000000-AddPerTruckDetentionWindow.ts new file mode 100644 index 000000000..710ad12ad --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2860000000000-AddPerTruckDetentionWindow.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-truck detention clocks. Detention was timed once per last-mile leg + * (last_mile.arrived_at / delivered_at), so every truck on a multi-truck + * delivery shared one window and was billed identical days — wrong the moment + * two trucks arrive or return at different times. + * + * Deliberately NEW columns rather than reusing the existing per-truck + * arrived_at / departed_at on this table: those are WAREHOUSE gate-in/gate-out + * events stamped by release(), whereas detention runs from arrival at the + * DESTINATION until the truck is released/returned. + * + * Both nullable — a truck without its own window falls back to the leg-level + * timestamps, so legacy legs keep billing exactly as before. + */ +export class AddPerTruckDetentionWindow2860000000000 implements MigrationInterface { + name = 'AddPerTruckDetentionWindow2860000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.last_mile_vehicle_assignments + ADD COLUMN IF NOT EXISTS destination_arrived_at timestamptz, + ADD COLUMN IF NOT EXISTS returned_at timestamptz; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.last_mile_vehicle_assignments + DROP COLUMN IF EXISTS returned_at, + DROP COLUMN IF EXISTS destination_arrived_at; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2900000000000-LivestockPerItem.ts b/apps/edr-freight-api/src/migrations/2900000000000-LivestockPerItem.ts new file mode 100644 index 000000000..ce05a7ce1 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2900000000000-LivestockPerItem.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Livestock is billed and counted per head, not per ton — line it up with the + * other break-bulk cargo types (Machinery, Truck, Automobile) so bulk + * storage/demurrage fees charge per item instead of per ton for it. + */ +export class LivestockPerItem2900000000000 implements MigrationInterface { + name = "LivestockPerItem2900000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.cargo_types + SET unit_of_measure = 'PER_ITEM' + WHERE code = 'LIVESTOCK' + AND unit_of_measure IS DISTINCT FROM 'PER_ITEM' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.cargo_types + SET unit_of_measure = 'PER_TON' + WHERE code = 'LIVESTOCK' + AND unit_of_measure IS DISTINCT FROM 'PER_TON' + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2990000000000-IndodeYardsAndCargoRouting.ts b/apps/edr-freight-api/src/migrations/2990000000000-IndodeYardsAndCargoRouting.ts new file mode 100644 index 000000000..d89e0c04b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2990000000000-IndodeYardsAndCargoRouting.ts @@ -0,0 +1,131 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Indode's real 11-yard layout, plus the plumbing to auto-route a booking to + * the right yard by cargo type (and, for container yards, trade direction): + * + * - `warehouse_yards.direction` — IMPORT | EXPORT | BOTH | null. Only + * meaningful for CONTAINER_YARD, where import and export stacks are + * physically separate (Yard 5 vs Yard 6). Everything else takes cargo + * either way. A CONTAINER_YARD left at null/BOTH is a signal too: it means + * "not a customer cargo yard" — Yards 10/11 (service/equipment) are + * CONTAINER_YARD structurally but must never be offered for ordinary + * import/export cargo, so the frontend match requires an EXACT IMPORT/ + * EXPORT direction hit for container freight rather than treating BOTH as + * a wildcard. + * - `warehouse_yard_cargo_types` — which cargo types a yard accepts (mirrors + * the existing `cargo_type_wagon_types` join table). Empty = open to any + * cargo type of the yard's structural type (additive, never restrictive + * by default), so this cannot break a yard nobody has configured yet. + * + * Three cargo types didn't exist yet (Fertilizer, Coffee, Tea) — added here + * so Yards 1 and 9 have a real mapping ready for when they reopen. + */ +export class IndodeYardsAndCargoRouting2990000000000 implements MigrationInterface { + name = "IndodeYardsAndCargoRouting2990000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.warehouse_yards + ADD COLUMN IF NOT EXISTS direction varchar(10) + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.warehouse_yard_cargo_types ( + yard_id uuid NOT NULL REFERENCES freight.warehouse_yards (id) ON DELETE CASCADE, + cargo_type_id uuid NOT NULL REFERENCES freight.cargo_types (id) ON DELETE CASCADE, + PRIMARY KEY (yard_id, cargo_type_id) + ) + `); + + // New cargo types Indode's yard list names but the catalog didn't have yet. + await queryRunner.query(` + INSERT INTO freight.cargo_types (code, cargo_type_name, unit_of_measure, is_active) + VALUES + ('FERTILIZER', 'Fertilizer', 'PER_TON', true), + ('COFFEE', 'Coffee', 'PER_TON', true), + ('TEA', 'Tea', 'PER_TON', true) + ON CONFLICT (code) DO NOTHING + `); + + // The 11 real yards at Indode Open Warehouse (code 'IOW'). + await queryRunner.query(` + INSERT INTO freight.warehouse_yards + (warehouse_id, name, code, type, direction, status, is_active) + SELECT w.id, y.name, y.code, y.type, y.direction, y.status, y.status = 'ACTIVE' + FROM freight.warehouses w + CROSS JOIN (VALUES + ('Y1', 'Bagged Cargo Discharge - Fertilizer', 'BULK_YARD', NULL, 'INACTIVE'), + ('Y2', 'Break Bulk', 'GENERAL_CARGO_YARD', NULL, 'ACTIVE'), + ('Y3', 'Ro-Ro / Pac', 'GENERAL_CARGO_YARD', NULL, 'ACTIVE'), + ('Y4', 'Dry Bulk', 'BULK_YARD', NULL, 'INACTIVE'), + ('Y5', 'Container Terminal - Import (Stack Area)', 'CONTAINER_YARD', 'IMPORT', 'ACTIVE'), + ('Y6', 'Container Terminal - Export', 'CONTAINER_YARD', 'EXPORT', 'ACTIVE'), + ('Y7', 'Cold Chain', 'COLD_STORAGE_YARD', NULL, 'INACTIVE'), + ('Y8', 'Chemical', 'HAZARDOUS_YARD', NULL, 'INACTIVE'), + ('Y9', 'Coffee and Tea', 'GENERAL_CARGO_YARD', NULL, 'INACTIVE'), + ('Y10', 'Container Service Yard - Maintenance', 'CONTAINER_YARD', 'BOTH', 'ACTIVE'), + ('Y11', 'Equipment (Empty Container)', 'CONTAINER_YARD', 'BOTH', 'ACTIVE') + ) AS y(code, name, type, direction, status) + WHERE w.code = 'IOW' + ON CONFLICT (warehouse_id, code) DO NOTHING + `); + + // One default zone per new yard, matching its yard's type — every existing + // yard (CY-1, CY-A) already follows this one-zone-per-yard shape. + await queryRunner.query(` + INSERT INTO freight.warehouse_zones (yard_id, name, code, type, status, is_active) + SELECT y.id, y.name || ' Zone 1', 'Z1', + CASE y.type + WHEN 'CONTAINER_YARD' THEN 'CONTAINER_ZONE' + WHEN 'COLD_STORAGE_YARD' THEN 'COLD_STORAGE_ZONE' + WHEN 'HAZARDOUS_YARD' THEN 'HAZARDOUS_ZONE' + WHEN 'BULK_YARD' THEN 'BULK_ZONE' + ELSE 'GENERAL_CARGO_ZONE' + END, + y.status, y.status = 'ACTIVE' + FROM freight.warehouse_yards y + JOIN freight.warehouses w ON w.id = y.warehouse_id + WHERE w.code = 'IOW' AND y.code LIKE 'Y%' + ON CONFLICT (yard_id, code) DO NOTHING + `); + + // Cargo-type routing. Yards 5/6/10/11 (CONTAINER_YARD) are intentionally + // left with no rows — direction alone decides those, per the entity comment. + await queryRunner.query(` + INSERT INTO freight.warehouse_yard_cargo_types (yard_id, cargo_type_id) + SELECT y.id, ct.id + FROM freight.warehouses w + JOIN freight.warehouse_yards y ON y.warehouse_id = w.id + JOIN (VALUES + ('Y1', 'FERTILIZER'), + ('Y2', 'STEEL_BILLET'), ('Y2', 'PLASTIC_BARREL'), ('Y2', 'MACHINERY'), ('Y2', 'LIVESTOCK'), + ('Y3', 'AUTOMOBILE'), ('Y3', 'TRUCK'), + ('Y4', 'BARLY'), ('Y4', 'BEANS'), ('Y4', 'BULK'), ('Y4', 'CEREAL'), + ('Y4', 'EDIBLE_OIL'), ('Y4', 'RICE'), ('Y4', 'SUGAR'), ('Y4', 'WHEAT'), + ('Y7', 'PERISHABLE'), + ('Y9', 'COFFEE'), ('Y9', 'TEA') + ) AS m(yard_code, cargo_code) ON m.yard_code = y.code + JOIN freight.cargo_types ct ON ct.code = m.cargo_code + WHERE w.code = 'IOW' + ON CONFLICT (yard_id, cargo_type_id) DO NOTHING + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DELETE FROM freight.warehouse_zones z + USING freight.warehouse_yards y, freight.warehouses w + WHERE z.yard_id = y.id AND y.warehouse_id = w.id + AND w.code = 'IOW' AND y.code LIKE 'Y%' + `); + await queryRunner.query(` + DELETE FROM freight.warehouse_yards y + USING freight.warehouses w + WHERE y.warehouse_id = w.id AND w.code = 'IOW' AND y.code LIKE 'Y%' + `); + // Cargo types and the join table are left in place — other data may have + // started referencing them since; dropping columns/tables is not reversible + // once real rows exist, and leaving them is harmless. + } +} diff --git a/apps/edr-freight-api/src/modules/auth/login-audience.middleware.spec.ts b/apps/edr-freight-api/src/modules/auth/login-audience.middleware.spec.ts new file mode 100644 index 000000000..2303c3ef2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/login-audience.middleware.spec.ts @@ -0,0 +1,92 @@ +import { ForbiddenException } from '@nestjs/common'; + +import { LoginAudienceMiddleware } from './login-audience.middleware'; + +/** + * Touches only the DataSource, so build off the prototype rather than + * standing up a full Nest module — same pattern as + * warehouses/receive-export-paid.spec.ts. + */ +function makeMiddleware(userType: string | undefined) { + const query = jest.fn().mockResolvedValue(userType ? [{ userType }] : []); + const middleware = Object.create( + LoginAudienceMiddleware.prototype, + ) as LoginAudienceMiddleware; + (middleware as unknown as { dataSource: unknown }).dataSource = { query }; + return middleware; +} + +function makeReq(clientApp: string | undefined, email = 'someone@example.com') { + return { + header: (name: string) => + name.toLowerCase() === 'x-client-app' ? clientApp : undefined, + body: { email }, + } as any; +} + +describe('LoginAudienceMiddleware', () => { + it('rejects when the client app header is missing', async () => { + const middleware = makeMiddleware('employee'); + const next = jest.fn(); + + await expect( + middleware.use(makeReq(undefined), {} as any, next), + ).rejects.toBeInstanceOf(ForbiddenException); + expect(next).not.toHaveBeenCalled(); + }); + + it('rejects an unrecognized client app header', async () => { + const middleware = makeMiddleware('employee'); + const next = jest.fn(); + + await expect( + middleware.use(makeReq('mobile'), {} as any, next), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('rejects an employee account signing in through the portal client', async () => { + const middleware = makeMiddleware('employee'); + const next = jest.fn(); + + await expect( + middleware.use(makeReq('portal'), {} as any, next), + ).rejects.toBeInstanceOf(ForbiddenException); + expect(next).not.toHaveBeenCalled(); + }); + + it('rejects a customer account signing in through the backoffice client', async () => { + const middleware = makeMiddleware('individual'); + const next = jest.fn(); + + await expect( + middleware.use(makeReq('backoffice'), {} as any, next), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('allows an employee account through the backoffice client', async () => { + const middleware = makeMiddleware('employee'); + const next = jest.fn(); + + await middleware.use(makeReq('backoffice'), {} as any, next); + + expect(next).toHaveBeenCalledTimes(1); + }); + + it('allows a customer account through the portal client', async () => { + const middleware = makeMiddleware('individual'); + const next = jest.fn(); + + await middleware.use(makeReq('portal'), {} as any, next); + + expect(next).toHaveBeenCalledTimes(1); + }); + + it('lets an unknown identifier fall through to the login handler', async () => { + const middleware = makeMiddleware(undefined); + const next = jest.fn(); + + await middleware.use(makeReq('portal'), {} as any, next); + + expect(next).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/edr-freight-api/src/modules/auth/login-audience.middleware.ts b/apps/edr-freight-api/src/modules/auth/login-audience.middleware.ts new file mode 100644 index 000000000..3af58d02c --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/login-audience.middleware.ts @@ -0,0 +1,58 @@ +import { ForbiddenException, Injectable, NestMiddleware } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; +import { NextFunction, Request, Response } from 'express'; + +export const CLIENT_APP_HEADER = 'x-client-app'; + +// EUserType values from @tria-plc/api-common, duplicated here to avoid +// pulling in the full enum just for this string comparison. +const ALLOWED_USER_TYPES_BY_CLIENT: Record = { + backoffice: ['employee'], + portal: ['individual', 'external_organization'], +}; + +/** + * Blocks EDRFREIGHT-415: /auth/login and /auth/mfa-verify match credentials + * against email/username/phone_number only (see vendor + * findUserForLogin), with no check that the account's userType belongs on + * the app that's asking. A backoffice (employee) client presenting a + * customer's credentials — or vice versa — must not get a session. + */ +@Injectable() +export class LoginAudienceMiddleware implements NestMiddleware { + constructor(@InjectDataSource() private readonly dataSource: DataSource) {} + + async use(req: Request, _res: Response, next: NextFunction) { + const clientApp = req.header(CLIENT_APP_HEADER); + const allowedUserTypes = clientApp + ? ALLOWED_USER_TYPES_BY_CLIENT[clientApp] + : undefined; + if (!allowedUserTypes) { + throw new ForbiddenException( + `Missing or unrecognized ${CLIENT_APP_HEADER} header`, + ); + } + + const identifier: unknown = req.body?.email; + if (typeof identifier !== 'string' || !identifier) { + // No identifier to look up — the vendor DTO validation rejects the + // request on its own. + return next(); + } + + const [user] = await this.dataSource.query( + `SELECT user_type AS "userType" FROM iam.users + WHERE email = $1 OR username = $1 OR phone_number = $1 LIMIT 1`, + [identifier], + ); + + if (user && !allowedUserTypes.includes(user.userType)) { + throw new ForbiddenException( + `This account cannot sign in through the ${clientApp} application`, + ); + } + + next(); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 892c82a99..3d339fa19 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -302,6 +302,20 @@ export class Booking extends BaseEntity { @Column({ name: 'customer_truck_arrived_at', type: 'timestamptz', nullable: true }) customerTruckArrivedAt?: Date | null; + /** + * Did the goods need re-handling in the warehouse? Recorded by warehouse + * staff after unloading. Only `true` bills the DOUBLE_HANDLING_FEE rule; + * null = not yet decided (no charge). + */ + @Column({ name: 'double_handling', type: 'boolean', nullable: true }) + doubleHandling?: boolean | null; + + @Column({ name: 'double_handling_set_at', type: 'timestamptz', nullable: true }) + doubleHandlingSetAt?: Date | null; + + @Column({ name: 'double_handling_set_by', type: 'varchar', length: 160, nullable: true }) + doubleHandlingSetBy?: string | null; + @Column({ name: 'customs_clearing_enabled', type: 'boolean', default: false }) customsClearingEnabled!: boolean; diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 8e3be4d1a..ee75460c9 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -35,6 +35,10 @@ import { CreateExternalProfileDto } from "./dto/create-external-profile.dto"; import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto"; import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto"; import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto"; +import { + CompanyIdentityStateDto, + CompleteIdentityVerificationDto, +} from "./dto/complete-identity-verification.dto"; import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto"; import { StartOnboardingDto } from "./dto/start-onboarding.dto"; import { DashboardQueryDto } from "./dto/dashboard-query.dto"; @@ -188,9 +192,20 @@ export class CompaniesController { @Post("fetch-etrade-info") @ApiOperation({ summary: "Fetch company info from eTrade by TIN" }) async fetchETradeInfo( + @CurrentUser() user: CurrentIamUser, @Body() dto: FetchETradeDto, ): Promise { - const data = await this.companiesService.fetchETradeData(dto.tin); + // Best-effort: a first-run onboarding draft may not exist yet, in which + // case there is no company to exclude and `tinTaken` checks every row — + // the correct behaviour for a brand-new lookup. + const companyId = await this.companiesService + .getCompanyInfoByUserId(user.id) + .then(({ company }) => company.id) + .catch(() => undefined); + const data = await this.companiesService.fetchETradeData( + dto.tin, + companyId, + ); return new ETradeResponseDto(data); } @@ -378,6 +393,32 @@ export class CompaniesController { return this.companiesService.removePoaDelegationLetter(user.id, fileId); } + @Post("identity/fayda/complete") + @ApiOperation({ + summary: + "Bind a completed Fayda verification to the company's owner or Power of Attorney. " + + "Start the flow with POST /fayda/verification/start (platform=PORTAL), then post the returned code+state here. " + + "The verified name, phone, email and address are written from the Fayda payload; on an approved company the change is staged for backoffice review.", + }) + async completeIdentityVerification( + @CurrentUser() user: CurrentIamUser, + @Body() dto: CompleteIdentityVerificationDto, + ): Promise { + return this.companiesService.completeIdentityVerification(user.id, dto); + } + + @Delete("identity/fayda/poa") + @ApiOperation({ + summary: + "Remove the company's Power of Attorney — the verified identity, its details and the delegation paper together. " + + "Refused while the company holds a freight forwarder role, which cannot operate without a representative.", + }) + async removePoaIdentity( + @CurrentUser() user: CurrentIamUser, + ): Promise { + return this.companiesService.removePoaIdentity(user.id); + } + @Patch("onboarding-step") @ApiOperation({ summary: "Persist the user's current onboarding wizard step" }) @HttpCode(HttpStatus.NO_CONTENT) 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 new file mode 100644 index 000000000..7f72d431a --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts @@ -0,0 +1,412 @@ +import { BadRequestException } from "@nestjs/common"; + +import { CompaniesService } from "./companies.service"; +import { CompanyNationality, CompanyStatus } from "./entities/company.entity"; +import { ProfileType } from "./entities/company-profile.entity"; +import { POA_DELEGATION_FILE_KEY } from "../file-upload-settings/poa-delegation.constants"; + +/** + * A person's identity is proved through Fayda: name, email, phone and address + * come from the verified payload, not typed. Fayda's userinfo carries no + * national ID number, so none is collected or derived here. + * + * - Ethiopian company: the owner (and its PoA, once named) is verified through + * Fayda, and their details can't be edited afterwards. + * - Foreign company: Fayda is an Ethiopian national ID, so the owner instead + * supplies a typed passport number — required on its own, whether or not the + * owner also completes a (purely optional) Fayda verification. + * + * The owner is NOT the general manager — GM is a separate, plain typed role + * the portal offers a "same as owner" copy for, but it is never itself + * Fayda-verified or gated on. + */ + +interface Ctx { + attributes: Record; + files: { id: string; code: string; reviewStatus?: string | null }[]; + profileTypes: ProfileType[]; + status: CompanyStatus; + nationality: CompanyNationality; + verification: Record; +} + +const OWNER_VERIFIED = { + ownerFaydaSub: "owner-sub", + ownerFaydaVerifiedAt: "2026-07-01T00:00:00.000Z", + ownerName: "Abebe Bikila", +}; + +const POA_VERIFIED = { + poaFaydaSub: "poa-sub", + poaFaydaVerifiedAt: "2026-07-02T00:00:00.000Z", + poaName: "Tirunesh Dibaba", + poaEmail: "tirunesh@example.com", + poaPhone: "+251911000000", +}; + +const paper = () => ({ + id: "file-1", + code: POA_DELEGATION_FILE_KEY, + reviewStatus: null, +}); + +function makeService(overrides: Partial = {}) { + const ctx: Ctx = { + attributes: {}, + files: [], + profileTypes: [ProfileType.importer], + status: CompanyStatus.Pending, + nationality: CompanyNationality.Ethiopian, + verification: { + purpose: "VERIFY", + verified: true, + sub: "new-sub", + fullName: "Haile Gebrselassie", + email: "haile@example.com", + phoneNumber: "+251922000000", + address: "Addis Ababa", + birthdate: "1973-04-18", + gender: "Male", + }, + ...overrides, + }; + + const company = () => ({ + id: "company-1", + status: ctx.status, + nationality: ctx.nationality, + attributes: ctx.attributes, + companyProfiles: ctx.profileTypes.map((type, i) => ({ + id: `profile-${i}`, + type, + })), + type: "customer", + }); + + const deps = { + companiesRepo: { + findById: jest.fn(async () => company()), + update: jest.fn(async (_id: string, patch: Record) => { + if (patch.attributes) + ctx.attributes = patch.attributes as Record; + return company(); + }), + findByTin: jest.fn(async () => null), + }, + companyProfilesRepo: { + findByCompanyId: jest.fn(async () => + ctx.profileTypes.map((type, i) => ({ id: `profile-${i}`, type })), + ), + findByType: jest.fn(async (_id: string, type: ProfileType) => + ctx.profileTypes.includes(type) ? { id: "existing", type } : null, + ), + create: jest.fn(async (row: Record) => ({ + id: "new", + ...row, + })), + }, + changeRequestRepo: { + findPendingByCompanyId: jest.fn(async () => null), + findByCompanyId: jest.fn(async () => []), + create: jest.fn(async (row: Record) => ({ + id: "cr-1", + ...row, + })), + update: jest.fn(async () => ({ id: "cr-1" })), + }, + profilesRepo: { + findByCompanyId: jest.fn(async () => []), + findByUserId: jest.fn(async () => ({ + id: "external-1", + companyId: "company-1", + company: company(), + onboardingCompleted: false, + })), + }, + filesService: { + findByResource: jest.fn(async () => ctx.files), + findById: jest.fn(async () => null), + remove: jest.fn(async () => undefined), + }, + companyNotifier: { changeRequestSubmitted: jest.fn() }, + verifayda: { + completeVerification: jest.fn(async () => ctx.verification), + }, + }; + + const service = new CompaniesService( + deps.companiesRepo as never, + deps.companyProfilesRepo as never, + deps.changeRequestRepo as never, + deps.profilesRepo as never, + {} as never, + deps.filesService as never, + {} as never, + {} as never, + deps.companyNotifier as never, + {} as never, + deps.verifayda as never, + ); + + jest + .spyOn(service, "getCompanyInfoByUserId") + .mockImplementation( + async () => + ({ profile: { id: "external-1" }, company: company() }) as never, + ); + + return { service, ctx, deps, company }; +} + +describe("Fayda identity verification binds a person to the company", () => { + it("writes the verified identity", async () => { + const { service, ctx } = makeService(); + + const state = await service.completeIdentityVerification("user-1", { + subject: "owner", + code: "c", + state: "s", + }); + + expect(ctx.attributes.ownerFaydaSub).toBe("new-sub"); + expect(ctx.attributes.ownerName).toBe("Haile Gebrselassie"); + expect(state.owner.verified).toBe(true); + }); + + it("fills every PoA detail from the payload, address included", async () => { + const { service, ctx } = makeService(); + + await service.completeIdentityVerification("user-1", { + subject: "poa", + code: "c", + state: "s", + }); + + expect(ctx.attributes.poaName).toBe("Haile Gebrselassie"); + expect(ctx.attributes.poaEmail).toBe("haile@example.com"); + expect(ctx.attributes.poaPhone).toBe("+251922000000"); + expect(ctx.attributes.poaAddress).toBe("Addis Ababa"); + }); + + it("verifies successfully even though Fayda returns no national ID number", async () => { + // Fayda's userinfo carries no FAN/FIN claim at all — this must be the + // normal, successful path, not an error. + const { service } = makeService({ + verification: { + purpose: "VERIFY", + verified: true, + sub: "x", + fullName: "No Fan Here", + }, + }); + + const state = await service.completeIdentityVerification("user-1", { + subject: "owner", + code: "c", + state: "s", + }); + + expect(state.owner.verified).toBe(true); + }); + + it("refuses to make one identity both owner and PoA", async () => { + const { service } = makeService({ + attributes: { ownerFaydaSub: "same-person" }, + verification: { + purpose: "VERIFY", + verified: true, + sub: "same-person", + fullName: "Abebe Bikila", + }, + }); + + await expect( + service.completeIdentityVerification("user-1", { + subject: "poa", + code: "c", + state: "s", + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("stages the change for review on an approved company", async () => { + // Swapping the person who can act for a live company is exactly what the + // backoffice review exists for, so it must not rewrite the row directly. + const { service, ctx, deps } = makeService({ + status: CompanyStatus.Active, + }); + + await service.completeIdentityVerification("user-1", { + subject: "poa", + code: "c", + state: "s", + }); + + expect(deps.changeRequestRepo.create).toHaveBeenCalled(); + expect(ctx.attributes.poaFaydaSub).toBeUndefined(); + }); + + it("refuses to rename a verified person by hand", async () => { + const { service } = makeService({ + attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED }, + files: [paper()], + }); + + await expect( + service.updateProfile("user-1", { poaName: "Someone Else" } as never), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("never locks or gates the general manager — it is not the verified subject", async () => { + // GM is a plain typed role; the portal offers a "same as owner" copy, but + // the backend must not treat it as identity-owned or require it verified. + const { service } = makeService({ + attributes: { ...OWNER_VERIFIED }, + }); + + await expect( + service.updateProfile("user-1", { + generalManagerName: "Someone Else", + generalManagerEmail: "someone@example.com", + generalManagerPhone: "+251911223344", + } as never), + ).resolves.toBeDefined(); + }); +}); + +describe("Ethiopian companies verify with Fayda; foreign companies verify identity by passport", () => { + // The company is applying for the forwarder role, so it must not already + // hold it — createCompanyProfileForUser short-circuits on an existing profile + // and would never reach the gate. + const applyingForFf = { + profileTypes: [ProfileType.importer], + attributes: { ...POA_VERIFIED }, + files: [paper()], + }; + + it("blocks the forwarder role while the owner is unverified", async () => { + const { service } = makeService(applyingForFf); + + await expect( + service.createCompanyProfileForUser( + "user-1", + ProfileType.freightForwarder, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("blocks the forwarder role while the PoA is unverified", async () => { + const { service } = makeService({ + profileTypes: [ProfileType.importer], + attributes: { + ...OWNER_VERIFIED, + poaName: "Tirunesh Dibaba", + poaEmail: "t@example.com", + poaPhone: "+251911000000", + }, + files: [paper()], + }); + + await expect( + service.createCompanyProfileForUser( + "user-1", + ProfileType.freightForwarder, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("grants the forwarder role once owner and PoA are both verified", async () => { + const { service } = makeService({ + profileTypes: [ProfileType.importer], + attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED }, + files: [paper()], + }); + + await expect( + service.createCompanyProfileForUser( + "user-1", + ProfileType.freightForwarder, + ), + ).resolves.toBeDefined(); + }); + + it("never asks a foreign company for Fayda, verified or not", async () => { + const { service } = makeService({ + nationality: CompanyNationality.Foreign, + }); + + const state = await service.completeIdentityVerification("user-1", { + subject: "owner", + code: "c", + state: "s", + }); + + // Still lets the owner verify — a foreign owner verifying is allowed, just + // never required — but the passport is the thing that actually gates it. + expect(state.owner.verified).toBe(true); + expect(state.faydaRequired).toBe(false); + expect(state.passportRequired).toBe(true); + }); + + it("blocks the forwarder role for a foreign company with no owner passport", async () => { + const { service } = makeService({ + profileTypes: [ProfileType.importer], + nationality: CompanyNationality.Foreign, + attributes: { + poaName: "Jean Dupont", + poaEmail: "jean@example.com", + poaPhone: "+33100000000", + }, + }); + + await expect( + service.createCompanyProfileForUser( + "user-1", + ProfileType.freightForwarder, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("grants the forwarder role to a foreign company with an owner passport and no Fayda at all", async () => { + const { service } = makeService({ + profileTypes: [ProfileType.importer], + nationality: CompanyNationality.Foreign, + attributes: { + ownerPassportNumber: "P1234567", + poaName: "Jean Dupont", + poaEmail: "jean@example.com", + poaPhone: "+33100000000", + }, + files: [paper()], + }); + + await expect( + service.createCompanyProfileForUser( + "user-1", + ProfileType.freightForwarder, + ), + ).resolves.toBeDefined(); + }); + + it("still requires the passport for a foreign owner who chose to verify with Fayda too", async () => { + // Verifying is optional for a foreign owner, but it does not waive the + // passport requirement — the two are independent credentials. + const { service } = makeService({ + profileTypes: [ProfileType.importer], + nationality: CompanyNationality.Foreign, + attributes: { + ...OWNER_VERIFIED, + poaName: "Jean Dupont", + poaEmail: "jean@example.com", + poaPhone: "+33100000000", + }, + }); + + await expect( + service.createCompanyProfileForUser( + "user-1", + ProfileType.freightForwarder, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); +}); diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts index 73826689a..450222657 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.module.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -20,6 +20,7 @@ import { CompanyProfileRepository } from "./company-profile.repository"; import { CompanyChangeRequestRepository } from "./company-change-request.repository"; import { ETradeService } from "./services/etrade.service"; import { CompanyNotifierService } from "./company-notifier.service"; +import { VerifaydaModule } from "../verifayda/verifayda.module"; @Module({ imports: [ @@ -38,6 +39,8 @@ import { CompanyNotifierService } from "./company-notifier.service"; // imports this module back for portal recipient targeting, hence forwardRef. NotificationsModule, forwardRef(() => NotificationInboxModule), + // Fayda identity verification for the company's owner and PoA. + VerifaydaModule, ], controllers: [CompaniesController], providers: [ diff --git a/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts new file mode 100644 index 000000000..9e1c76477 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts @@ -0,0 +1,242 @@ +import { BadRequestException } from "@nestjs/common"; + +import { CompaniesService } from "./companies.service"; +import { CompanyStatus } from "./entities/company.entity"; +import { ProfileType } from "./entities/company-profile.entity"; +import { POA_DELEGATION_FILE_KEY } from "../file-upload-settings/poa-delegation.constants"; + +/** + * EDRFREIGHT-358: a company that names a Power of Attorney must have the DARS + * delegation paper on file. The rule used to live only in the onboarding + * wizard's completion check, so every other write that could break the pairing + * — saving PoA details, deleting the paper, picking up the forwarder role — + * went unguarded. These cover those writes. + */ + +interface Ctx { + attributes: Record; + files: { id: string; code: string; reviewStatus?: string | null }[]; + profileTypes: ProfileType[]; + status: CompanyStatus; + pendingSnapshot: Record | null; +} + +const POA = { poaName: "Abebe", poaEmail: "a@b.com", poaPhone: "+251911000000" }; + +/** + * The forwarder role is gated on Fayda-verified identities as well as on the + * delegation paper. These tests are about the paper, so they run against a + * company whose identities are already verified — the identity rule itself is + * covered in companies.fayda-identity.spec.ts. + */ +const VERIFIED_IDENTITIES = { + ownerFaydaSub: "owner-sub", + poaFaydaSub: "poa-sub", +}; + +function makeService(overrides: Partial = {}) { + const ctx: Ctx = { + attributes: {}, + files: [], + profileTypes: [ProfileType.importer], + status: CompanyStatus.Pending, + pendingSnapshot: null, + ...overrides, + }; + + const company = () => ({ + id: "company-1", + status: ctx.status, + attributes: ctx.attributes, + companyProfiles: ctx.profileTypes.map((type, i) => ({ + id: `profile-${i}`, + type, + })), + type: "customer", + }); + + const deps = { + companiesRepo: { + findById: jest.fn(async () => company()), + update: jest.fn(async (_id: string, patch: Record) => { + ctx.attributes = (patch.attributes ?? + ctx.attributes) as Record; + return company(); + }), + findByTin: jest.fn(async () => null), + }, + companyProfilesRepo: { + findByCompanyId: jest.fn(async () => + ctx.profileTypes.map((type, i) => ({ id: `profile-${i}`, type })), + ), + findByType: jest.fn(async (_id: string, type: ProfileType) => + ctx.profileTypes.includes(type) ? { id: "existing", type } : null, + ), + create: jest.fn(async (row: Record) => ({ + id: "new", + ...row, + })), + }, + changeRequestRepo: { + findPendingByCompanyId: jest.fn(async () => + ctx.pendingSnapshot ? { id: "cr-1", snapshot: ctx.pendingSnapshot } : null, + ), + findByCompanyId: jest.fn(async () => []), + create: jest.fn(async (row: Record) => ({ + id: "cr-1", + ...row, + })), + update: jest.fn(async () => ({ id: "cr-1" })), + }, + profilesRepo: { + findByCompanyId: jest.fn(async () => []), + findByUserId: jest.fn(async () => ({ + id: "external-1", + companyId: "company-1", + company: company(), + onboardingCompleted: false, + })), + }, + filesService: { + findByResource: jest.fn(async () => ctx.files), + findById: jest.fn(async (id: string) => + ctx.files.find((f) => f.id === id) + ? { + ...ctx.files.find((f) => f.id === id), + resource: "companies", + resourceId: "company-1", + name: "dars.pdf", + } + : null, + ), + remove: jest.fn(async () => undefined), + }, + companyNotifier: { changeRequestSubmitted: jest.fn() }, + }; + + const service = new CompaniesService( + deps.companiesRepo as never, + deps.companyProfilesRepo as never, + deps.changeRequestRepo as never, + deps.profilesRepo as never, + {} as never, + deps.filesService as never, + {} as never, + {} as never, + deps.companyNotifier as never, + {} as never, + {} as never, + ); + + // getCompanyInfoByUserId does its own lookups; the stubs above are enough for + // the PoA paths, so short-circuit it rather than mock the whole graph. + jest + .spyOn(service, "getCompanyInfoByUserId") + .mockImplementation( + async () => + ({ profile: { id: "external-1" }, company: company() }) as never, + ); + + return { service, ctx, deps }; +} + +const paper = (reviewStatus: string | null = null) => ({ + id: "file-1", + code: POA_DELEGATION_FILE_KEY, + reviewStatus, +}); + +describe("PoA delegation paper is enforced wherever PoA state changes", () => { + it("rejects PoA details saved with no paper on file", async () => { + const { service } = makeService(); + + await expect( + service.updateProfile("user-1", POA as never), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("accepts PoA details once the paper is on file", async () => { + const { service } = makeService({ files: [paper()] }); + + await expect( + service.updateProfile("user-1", POA as never), + ).resolves.toBeDefined(); + }); + + it("rejects a paper the reviewer sent back for correction", async () => { + const { service } = makeService({ files: [paper("change_requested")] }); + + await expect( + service.updateProfile("user-1", POA as never), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("leaves edits that don't touch the PoA alone", async () => { + // A company carrying legacy details must not be locked out of every other + // field until it produces a paper. + const { service } = makeService({ attributes: { ...POA }, files: [] }); + + await expect( + service.updateProfile("user-1", { companyEmail: "x@y.com" } as never), + ).resolves.toBeDefined(); + }); + + it("refuses to remove the paper while the PoA is still named", async () => { + const { service } = makeService({ + attributes: { ...POA }, + files: [paper()], + }); + + await expect( + service.removePoaDelegationLetter("user-1", "file-1"), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("allows removing the paper once the PoA has been cleared", async () => { + const { service } = makeService({ attributes: {}, files: [paper()] }); + + await expect( + service.removePoaDelegationLetter("user-1", "file-1"), + ).resolves.toBeDefined(); + }); + + it("judges the removal against a staged clear, not the live row", async () => { + // An Active company's edits are staged for review rather than written, so + // the live attributes still carry the PoA the customer just cleared. + const { service } = makeService({ + status: CompanyStatus.Active, + attributes: { ...POA }, + pendingSnapshot: { poaName: "", poaEmail: "", poaPhone: "" }, + files: [paper()], + }); + + await expect( + service.removePoaDelegationLetter("user-1", "file-1"), + ).resolves.toBeDefined(); + }); + + it("refuses the forwarder role to a company with no PoA", async () => { + const { service } = makeService({ attributes: { ...VERIFIED_IDENTITIES } }); + + await expect( + service.createCompanyProfileForUser( + "user-1", + ProfileType.freightForwarder, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("grants the forwarder role once PoA details and paper are both in place", async () => { + const { service } = makeService({ + attributes: { ...POA, ...VERIFIED_IDENTITIES }, + files: [paper()], + }); + + await expect( + service.createCompanyProfileForUser( + "user-1", + ProfileType.freightForwarder, + ), + ).resolves.toBeDefined(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/companies/companies.repository.ts b/apps/edr-freight-api/src/modules/companies/companies.repository.ts index db8db0d2e..092ebc2c4 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.repository.ts @@ -75,8 +75,14 @@ export class CompaniesRepository extends BaseRepository { .getMany(); } - async existsByTin(tin: string): Promise { - const count = await this.repository.count({ where: { tin } as any }); + async existsByTin(tin: string, excludeCompanyId?: string): Promise { + const qb = this.repository + .createQueryBuilder('company') + .where('company.tin = :tin', { tin }); + if (excludeCompanyId) { + qb.andWhere('company.id != :excludeCompanyId', { excludeCompanyId }); + } + const count = await qb.getCount(); return count > 0; } diff --git a/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts new file mode 100644 index 000000000..f22123f61 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts @@ -0,0 +1,113 @@ +import { CompaniesService } from "./companies.service"; +import { CompanyType } from "./entities/company.entity"; +import { ProfileStatus, ProfileType } from "./entities/company-profile.entity"; + +/** + * EDRFREIGHT-416: onboarding asked for a deselected role's documents. + * + * Re-running role selection used to only ADD operational profiles, so a role + * the user unticked on the way back left its company_profile row behind — and + * every role-driven requirement (business license, forwarder PoA) is derived + * from those rows. startOnboarding now reconciles both directions. + */ + +interface ExistingProfile { + id: string; + type: ProfileType; + status: ProfileStatus; +} + +function makeService(existing: ExistingProfile[]) { + const companyProfilesRepo = { + findByCompanyId: jest.fn(async () => existing), + create: jest.fn(async (row: Record) => ({ + id: "new", + ...row, + })), + softDelete: jest.fn(async () => undefined), + }; + const companiesRepo = { update: jest.fn(async () => null) }; + const profilesRepo = { + findByUserId: jest.fn(async () => ({ + id: "external-1", + companyId: "company-1", + company: { id: "company-1" }, + })), + }; + + const service = new CompaniesService( + companiesRepo as never, + companyProfilesRepo as never, + {} as never, + profilesRepo as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + ); + + jest + .spyOn(service, "getCompanyInfoByUserId") + .mockImplementation( + async () => + ({ profile: { id: "external-1" }, company: { id: "company-1" } }) as never, + ); + + return { service, companyProfilesRepo }; +} + +const identity = { userId: "user-1", firstName: "Abebe", lastName: "K" }; + +const start = (service: CompaniesService, roles: ProfileType[]) => + service.startOnboarding(identity as never, CompanyType.Customer, roles); + +describe("re-running role selection reconciles the operational profiles", () => { + it("drops the profile for a role the user deselected", async () => { + const { service, companyProfilesRepo } = makeService([ + { id: "p-imp", type: ProfileType.importer, status: ProfileStatus.Pending }, + { + id: "p-ff", + type: ProfileType.freightForwarder, + status: ProfileStatus.Pending, + }, + ]); + + await start(service, [ProfileType.importer]); + + expect(companyProfilesRepo.softDelete).toHaveBeenCalledWith("p-ff"); + expect(companyProfilesRepo.softDelete).toHaveBeenCalledTimes(1); + expect(companyProfilesRepo.create).not.toHaveBeenCalled(); + }); + + it("keeps an already-approved profile even when it is unticked", async () => { + const { service, companyProfilesRepo } = makeService([ + { id: "p-imp", type: ProfileType.importer, status: ProfileStatus.Pending }, + { + id: "p-exp", + type: ProfileType.exporter, + status: ProfileStatus.Active, + }, + ]); + + await start(service, [ProfileType.importer]); + + expect(companyProfilesRepo.softDelete).not.toHaveBeenCalled(); + }); + + it("still adds a newly-picked role", async () => { + const { service, companyProfilesRepo } = makeService([ + { id: "p-imp", type: ProfileType.importer, status: ProfileStatus.Pending }, + ]); + + await start(service, [ProfileType.importer, ProfileType.exporter]); + + expect(companyProfilesRepo.softDelete).not.toHaveBeenCalled(); + expect(companyProfilesRepo.create).toHaveBeenCalledTimes(1); + expect(companyProfilesRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ type: ProfileType.exporter }), + ); + }); +}); 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 6650dfca5..50cc648ad 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -17,10 +17,23 @@ import { import { FilesService } from "../files/files.service"; import { FileRecord } from "../files/entities/file.entity"; import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service"; +import { + POA_DELEGATION_FILE_KEY, + POA_DELEGATION_LABEL, + POA_DELEGATION_PENDING_CODE, +} from "../file-upload-settings/poa-delegation.constants"; +import { VerifaydaService } from "../verifayda/verifayda.service"; +import { + buildCompanyIdentityState, + CompanyIdentityStateDto, + CompleteIdentityVerificationDto, + IdentitySubject, +} from "./dto/complete-identity-verification.dto"; import { ETradeService } from "./services/etrade.service"; import { CompanyNotifierService } from "./company-notifier.service"; import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto"; import { normalizeE164 } from "../../common/validators/is-phone-number.validator"; +import type { CompanyRegistrationData } from "@edr/types"; import { CreateCompanyDto } from "./dto/create-company.dto"; import { UpdateCompanyDto } from "./dto/update-company.dto"; import { CreateExternalProfileDto } from "./dto/create-external-profile.dto"; @@ -58,10 +71,6 @@ const LICENSE_CODE = "business_license"; /** Code for a license file staged in an open change request (not yet live). */ const LICENSE_PENDING_CODE = "business_license_pending"; -/** Mirrors the field seeded in seed/file-upload-settings.seeder.ts. */ -const POA_DELEGATION_FILE_KEY = "poa_delegation_letter"; -/** Code for a PoA letter staged in an open change request (not yet live). */ -const POA_DELEGATION_PENDING_CODE = "poa_delegation_letter_pending"; /** FileRecord resource that company-level documents are stored under. */ const COMPANY_RESOURCE = "companies"; /** company.attributes keys that together mean "a PoA was entered". */ @@ -79,6 +88,62 @@ const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [ { key: "poaPhone", label: "PoA phone" }, ]; +/** + * `attributes` key prefix per verifiable person. The owner is NOT the general + * manager — GM is a plain typed role (the portal offers a "same as owner" copy + * once the owner is verified), while the owner is who this verification + * actually proves. They're very often the same human; that's what the copy is + * for. + */ +const IDENTITY_PREFIX: Record = { + owner: "owner", + poa: "poa", +}; + +const IDENTITY_LABEL: Record = { + owner: "owner", + poa: "Power of Attorney", +}; + +/** + * Identity fields a Fayda verification owns outright, per person. Once verified + * these can no longer be typed — the government IdP is the source, so an edit + * that disagrees with it is either a mistake or an attempt to launder the + * guarantee away. The GM fields are deliberately absent: GM is never itself + * Fayda-verified, so it stays freely editable regardless of the owner's state. + */ +const IDENTITY_OWNED_FIELDS: Record = { + owner: ["ownerName", "ownerEmail", "ownerPhone", "ownerAddress"], + poa: ["poaName", "poaEmail", "poaPhone", "poaAddress"], +}; + +/** + * `UpdateProfileDto` fields eTrade is the sole source of truth for. A request + * touching any of these must be re-checked against a fresh eTrade lookup — + * see `assertEtradeFieldsAuthentic`. + */ +const ETRADE_SOURCED_FIELDS = [ + "companyName", + "tin", + "licenceNumber", + "statusDescription", + "dateRegistered", + "renewedFrom", + "renewalDate", + "renewedTo", + "region", + "zone", + "woreda", + "kebele", + "houseNo", + "etradePhone", +] as const satisfies readonly (keyof UpdateProfileDto)[]; + +/** The attributes a verification writes, for one person. */ +interface VerifiedIdentityAttributes { + [key: string]: unknown; +} + export interface UserIdentity { userId: string; firstName: string; @@ -100,6 +165,7 @@ export class CompaniesService { private readonly etradeService: ETradeService, private readonly companyNotifier: CompanyNotifierService, private readonly dataSource: DataSource, + private readonly verifaydaService: VerifaydaService, ) { } /** @@ -255,8 +321,9 @@ export class CompaniesService { * chosen operational role(s) up front, so every subsequent wizard step can * save incrementally (PATCH /profile, /onboarding-step) against existing rows. * - * Idempotent: if the user already has a profile, returns it unchanged (only - * adding any newly-chosen roles). The draft company carries a placeholder TIN + * Idempotent: if the user already has a profile, returns it unchanged, with + * the operational profiles reconciled against the roles just chosen (added + * and — for still-pending ones — removed). The draft company carries a placeholder TIN * (the real one is filled on the Company Information step) and stays * status=pending / onboardingCompleted=false until the wizard finishes. */ @@ -271,7 +338,7 @@ export class CompaniesService { const existing = await this.profilesRepo.findByUserId(identity.userId); if (existing) { const companyId = existing.company?.id ?? existing.companyId; - await this.ensureCompanyProfiles(companyId, companyType, roles); + await this.syncCompanyProfiles(companyId, companyType, roles); if (nationality) { await this.companiesRepo.update(companyId, { nationality }); } @@ -302,25 +369,44 @@ export class CompaniesService { onboardingCompleted: false, }); - await this.ensureCompanyProfiles(company.id, companyType, chosenTypes); + await this.syncCompanyProfiles(company.id, companyType, chosenTypes); return this.getCompanyInfoByUserId(identity.userId); } - /** Create any of the requested operational profiles that don't exist yet. */ - private async ensureCompanyProfiles( + /** + * Reconcile the company's operational profiles with the roles the user has + * selected: create the missing ones, drop the ones they deselected. + * + * Dropping matters because every role-driven onboarding requirement — the + * per-profile business license, the freight-forwarder PoA rule, the license + * cards in the wizard — is derived from these rows. A row left behind after + * the user went back and unticked a role keeps asking for that role's + * documents (EDRFREIGHT-416). Only still-pending profiles are removed: an + * approved one is live (it can carry bookings and contracts) and re-running + * role selection must never delete it. + */ + private async syncCompanyProfiles( companyId: string, companyType: CompanyType, roles: ProfileType[], ): Promise { const allowedTypes = this.getProfileTypeForCompanyType(companyType); - for (const type of roles) { - if (!allowedTypes.includes(type)) continue; - const existing = await this.companyProfilesRepo.findByType( - companyId, - type, - ); - if (existing) continue; + const chosen = roles.filter((t) => allowedTypes.includes(t)); + const existing = await this.companyProfilesRepo.findByCompanyId(companyId); + + for (const profile of existing) { + if (chosen.includes(profile.type)) continue; + if (profile.status !== ProfileStatus.Pending) continue; + // The license files uploaded against this profile go with it: they are + // only ever read per company_profile id, so a soft-deleted profile + // leaves nothing behind to prompt for. Re-picking the role creates a + // fresh profile the user uploads against again. + await this.companyProfilesRepo.softDelete(profile.id); + } + + for (const type of chosen) { + if (existing.some((p) => p.type === type)) continue; // No reference yet — minted on backoffice approval (setCompanyProfileStatus). await this.companyProfilesRepo.create({ companyId, @@ -599,7 +685,9 @@ export class CompaniesService { */ private mapProfileDtoToCompanyUpdates( company: Company, - dto: Partial, + dto: Partial & { + faydaIdentity?: VerifiedIdentityAttributes; + }, ): Record { const companyUpdates: Record = {}; const attrUpdates: Record = { ...(company.attributes ?? {}) }; @@ -617,7 +705,6 @@ export class CompaniesService { if (dto.tin !== undefined && dto.tin !== company.tin) companyUpdates.tin = dto.tin; if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber; - if (dto.fanNumber !== undefined) companyUpdates.fanNumber = dto.fanNumber; if (dto.contactPersonName !== undefined) attrUpdates.contactPersonName = dto.contactPersonName; @@ -661,6 +748,72 @@ export class CompaniesService { if (dto.etradePhone !== undefined) companyUpdates.etradePhone = normalizeE164(dto.etradePhone); + // A plain typed field — never Fayda-verified, so no lock ever applies to + // it. Independent of the owner's verification: still required for a + // foreign company even if the owner also verifies with Fayda. + if (dto.ownerPassportNumber !== undefined) + attrUpdates.ownerPassportNumber = dto.ownerPassportNumber; + + // A verified identity overwrites the person's details. `faydaIdentity` + // never comes off the wire — the global validation pipe runs with + // forbidNonWhitelisted, so a client that sends it is rejected outright; it + // only reaches here from completeIdentityVerification, directly or through + // a staged snapshot. + if (dto.faydaIdentity) { + Object.assign(attrUpdates, dto.faydaIdentity); + } + + // companyEmail/companyPhone are the Company-column mirrors of the owner's + // verified contact details (the portal derives and submits them, it never + // 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. + 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.", + ); + } + } + + // Renaming a Fayda-verified person by hand would launder the guarantee + // away, so the fields the verification owns are refused once it exists. + for (const subject of ["owner", "poa"] as IdentitySubject[]) { + 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.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.`, + ); + } + } + } + companyUpdates.attributes = attrUpdates; return companyUpdates; } @@ -702,6 +855,21 @@ export class CompaniesService { ): Promise { const { profile, company } = await this.getCompanyInfoByUserId(userId); + await this.assertEtradeFieldsAuthentic(company, dto); + + // Naming (or renaming) a Power of Attorney is one of the writes that can + // leave the company with a representative and nothing evidencing them, so + // it is gated here. Edits that don't touch the PoA are left alone — a + // company carrying legacy details must not be locked out of every other + // field until it produces a paper. + if (POA_ATTRIBUTES.some((k) => dto[k] !== undefined)) { + const attributes = this.mapProfileDtoToCompanyUpdates(company, dto) + .attributes as Record; + await this.assertPoaDelegationSatisfied(company.id, attributes, { + requirePoa: await this.isFreightForwarder(company.id), + }); + } + if (company.status !== CompanyStatus.Active) { await this.assertTinAvailable(company, dto.tin); const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, dto); @@ -1127,11 +1295,24 @@ export class CompaniesService { // blacklist skip all this — staff must always be able to act against a bad // account. return this.dataSource.transaction(async (manager) => { - await manager.findOne(Company, { + const company = await manager.findOne(Company, { where: { id: existing.companyId }, lock: { mode: "pessimistic_write" }, }); + // Putting a forwarder into service without a Power of Attorney backed by + // a DARS paper is the thing EDRFREIGHT-358 forbids, so the approval is + // the last place it has to be checked — the role may have been applied + // for before the paper was withdrawn. + if (company && existing.type === ProfileType.freightForwarder) { + this.assertIdentityVerified(company, { requirePoa: true }); + await this.assertPoaDelegationSatisfied( + company.id, + company.attributes, + { requirePoa: true }, + ); + } + const [companyDocs, profileDocs] = await Promise.all([ this.filesService.findWithOpenChangeRequest( [existing.companyId], @@ -1382,6 +1563,18 @@ export class CompaniesService { ); if (existing) continue; + // A forwarder signs on other companies' behalf, so it cannot be taken on + // without a Power of Attorney and its DARS paper — checked here so the + // customer is told at the point of asking, not at review. + if (type === ProfileType.freightForwarder) { + this.assertIdentityVerified(company, { requirePoa: true }); + await this.assertPoaDelegationSatisfied( + companyId, + await this.effectivePoaAttributes(company), + { requirePoa: true }, + ); + } + // Self-service role adds start Pending and carry no reference — a reference // is minted only when a backoffice reviewer approves the role. await this.companyProfilesRepo.create({ @@ -1419,6 +1612,14 @@ export class CompaniesService { } let created = await this.companyProfilesRepo.findByType(companyId, type); + if (!created && type === ProfileType.freightForwarder) { + this.assertIdentityVerified(company, { requirePoa: true }); + await this.assertPoaDelegationSatisfied( + companyId, + await this.effectivePoaAttributes(company), + { requirePoa: true }, + ); + } if (!created) { // New self-service roles start Pending (awaiting backoffice approval) and // carry no reference until approved. @@ -1453,11 +1654,17 @@ export class CompaniesService { userId: string, ): Promise { const { profile, company } = await this.getCompanyInfoByUserId(userId); + const identity = this.getCompanyIdentityState(company); - // 1. Required company-information fields. - const missingInfo = this.REQUIRED_COMPANY_INFO.filter( - (f) => !f.get(company), - ).map((f) => ({ key: f.key, label: f.label })); + // 1. Required company-information fields. The FAN is never one of them — + // Fayda verification doesn't produce a FAN, so it's never collected as + // part of onboarding at all (see the identity block below). + const requiredInfo = this.REQUIRED_COMPANY_INFO.filter( + (f) => f.key !== "fanNumber", + ); + const missingInfo = requiredInfo + .filter((f) => !f.get(company)) + .map((f) => ({ key: f.key, label: f.label })); // 2. Nationality-based company documents + which are already uploaded. const documentSettingCode = this.documentSettingCodeFor(company.nationality); @@ -1504,26 +1711,31 @@ export class CompaniesService { // 4. Power of Attorney. Optional in general, but a freight forwarder acts on // other companies' behalf so its PoA is mandatory. Either way, a PoA that - // has been entered must be evidenced by the delegation letter. + // has been entered must be evidenced by the DARS delegation paper — a legal + // requirement, so unlike the documents above it does not depend on the + // upload set carrying a field for it (see poa-delegation.constants.ts). const poaRequired = (company.companyProfiles ?? []).some( (p) => p.type === ProfileType.freightForwarder, ); const poaProvided = POA_ATTRIBUTES.some((k) => (company.attributes?.[k] as string | undefined)?.trim(), ); - const missingPoaFields = poaRequired - ? REQUIRED_POA_FIELDS.filter( - (f) => !(company.attributes?.[f.key] as string | undefined)?.trim(), - ) - : []; - // Only gate on the letter once the document set actually carries the field. - const delegationField = (setting?.fields ?? []).find( - (f) => f.fileKey === POA_DELEGATION_FILE_KEY, - ); - const missingDelegation = - Boolean(delegationField) && - (poaRequired || poaProvided) && - !uploadedCodes.has(POA_DELEGATION_FILE_KEY); + // An Ethiopian company does not type its PoA details at all — they arrive + // from the Fayda verification — so reporting them as missing fields would + // ask for something the form no longer offers. The identity block below + // reports "verify your PoA" instead. + const missingPoaFields = + poaRequired && !identity.faydaRequired + ? REQUIRED_POA_FIELDS.filter( + (f) => !(company.attributes?.[f.key] as string | undefined)?.trim(), + ) + : []; + const delegation = await this.getPoaDelegationState(company.id); + const delegationDue = poaRequired || poaProvided; + const missingDelegation = delegationDue && !delegation.onFile; + // A paper the reviewer sent back is not evidence — the customer has to + // replace it before the application counts as complete. + const flaggedDelegation = delegationDue && delegation.flagged; const outstanding = [ ...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`), @@ -1534,29 +1746,62 @@ export class CompaniesService { ), ...missingPoaFields.map((f) => `Add your ${f.label.toLowerCase()}`), ...(missingDelegation - ? ["Upload the delegation letter for your Power of Attorney"] + ? [`Upload the ${POA_DELEGATION_LABEL} for your Power of Attorney`] + : []), + ...(flaggedDelegation + ? [`Re-upload your ${POA_DELEGATION_LABEL} — EDR asked for a correction`] + : []), + ...(identity.faydaRequired && !identity.owner.verified + ? ["Verify the company owner's identity with Fayda"] + : []), + ...(identity.faydaRequired && + (poaRequired || poaProvided) && + !identity.poa.verified + ? ["Verify your Power of Attorney's identity with Fayda"] + : []), + ...(identity.passportRequired && !identity.owner.passportNumber + ? ["Add the company owner's passport number"] : []), ]; // Progress spans every required item the user has to satisfy: company-info // fields, required documents, one license per operational profile, and the - // PoA details/letter whenever those are mandatory. + // PoA details/paper whenever those are mandatory. const requiredDocCount = documents.filter((d) => d.isRequired).length; const poaItemCount = - (poaRequired ? REQUIRED_POA_FIELDS.length : 0) + - (delegationField && (poaRequired || poaProvided) ? 1 : 0); + (poaRequired && !identity.faydaRequired + ? REQUIRED_POA_FIELDS.length + : 0) + (delegationDue ? 1 : 0); + // One item per identity credential the company has to prove: the owner + // always (Fayda for Ethiopian, passport for foreign), the PoA once there + // is one and Fayda is what's mandatory here. + const identityItemCount = identity.faydaRequired + ? delegationDue + ? 2 + : 1 + : identity.passportRequired + ? 1 + : 0; + const missingIdentityCount = identity.faydaRequired + ? (identity.owner.verified ? 0 : 1) + + (delegationDue && !identity.poa.verified ? 1 : 0) + : identity.passportRequired && !identity.owner.passportNumber + ? 1 + : 0; const total = - this.REQUIRED_COMPANY_INFO.length + + requiredInfo.length + requiredDocCount + licenseProfiles.length + - poaItemCount; + poaItemCount + + identityItemCount; const completed = total - (missingInfo.length + missingDocs.length + missingLicenses.length + missingPoaFields.length + - (missingDelegation ? 1 : 0)); + (missingDelegation || flaggedDelegation ? 1 : 0) + + missingIdentityCount); return new OnboardingRequirementsResponseDto({ documentSettingCode, @@ -1567,10 +1812,15 @@ export class CompaniesService { poa: { required: poaRequired, provided: poaProvided, - delegationLetterUploaded: uploadedCodes.has(POA_DELEGATION_FILE_KEY), + delegationLetterUploaded: delegation.onFile, + delegationLetterFlagged: delegation.flagged, missingFields: missingPoaFields, - complete: missingPoaFields.length === 0 && !missingDelegation, + complete: + missingPoaFields.length === 0 && + !missingDelegation && + !flaggedDelegation, }, + identity, progress: { completed, total }, isComplete: outstanding.length === 0, onboardingCompleted: profile.onboardingCompleted, @@ -2044,15 +2294,348 @@ export class CompaniesService { } // --------------------------------------------------------------------------- - // Power of Attorney delegation letter + // Power of Attorney delegation paper (DARS) // // A company-level document that follows the same staged-review model as the // business license: on an approved (Active) company an upload lands under the - // pending code and the live letter is flagged for removal, so the reviewer + // pending code and the live paper is flagged for removal, so the reviewer // sees both and approval swaps them atomically. During onboarding it goes live. // --------------------------------------------------------------------------- - /** The company's PoA letter(s), with each file's review status resolved. */ + /** + * What the company has on file towards its DARS delegation paper. A paper + * staged for review counts as "on file" — it is the customer's whole + * obligation discharged; whether it is good enough is the reviewer's call, + * recorded as `flagged`. + */ + private async getPoaDelegationState( + companyId: string, + ignoreFileIds: string[] = [], + ): Promise<{ onFile: boolean; flagged: boolean }> { + const records = ( + await this.filesService.findByResource(companyId, COMPANY_RESOURCE) + ).filter( + (r) => + (r.code === POA_DELEGATION_FILE_KEY || + r.code === POA_DELEGATION_PENDING_CODE) && + !ignoreFileIds.includes(r.id), + ); + return { + onFile: records.length > 0, + flagged: records.some((r) => r.reviewStatus === "change_requested"), + }; + } + + /** + * The rule behind EDRFREIGHT-358: a company that names a Power of Attorney + * must evidence it with a DARS delegation paper, and a freight forwarder — + * which signs on other companies' behalf — must have both, verified. + * + * This is enforced at every write that can break the pairing (PoA details + * saved, paper removed, forwarder role applied for or approved) rather than + * only at onboarding submission, which is what let a company that finished + * onboarding as an importer pick up the forwarder role with neither. + * + * `attributes` is the state being written, which is not always the state on + * the row yet — a staged change request carries it, and a removal has to be + * judged against the files that would survive it (`ignoreFileIds`). + */ + private async assertPoaDelegationSatisfied( + companyId: string, + attributes: Record | null | undefined, + opts: { requirePoa: boolean; ignoreFileIds?: string[] }, + ): Promise { + const read = (key: string) => + (attributes?.[key] as string | undefined)?.trim(); + const poaProvided = POA_ATTRIBUTES.some((k) => read(k)); + if (!opts.requirePoa && !poaProvided) return; + + if (opts.requirePoa) { + const missing = REQUIRED_POA_FIELDS.filter((f) => !read(f.key)); + if (missing.length > 0) { + throw new BadRequestException( + `A freight forwarder acts on other companies' behalf, so a Power of Attorney is required. ` + + `Add the ${missing.map((f) => f.label.toLowerCase()).join(", ")} first.`, + ); + } + } + + const { onFile, flagged } = await this.getPoaDelegationState( + companyId, + opts.ignoreFileIds, + ); + if (!onFile) { + throw new BadRequestException( + `Upload the ${POA_DELEGATION_LABEL} for the Power of Attorney` + + (opts.requirePoa ? " — it is required for freight forwarders." : "."), + ); + } + if (flagged) { + throw new BadRequestException( + `The ${POA_DELEGATION_LABEL} on file needs to be corrected. ` + + `Re-upload it before continuing.`, + ); + } + } + + /** Does this company operate as a freight forwarder? */ + private async isFreightForwarder(companyId: string): Promise { + const profiles = await this.companyProfilesRepo.findByCompanyId(companyId); + return profiles.some((p) => p.type === ProfileType.freightForwarder); + } + + // --------------------------------------------------------------------------- + // Fayda identity verification (owner / PoA) + // + // A completed VeriFayda verification proves a person's name, phone, email + // and address — Fayda's userinfo carries no national ID number, so none of + // that is collected here. For an Ethiopian company both the owner and its + // PoA (once named) must be verified before the company can trade. Fayda is + // an Ethiopian national ID system, so a foreign company's owner proves + // identity with a typed passport number instead — required on its own + // terms, not waived by an owner who happens to verify with Fayda too. + // --------------------------------------------------------------------------- + + /** + * Verification state for both people, plus whether it is mandatory here. + * `complete` answers the gate question directly so the portal, the onboarding + * requirements and the assertions below all read the same verdict — the + * derivation itself is shared with ProfileResponseDto. + */ + getCompanyIdentityState(company: Company): CompanyIdentityStateDto { + return buildCompanyIdentityState(company); + } + + /** + * Complete a Fayda verification and bind the identity to one of the company's + * people. The portal starts the flow through the shared + * `POST /fayda/verification/start` and only tells us which person it was for + * here, at completion — so the verifayda module stays generic and its session + * table needs no company-specific column. + */ + async completeIdentityVerification( + userId: string, + dto: CompleteIdentityVerificationDto, + ): Promise { + const { company } = await this.getCompanyInfoByUserId(userId); + const prefix = IDENTITY_PREFIX[dto.subject]; + + const result = await this.verifaydaService.completeVerification({ + code: dto.code, + state: dto.state, + }); + if (!result.verified || !result.sub) { + throw new BadRequestException( + "Fayda could not verify this identity. Start the verification again.", + ); + } + + // The owner delegating power of attorney to themselves is not a + // delegation — it would let one identity satisfy both halves of the check. + const other: IdentitySubject = dto.subject === "poa" ? "owner" : "poa"; + const otherSub = company.attributes?.[`${IDENTITY_PREFIX[other]}FaydaSub`]; + if (otherSub && otherSub === result.sub) { + throw new BadRequestException( + `This identity is already registered as the company's ${IDENTITY_LABEL[other]}. The Power of Attorney must be a different person from the owner.`, + ); + } + + const now = new Date().toISOString(); + const identity: VerifiedIdentityAttributes = { + [`${prefix}FaydaSub`]: result.sub, + [`${prefix}FaydaVerifiedAt`]: now, + [`${prefix}Birthdate`]: result.birthdate ?? null, + [`${prefix}Gender`]: result.gender ?? null, + // 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 } : {}), + ...(result.address ? { [`${prefix}Address`]: result.address } : {}), + }; + + // An approved company's profile edits are staged for backoffice review, and + // swapping the person who can act for the company is exactly the kind of + // edit that review exists for — so a verification lands the same way an + // ordinary edit does, rather than quietly rewriting a live record. + if (company.status === CompanyStatus.Active) { + await this.stageIdentityChange(company, userId, identity); + return this.getCompanyIdentityState(company); + } + + const updated = await this.companiesRepo.update(company.id, { + attributes: { ...(company.attributes ?? {}), ...identity }, + }); + if (!updated) + throw new NotFoundException(`Company ${company.id} not found`); + updated.companyProfiles = company.companyProfiles; + return this.getCompanyIdentityState(updated); + } + + /** + * Drop the Power of Attorney entirely — the verified identity, the details it + * wrote and the delegation paper together. + * + * Only the PoA can go: a company always has an owner, and a freight forwarder + * always has a representative. Once a PoA is Fayda-verified its + * fields are locked, so blanking the form is no longer a way out — without + * this the customer would be stuck with a representative they cannot remove. + */ + async removePoaIdentity(userId: string): Promise { + const { company } = await this.getCompanyInfoByUserId(userId); + if ( + (company.companyProfiles ?? []).some( + (p) => p.type === ProfileType.freightForwarder, + ) + ) { + throw new BadRequestException( + "A freight forwarder must have a Power of Attorney. Remove the freight forwarder role first.", + ); + } + + const cleared: Record = {}; + for (const key of [ + ...POA_ATTRIBUTES, + "poaFaydaSub", + "poaFaydaVerifiedAt", + "poaBirthdate", + "poaGender", + ]) { + cleared[key] = null; + } + const attributes = { ...(company.attributes ?? {}), ...cleared }; + + // The paper evidences a representative who no longer exists. + const records = await this.filesService.findByResource( + company.id, + COMPANY_RESOURCE, + ); + for (const r of records) { + if ( + r.code === POA_DELEGATION_FILE_KEY || + r.code === POA_DELEGATION_PENDING_CODE + ) { + await this.filesService.remove(r.id); + await this.withdrawDocumentIntent(company.id, r.id); + } + } + + const updated = await this.companiesRepo.update(company.id, { attributes }); + if (!updated) + throw new NotFoundException(`Company ${company.id} not found`); + updated.companyProfiles = company.companyProfiles; + return this.getCompanyIdentityState(updated); + } + + /** Stage a verified identity onto the company's pending change request. */ + private async stageIdentityChange( + company: Company, + userId: string, + identity: VerifiedIdentityAttributes, + ): Promise { + const existing = await this.changeRequestRepo.findPendingByCompanyId( + company.id, + ); + const now = new Date(); + const snapshot = { + ...(existing?.snapshot ?? {}), + faydaIdentity: { + ...(((existing?.snapshot ?? {}) as Record) + .faydaIdentity ?? {}), + ...identity, + }, + }; + if (existing) { + await this.changeRequestRepo.update(existing.id, { + snapshot, + submittedBy: userId, + submittedAt: now, + note: null, + }); + this.companyNotifier.changeRequestSubmitted(company, existing.id, false); + return; + } + const history = await this.changeRequestRepo.findByCompanyId(company.id); + const resubmitted = history.some( + (r) => r.status === ChangeRequestStatus.Rejected, + ); + const request = await this.changeRequestRepo.create({ + companyId: company.id, + snapshot, + status: ChangeRequestStatus.Pending, + submittedBy: userId, + submittedAt: now, + }); + this.companyNotifier.changeRequestSubmitted( + company, + request.id, + resubmitted, + ); + } + + /** + * The gate: an Ethiopian company's owner must be Fayda-verified, and so must + * its Power of Attorney once it has one; a foreign company's owner must carry + * a passport number instead. Called from the same places as + * `assertPoaDelegationSatisfied` — the two rules describe the same moment + * (who may act for this company, and on what evidence) and drifting them + * apart is how one of them ends up unenforced. + */ + private assertIdentityVerified( + company: Company, + opts: { requirePoa: boolean }, + ): void { + const state = buildCompanyIdentityState(company); + + if (state.passportRequired) { + if (!state.owner.passportNumber) { + throw new BadRequestException( + "Add the company owner's passport number before continuing.", + ); + } + return; + } + + if (!state.owner.verified) { + throw new BadRequestException( + "Verify the company owner's identity with Fayda before continuing.", + ); + } + + const poaNamed = POA_ATTRIBUTES.some((k) => + (company.attributes?.[k] as string | undefined)?.trim(), + ); + if (!opts.requirePoa && !poaNamed) return; + + if (!state.poa.verified) { + throw new BadRequestException( + opts.requirePoa + ? "Verify your Power of Attorney with Fayda — a freight forwarder cannot operate without one." + : "Verify the Power of Attorney you named with Fayda, or remove the representative.", + ); + } + } + + /** + * The PoA details the company is heading for: its live attributes with any + * pending change-request snapshot laid over them. An Active company's edits + * are staged rather than written, so the live row on its own would judge the + * customer against details they have already asked to change. + */ + private async effectivePoaAttributes( + company: Company, + ): Promise> { + const pending = await this.changeRequestRepo.findPendingByCompanyId( + company.id, + ); + const snapshot = (pending?.snapshot ?? {}) as Record; + const staged: Record = {}; + for (const key of POA_ATTRIBUTES) { + if (key in snapshot) staged[key] = snapshot[key]; + } + return { ...(company.attributes ?? {}), ...staged }; + } + + /** The company's PoA paper(s), with each file's review status resolved. */ async listPoaDelegationFiles( userId: string, ): Promise { @@ -2149,6 +2732,18 @@ export class CompaniesService { throw new NotFoundException(`Delegation letter ${fileId} not found`); } + // Taking the paper away is the other half of the pairing: allowed only once + // the representative it evidences is gone too (which, for an Active + // company, means the clearing edit is already staged). + await this.assertPoaDelegationSatisfied( + company.id, + await this.effectivePoaAttributes(company), + { + requirePoa: await this.isFreightForwarder(company.id), + ignoreFileIds: [fileId], + }, + ); + if (record.code === POA_DELEGATION_PENDING_CODE) { await this.filesService.remove(fileId); await this.withdrawDocumentIntent(company.id, fileId); @@ -2328,7 +2923,10 @@ export class CompaniesService { return match?.id ?? null; } - async fetchETradeData(tin: string) { + /** Resolve a TIN's live eTrade registration data. Throws when eTrade has no matching business licence. */ + private async resolveEtradeRegistration( + tin: string, + ): Promise { const { businessInfo, companyInfo } = await this.etradeService.resolveCompanyData(tin); if (!businessInfo) { @@ -2336,11 +2934,71 @@ export class CompaniesService { "We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.", ); } - const registrationData = this.etradeService.extractRegistrationData( - businessInfo, - companyInfo, + return this.etradeService.extractRegistrationData(businessInfo, companyInfo); + } + + async fetchETradeData(tin: string, excludeCompanyId?: string) { + const registrationData = await this.resolveEtradeRegistration(tin); + const tinTaken = await this.companiesRepo.existsByTin( + tin, + excludeCompanyId, ); - const tinTaken = await this.companiesRepo.existsByTin(tin); return { ...registrationData, tinTaken }; } + + /** + * An eTrade-sourced field can only ever hold what a fresh eTrade lookup for + * this TIN actually returns — the portal never lets the customer type these + * once eTrade has supplied them, so a mismatch here means either stale + * client state or a hand-crafted request, and either way the write is + * refused rather than silently trusting it. + */ + private async assertEtradeFieldsAuthentic( + company: Company, + dto: UpdateProfileDto, + ): Promise { + const touched = ETRADE_SOURCED_FIELDS.some( + (key) => dto[key] !== undefined, + ); + if (!touched) return; + + const tin = dto.tin ?? company.tin; + const registration = await this.resolveEtradeRegistration(tin); + const expected: Partial> = { + companyName: registration.companyName, + licenceNumber: registration.licenceNumber, + statusDescription: registration.statusDescription, + dateRegistered: registration.dateRegistered, + renewedFrom: registration.renewedFrom, + renewalDate: registration.renewalDate, + renewedTo: registration.renewedTo, + region: registration.region, + zone: registration.zone, + woreda: registration.woreda, + kebele: registration.kebele, + houseNo: registration.houseNo, + etradePhone: + registration.managerPhone || + registration.regularPhone || + registration.mobilePhone, + }; + + for (const key of ETRADE_SOURCED_FIELDS) { + const submitted = dto[key]; + if (submitted === undefined) continue; + const source = expected[key]; + // eTrade left this field blank — the onboarding/settings card falls back + // to letting the customer type it directly, so nothing to check against. + if (!source) continue; + const same = + key === "etradePhone" + ? normalizeE164(String(submitted)) === normalizeE164(source) + : submitted === source; + if (!same) { + throw new BadRequestException( + `${key} doesn't match eTrade's current record for this TIN. Re-verify with eTrade to pick up the latest details.`, + ); + } + } + } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts new file mode 100644 index 000000000..6e7468c8a --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts @@ -0,0 +1,152 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsIn, IsString, IsNotEmpty } from "class-validator"; + +import { Company, CompanyNationality } from "../entities/company.entity"; +import { ProfileType } from "../entities/company-profile.entity"; + +/** + * The two people a company is verified through — its owner and its Power of + * Attorney. "Owner" is not the same as the General Manager: a company's GM is + * a plain typed role (with a "same as owner" copy the portal offers), while + * the owner is the person this verification proves. They're very often the + * same human, which is exactly what the copy is for. + */ +export const IDENTITY_SUBJECTS = ["owner", "poa"] as const; +export type IdentitySubject = (typeof IDENTITY_SUBJECTS)[number]; + +export class CompleteIdentityVerificationDto { + @ApiProperty({ + enum: IDENTITY_SUBJECTS, + description: "Which of the company's people this verification is for.", + }) + @IsIn(IDENTITY_SUBJECTS) + subject!: IdentitySubject; + + @ApiProperty({ description: "Authorization code from the Fayda redirect." }) + @IsString() + @IsNotEmpty() + code!: string; + + @ApiProperty({ description: "CSRF state from the Fayda redirect." }) + @IsString() + @IsNotEmpty() + state!: string; +} + +/** One person's verification state, as reported back to the portal. */ +export class IdentityVerificationStateDto { + @ApiProperty() verified!: boolean; + @ApiProperty({ nullable: true }) name!: string | null; + @ApiProperty({ nullable: true }) phone!: string | null; + @ApiProperty({ nullable: true }) email!: string | null; + @ApiProperty({ nullable: true }) address!: string | null; + @ApiProperty({ nullable: true }) verifiedAt!: string | null; + @ApiProperty({ nullable: true }) birthdate!: string | null; + @ApiProperty({ nullable: true }) gender!: string | null; +} + +export class OwnerIdentityStateDto extends IdentityVerificationStateDto { + @ApiProperty({ + nullable: true, + description: + "Typed passport number — the foreign-company identity credential. Independent of Fayda: never written by a verification, and still required even if the owner also verifies.", + }) + passportNumber!: string | null; +} + +export class CompanyIdentityStateDto { + @ApiProperty({ + description: + "True when Fayda verification of the owner (and PoA, once named) is mandatory — Ethiopian companies only.", + }) + faydaRequired!: boolean; + + @ApiProperty({ + description: + "True when the owner's passport number is mandatory — foreign companies only. Independent of faydaRequired: a foreign owner may verify with Fayda too, but the passport is still required.", + }) + passportRequired!: boolean; + + @ApiProperty({ type: OwnerIdentityStateDto }) + owner!: OwnerIdentityStateDto; + + @ApiProperty({ type: IdentityVerificationStateDto }) + poa!: IdentityVerificationStateDto; + + @ApiProperty({ + description: + "False while a mandatory requirement (Fayda for Ethiopian, passport for foreign) is still outstanding.", + }) + complete!: boolean; +} + +/** `attributes` key prefix per person. */ +const PREFIX: Record = { + owner: "owner", + poa: "poa", +}; + +/** company.attributes keys that together mean "a PoA was entered". */ +const POA_KEYS = [ + "poaName", + "poaPhone", + "poaEmail", + "poaLocation", + "poaAddress", +] as const; + +function stateFor( + attrs: Record, + subject: IdentitySubject, +): IdentityVerificationStateDto { + const p = PREFIX[subject]; + const read = (key: string) => (attrs[key] as string | undefined) ?? null; + return { + verified: Boolean(read(`${p}FaydaSub`)), + name: read(`${p}Name`), + phone: read(`${p}Phone`), + email: read(`${p}Email`), + address: read(`${p}Address`), + verifiedAt: read(`${p}FaydaVerifiedAt`), + birthdate: read(`${p}Birthdate`), + gender: read(`${p}Gender`), + }; +} + +/** + * Derive both people's verification state from the company row. + * + * Pure and shared: `CompaniesService` gates on it and `ProfileResponseDto` + * renders from it, so the settings page and the onboarding wizard can never + * disagree with the rule the API actually enforces. + */ +export function buildCompanyIdentityState( + company: Company, +): CompanyIdentityStateDto { + const attrs = company.attributes ?? {}; + const read = (key: string) => (attrs[key] as string | undefined) ?? null; + + // Fayda is an Ethiopian national ID — a foreign company's owner may not hold + // one, so a typed passport number is the mandatory credential there instead. + // The two are mutually exclusive by nationality but independently tracked, + // since a foreign owner verifying with Fayda doesn't waive the passport. + const foreign = company.nationality === CompanyNationality.Foreign; + const faydaRequired = !foreign; + const passportRequired = foreign; + + const owner: OwnerIdentityStateDto = { + ...stateFor(attrs, "owner"), + passportNumber: read("ownerPassportNumber"), + }; + const poa = stateFor(attrs, "poa"); + const poaDue = + (company.companyProfiles ?? []).some( + (p) => p.type === ProfileType.freightForwarder, + ) || POA_KEYS.some((k) => (attrs[k] as string | undefined)?.trim()); + + const complete = faydaRequired + ? owner.verified && (!poaDue || poa.verified) + : !passportRequired || Boolean(owner.passportNumber); + + return { faydaRequired, passportRequired, owner, poa, complete }; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts index da908a177..a8d2f24a2 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts @@ -8,6 +8,8 @@ * truth the wizard uses to auto-finish. */ +import { CompanyIdentityStateDto } from "./complete-identity-verification.dto"; + export interface OnboardingInfoField { key: string; label: string; @@ -40,11 +42,13 @@ export interface OnboardingPoaState { required: boolean; /** True once any PoA detail has been entered. */ provided: boolean; - /** True when the delegation letter is stored for the company. */ + /** True when the DARS delegation paper is stored for the company. */ delegationLetterUploaded: boolean; + /** True when a reviewer sent the paper back for correction. */ + delegationLetterFlagged: boolean; /** PoA details still missing (only populated when `required`). */ missingFields: OnboardingInfoField[]; - /** False while the PoA step still owes details or a delegation letter. */ + /** False while the PoA step still owes details or an uncorrected paper. */ complete: boolean; } @@ -68,6 +72,13 @@ export class OnboardingRequirementsResponseDto { /** Power of Attorney state, so the wizard needn't re-derive the rule. */ poa: OnboardingPoaState; + /** + * Fayda verification state for the company's people. `required` is false for + * a foreign company, which is never gated on it — the portal renders the + * typed personnel forms in that case and the verify panels otherwise. + */ + identity: CompanyIdentityStateDto; + /** Overall setup progress across fields + documents + licenses. */ progress: { completed: number; total: number }; @@ -87,6 +98,7 @@ export class OnboardingRequirementsResponseDto { this.documents = init.documents; this.licenseProfiles = init.licenseProfiles; this.poa = init.poa; + this.identity = init.identity; this.progress = init.progress; this.isComplete = init.isComplete; this.onboardingCompleted = init.onboardingCompleted; diff --git a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts index 89ab954e7..6072268dc 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts @@ -1,3 +1,7 @@ +import { + buildCompanyIdentityState, + CompanyIdentityStateDto, +} from "./complete-identity-verification.dto"; import { Company } from '../entities/company.entity'; import { ExternalProfile } from '../entities/external-profile.entity'; import { @@ -52,6 +56,16 @@ export class ProfileResponseDto { profileId: string; + /** + * Fayda verification state for the company's owner and PoA — not the general + * manager, which is a separate typed role. The settings tabs and the + * onboarding wizard render from `identity.faydaRequired` / + * `identity.passportRequired`: an Ethiopian company verifies the owner (and + * PoA) instead of typing their details; a foreign one requires a typed + * passport number instead. + */ + identity: CompanyIdentityStateDto; + /** * Open profile-edit review, if any. `reviewStatus === "pending"` locks the * settings page; `"rejected"` surfaces the note and prefills the (declined) @@ -124,5 +138,6 @@ export class ProfileResponseDto { : null; this.reviewNote = openReview?.note ?? null; this.pendingChanges = openReview?.snapshot ?? null; + this.identity = buildCompanyIdentityState(company); } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts index a05812558..60f9f9a34 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts @@ -9,6 +9,10 @@ import { ProfileLicenseFileView, } from '../entities/company-profile.entity'; import { ResponseExternalProfileDto } from './response-external-profile.dto'; +import { + buildCompanyIdentityState, + CompanyIdentityStateDto, +} from './complete-identity-verification.dto'; export class ResponseCompanyProfileDto { id: string; @@ -69,6 +73,28 @@ export class ResponseCompanyDto { * external profiles weren't loaded. */ onboardingCompleted?: boolean; + + // eTrade-sourced registration record — populated by the onboarding TIN + // lookup, locked/read-only on the portal from the moment it's fetched. + licenceNumber?: string | null; + statusDescription?: string | null; + dateRegistered?: string | null; + renewedFrom?: string | null; + renewalDate?: string | null; + renewedTo?: string | null; + region?: string | null; + zone?: string | null; + woreda?: string | null; + kebele?: string | null; + houseNo?: string | null; + + /** + * Owner/PoA Fayda verification state, shared with the portal + * (`buildCompanyIdentityState`) so backoffice never re-derives — or + * disagrees with — the rule the API actually enforces. + */ + identity: CompanyIdentityStateDto; + createdAt: Date; updatedAt: Date; @@ -95,6 +121,18 @@ export class ResponseCompanyDto { ? company.profiles.length === 0 || company.profiles.some((p) => p.onboardingCompleted) : undefined; + this.licenceNumber = company.licenceNumber; + this.statusDescription = company.statusDescription; + this.dateRegistered = company.dateRegistered; + this.renewedFrom = company.renewedFrom; + this.renewalDate = company.renewalDate; + this.renewedTo = company.renewedTo; + this.region = company.region; + this.zone = company.zone; + this.woreda = company.woreda; + this.kebele = company.kebele; + this.houseNo = company.houseNo; + this.identity = buildCompanyIdentityState(company); this.createdAt = company.createdAt; this.updatedAt = company.updatedAt; } 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 ba3e27aeb..9f7d1ed39 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 @@ -44,10 +44,11 @@ export class UpdateProfileDto { @MaxLength(50) vatNumber?: string; - @IsOptional() - @IsString() - @MaxLength(16) - fanNumber?: string; + // `fanNumber` is deliberately absent: the FAN is the Fayda number of the + // company's PoA (or its general manager), so it is derived from a completed + // Fayda verification rather than typed. The global validation pipe runs with + // forbidNonWhitelisted, so a client that still sends it gets a 400 telling it + // so — see CompaniesService.completeIdentityVerification. @IsOptional() @IsString() @@ -110,6 +111,16 @@ export class UpdateProfileDto { @IsString() poaAddress?: string; + /** + * The owner's passport number — the identity credential for a foreign + * company, since Fayda is an Ethiopian national ID. Plain typed field, never + * written or locked by a Fayda verification: still required even if the + * owner also verifies. + */ + @IsOptional() + @IsString() + ownerPassportNumber?: string; + @IsOptional() @IsString() @MaxLength(100) diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts index 947bb5ffb..9651d4f37 100644 --- a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts +++ b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts @@ -15,6 +15,11 @@ import { FILE_UPLOAD_SETTINGS_REPOSITORY, IFileUploadSettingsRepository, } from "./interfaces/file-upload-settings.repository.interface"; +import { + COMPANY_ONBOARDING_CODE_PREFIX, + POA_DELEGATION_FILE_KEY, + poaDelegationField, +} from "./poa-delegation.constants"; @Injectable() export class FileUploadSettingsService { @@ -40,6 +45,22 @@ export class FileUploadSettingsService { async getByCode(code: string): Promise { const setting = await this.repository.findByCode(code); if (!setting) throw new NotFoundException(`Setting "${code}" not found`); + return this.withPoaDelegationField(setting); + } + + /** + * Company onboarding sets always carry the DARS delegation paper, whether or + * not anyone configured a row for it — see poa-delegation.constants.ts. Every + * consumer (the portal's PoA step, the onboarding gate) reads the set through + * here, so this is the single place the field can be guaranteed. + */ + private withPoaDelegationField(setting: FileUploadSetting): FileUploadSetting { + if (!setting.code.startsWith(COMPANY_ONBOARDING_CODE_PREFIX)) return setting; + const fields = setting.fields ?? []; + if (fields.some((f) => f.fileKey === POA_DELEGATION_FILE_KEY)) return setting; + + const lastOrder = fields.reduce((max, f) => Math.max(max, f.displayOrder), 0); + setting.fields = [...fields, poaDelegationField(lastOrder + 1)]; return setting; } diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/poa-delegation.constants.ts b/apps/edr-freight-api/src/modules/file-upload-settings/poa-delegation.constants.ts new file mode 100644 index 000000000..9085cc018 --- /dev/null +++ b/apps/edr-freight-api/src/modules/file-upload-settings/poa-delegation.constants.ts @@ -0,0 +1,52 @@ +import { FileUploadField } from "./entities/file-upload-field.entity"; + +/** + * The DARS delegation paper — the document that evidences a company's Power of + * Attorney (EDRFREIGHT-358). + * + * Every other onboarding document is admin-managed: the rows in + * `file_upload_fields` are edited from the backoffice file-settings editor and + * the seeder deliberately inserts none. This one is different — a company that + * names a PoA must produce a delegation paper authenticated by the Documents + * Authentication and Registration Service, and that is a legal requirement + * rather than a configuration choice. So the field is defined here in code and + * injected into the company onboarding sets on read: no row to forget to seed, + * and deleting one in the editor cannot silently switch the requirement off. + */ + +/** FileRecord `code` (and upload field key) of the live delegation paper. */ +export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter"; + +/** Code for a delegation paper staged in an open change request (not yet live). */ +export const POA_DELEGATION_PENDING_CODE = "poa_delegation_letter_pending"; + +/** Customer-facing name of the document, used by the API and both web apps. */ +export const POA_DELEGATION_LABEL = "DARS Delegation Paper"; + +/** Prefix of the setting codes the field is injected into. */ +export const COMPANY_ONBOARDING_CODE_PREFIX = "company_onboarding_documents_"; + +const POA_DELEGATION_HELP = + "Delegation paper issued by the Documents Authentication and Registration " + + "Service (DARS) delegating the representative named above. Upload the " + + "authenticated copy — a plain letter is not accepted."; + +/** + * The field descriptor. `isRequired` stays false because the paper is only due + * once a PoA has actually been named (or the company operates as a freight + * forwarder) — a rule that spans form fields as well as files, so it is + * enforced in CompaniesService rather than by this flag. + */ +export function poaDelegationField(displayOrder: number): FileUploadField { + return { + fileKey: POA_DELEGATION_FILE_KEY, + fileLabel: POA_DELEGATION_LABEL, + helpText: POA_DELEGATION_HELP, + isRequired: false, + isMultiple: false, + maxFiles: 1, + allowedExtensions: ["pdf", "jpg", "jpeg", "png"], + maxSizeMb: 10, + displayOrder, + } as FileUploadField; +} diff --git a/apps/edr-freight-api/src/modules/last-mile/dto/set-detention-times.dto.ts b/apps/edr-freight-api/src/modules/last-mile/dto/set-detention-times.dto.ts new file mode 100644 index 000000000..9f103e15b --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile/dto/set-detention-times.dto.ts @@ -0,0 +1,29 @@ +import { Type } from 'class-transformer'; +import { IsArray, IsDateString, IsOptional, IsUUID, ValidateNested } from 'class-validator'; + +/** + * One truck's detention window. Each truck reaches the destination and is + * released at its own time, so detention days differ between trucks on the + * same delivery. Null clears the value (falls back to the leg-level pair). + */ +export class TruckDetentionTimeInput { + @IsUUID() + vehicleId!: string; + + /** Detention clock start — this truck reached the destination. */ + @IsOptional() + @IsDateString() + destinationArrivedAt?: string | null; + + /** Detention clock end — this truck was released/returned. Omit = still out. */ + @IsOptional() + @IsDateString() + returnedAt?: string | null; +} + +export class SetDetentionTimesDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => TruckDetentionTimeInput) + trucks!: TruckDetentionTimeInput[]; +} diff --git a/apps/edr-freight-api/src/modules/last-mile/dto/set-warehouse-gate-times.dto.ts b/apps/edr-freight-api/src/modules/last-mile/dto/set-warehouse-gate-times.dto.ts new file mode 100644 index 000000000..ba980dfdc --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile/dto/set-warehouse-gate-times.dto.ts @@ -0,0 +1,22 @@ +import { Type } from 'class-transformer'; +import { IsArray, IsDateString, IsOptional, IsUUID, ValidateNested } from 'class-validator'; + +export class TruckWarehouseGateTimeInput { + @IsUUID() + vehicleId!: string; + + @IsOptional() + @IsDateString() + arrivedAt?: string | null; + + @IsOptional() + @IsDateString() + departedAt?: string | null; +} + +export class SetWarehouseGateTimesDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => TruckWarehouseGateTimeInput) + trucks!: TruckWarehouseGateTimeInput[]; +} diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts index e57c16b8a..f2b4e2467 100644 --- a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts @@ -51,6 +51,21 @@ export class LastMileVehicleAssignment extends BaseEntity { @Column({ name: 'departed_at', type: 'timestamptz', nullable: true }) departedAt?: Date | null; + /** + * Detention clock START for THIS truck: reached the delivery destination. + * Distinct from `arrivedAt` (warehouse gate-in). Null falls back to the + * leg-level `last_mile.arrived_at`. + */ + @Column({ name: 'destination_arrived_at', type: 'timestamptz', nullable: true }) + destinationArrivedAt?: Date | null; + + /** + * Detention clock END for THIS truck: released / returned by the customer. + * Null (with no leg-level `delivered_at`) means still out — detention accrues. + */ + @Column({ name: 'returned_at', type: 'timestamptz', nullable: true }) + returnedAt?: Date | null; + /** Weighed gross on exit, in TONNES (not kg — see the migration note). */ @Column({ name: 'gross_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true }) grossWeightTons?: number | null; diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts index 29e857e2f..d7e2ba1ff 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts @@ -23,6 +23,8 @@ import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto'; import { SetVehiclesDto } from './dto/set-vehicles.dto'; +import { SetDetentionTimesDto } from './dto/set-detention-times.dto'; +import { SetWarehouseGateTimesDto } from './dto/set-warehouse-gate-times.dto'; import { SetDistancesDto } from './dto/set-distances.dto'; import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto'; import { LastMileStatus } from './entities/last-mile.entity'; @@ -131,6 +133,30 @@ export class LastMileController { return this.lastMileService.setDistances(id, dto.distances, dto.remainingPayment); } + @Post(':id/detention-times') + @BookingStaff(FREIGHT_PERMS.lastMile.update) + @ApiOperation({ + summary: 'Set each truck\'s own detention window (arrived at destination / returned)', + }) + async setDetentionTimes( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SetDetentionTimesDto, + ) { + return this.lastMileService.setDetentionTimes(id, dto.trucks); + } + + @Post(':id/warehouse-gate-times') + @BookingStaff(FREIGHT_PERMS.lastMile.update) + @ApiOperation({ + summary: 'Set each truck\'s warehouse gate arrival/departure times', + }) + async setWarehouseGateTimes( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SetWarehouseGateTimesDto, + ) { + return this.lastMileService.setWarehouseGateTimes(id, dto.trucks); + } + @Post(':id/proof-of-delivery') @BookingStaff(FREIGHT_PERMS.lastMile.update) @UseInterceptors(AnyFilesInterceptor()) 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 601e03aec..af6c920fc 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 @@ -327,6 +327,8 @@ export class LastMileService { */ async arrivalTrucksForBooking(bookingId: string): Promise< Array<{ + /** The last-mile leg this truck belongs to — lets a caller chain straight into truck-detention-preview without a separate lookup. */ + lastMileId: string; vehicleId: string; truckPlateNumber: string | null; trailerPlateNumber: string | null; @@ -359,6 +361,7 @@ export class LastMileService { : []; const out: Array<{ + lastMileId: string; vehicleId: string; truckPlateNumber: string | null; trailerPlateNumber: string | null; @@ -386,6 +389,7 @@ export class LastMileService { } } out.push({ + lastMileId: lm.id, vehicleId: vehicle.id, truckPlateNumber: vehicle.powerPlateNo || vehicle.plateNumber || null, trailerPlateNumber: vehicle.trailerPlateNo || null, @@ -869,6 +873,81 @@ export class LastMileService { * sum and drives billing; `remainingPayment` (total km × rate) is recomputed * client-side. Does NOT generate an invoice — that's a separate explicit step. */ + /** + * Per-truck detention windows. Each truck reaches the destination and is + * released at its own time, so every truck gets its own clock (and therefore + * its own chargeable days). Locked once the detention invoice exists. + */ + async setDetentionTimes( + id: string, + trucks: Array<{ + vehicleId: string; + destinationArrivedAt?: string | null; + returnedAt?: string | null; + }>, + ): Promise { + await this.findById(id); + + const invoices = await this.billing.findBySourceIds('last_mile', [id]); + if (invoices.length) { + throw new BadRequestException( + 'Detention times cannot be changed after the invoice is generated', + ); + } + + for (const t of trucks) { + const start = t.destinationArrivedAt ? new Date(t.destinationArrivedAt) : null; + const end = t.returnedAt ? new Date(t.returnedAt) : null; + if (start && end && end.getTime() < start.getTime()) { + throw new BadRequestException( + 'A truck cannot be returned before it arrived — check the detention times', + ); + } + await this.dataSource.manager.update( + LastMileVehicleAssignment, + { lastMileId: id, vehicleId: t.vehicleId }, + { destinationArrivedAt: start, returnedAt: end }, + ); + } + + return this.findById(id); + } + + async setWarehouseGateTimes( + id: string, + trucks: Array<{ + vehicleId: string; + arrivedAt?: string | null; + departedAt?: string | null; + }>, + ): Promise { + await this.findById(id); + + const invoices = await this.billing.findBySourceIds('last_mile', [id]); + if (invoices.length) { + throw new BadRequestException( + 'Warehouse gate times cannot be changed after the invoice is generated', + ); + } + + for (const t of trucks) { + const arrived = t.arrivedAt ? new Date(t.arrivedAt) : null; + const departed = t.departedAt ? new Date(t.departedAt) : null; + if (arrived && departed && departed.getTime() < arrived.getTime()) { + throw new BadRequestException( + 'A truck cannot depart before it arrived — check the warehouse gate times', + ); + } + await this.dataSource.manager.update( + LastMileVehicleAssignment, + { lastMileId: id, vehicleId: t.vehicleId }, + { arrivedAt: arrived, departedAt: departed }, + ); + } + + return this.findById(id); + } + async setDistances( id: string, distances: Array<{ vehicleId: string; distanceKm: number }>, diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts index 294fb93f1..b0cbeb886 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts @@ -39,7 +39,11 @@ export class TrainSchedulesRepository extends BaseRepository { physicalWagon: true, allocations: { booking: { company: true, bookingContainers: { containerType: true } }, - containerItems: true, + // Both size sources loaded: the item's own container_type_id FK + // (always set for a manually-entered item) and the booking-line + // fallback via bookingContainer.containerType — the marshalling + // document's 40ft/20ft tally reads whichever is present. + containerItems: { containerType: true, bookingContainer: { containerType: true } }, }, }, }, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/facility-handling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/facility-handling.service.ts index c0e0b7c5f..4fe20dbf5 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/facility-handling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/facility-handling.service.ts @@ -48,10 +48,12 @@ export class FacilityHandlingService { if (!facility?.hasFacility) return null; const occurredAt = input.occurredAt ?? new Date(); + // Mapped to the goods owner, same as every warehouse-raised GRN. const grnNumber = generateGrnNumber( booking.tradeDirection ?? 'DOMESTIC', booking.id, occurredAt, + booking.company?.name ?? null, ); // Link the storage record when this facility keeps cargo — that link is @@ -67,6 +69,21 @@ export class FacilityHandlingService { inventoryId = inv?.id ?? null; } + // The handed-over weight: the booking's declared VGM, else what its + // containers actually carry. A GRN without a weight is not a receipt. + let weightTons = Number(booking.cargoTotalWeightVgm) || null; + if (!weightTons) { + const [sum]: Array<{ tons: string | null }> = await manager.query( + `SELECT SUM(bcu.vgm_tons) AS tons + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL`, + [booking.id], + ); + weightTons = Number(sum?.tons) || null; + } + const repo = manager.getRepository(FacilityHandlingEvent); await repo.save( repo.create({ @@ -75,7 +92,7 @@ export class FacilityHandlingService { trainScheduleId: input.trainScheduleId ?? null, eventType, grnNumber, - weightTons: Number(booking.cargoTotalWeightVgm) || null, + weightTons, inventoryId, performedBy: input.performedBy ?? null, occurredAt, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 3779876fa..658ea7c78 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -2784,11 +2784,13 @@ export class TrainSchedulingService { allocations: (wagon.allocations ?? []).map((allocation) => ({ bookingId: allocation.bookingId, bookingReference: allocation.booking?.reference ?? null, + booking: allocation.booking, loadType: allocation.loadType ?? null, allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0, containerNumbers: (allocation.containerItems ?? []) .map((item) => item.containerNumber) .filter(Boolean), + containerItems: allocation.containerItems ?? [], })), })), operation: await this.getImportDjiboutiOperation(schedule.id), @@ -2829,6 +2831,32 @@ export class TrainSchedulingService { }; } + /** + * A container item's size in feet, for the marshalling document's 40ft/20ft + * tally. Two independent sources, since only one is populated depending on + * how the item was created: + * - `item.containerType` — the item's own container_type_id FK, set for + * manually-entered items (no booking-container line behind them). + * - `item.bookingContainer.containerType.sizeFt` / `.containerSize` — the + * booking-line fallback for items generated from an allocation. + * (`findByIdWithFullGraph` must load both relations or every item here + * silently resolves to null and the tally stays zero.) + */ + private resolveContainerItemSize(item: { + containerType?: { sizeFt?: number | null } | null; + bookingContainer?: { + containerSize?: string | null; + containerType?: { sizeFt?: number | null } | null; + } | null; + }): number | null { + const fromSizeFt = item.containerType?.sizeFt ?? item.bookingContainer?.containerType?.sizeFt; + if (fromSizeFt === 20 || fromSizeFt === 40) return fromSizeFt; + const label = item.bookingContainer?.containerSize; + if (label?.includes('40')) return 40; + if (label?.includes('20')) return 20; + return null; + } + private buildExportLoadListHtml(schedule: TrainSchedule): string { const esc = (value: unknown) => String(value ?? '-') @@ -2869,6 +2897,7 @@ export class TrainSchedulingService { return allocations.map((allocation) => { const booking = allocation.booking ?? bookingById.get(allocation.bookingId); const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType; + const companyName = (booking as unknown as { company?: { name?: string } } | undefined)?.company?.name ?? '-'; const containerItems = allocation.containerItems ?? []; const firstContainer = containerItems[0]; const containerNumbers = containerItems.map((item) => item.containerNumber).filter(Boolean).join(', '); @@ -2877,6 +2906,7 @@ export class TrainSchedulingService { return ` ${wagonCells} ${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)} + ${esc(companyName)} ${esc(containerNumbers || firstContainer?.containerNumber)} ${esc(chassisNumbers)} ${esc(sealNumbers)} @@ -2891,6 +2921,18 @@ export class TrainSchedulingService { 0, ); + // Container count summary (40ft, 20ft) + let count40ft = 0, count20ft = 0; + wagons.forEach((wagon) => { + (wagon.allocations ?? []).forEach((allocation) => { + (allocation.containerItems ?? []).forEach((item) => { + const size = this.resolveContainerItemSize(item); + if (size === 40) count40ft++; + else if (size === 20) count20ft++; + }); + }); + }); + return ` @@ -2940,6 +2982,9 @@ export class TrainSchedulingService {
Departure station${esc(schedule.originStation?.label ?? schedule.originStation?.code)}
Arrival station${esc(schedule.destinationStation?.label ?? schedule.destinationStation?.code)}
Total loaded weight${esc(totalWeight.toFixed(3))} T
+
Containers 40ft${esc(count40ft)}
+
Containers 20ft${esc(count20ft)}
+
Total containers${esc(count40ft + count20ft)}
Prepared person${esc(schedule.preparedByUserId)}
Check person${esc(schedule.checkedByUserId)}
Wagons${esc(wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}
@@ -2958,6 +3003,7 @@ export class TrainSchedulingService { Tare Weight Load Capacity Cargo Type + Company Container No Chassis No Seal No @@ -3040,6 +3086,19 @@ export class TrainSchedulingService { 0, ); const emptyWagons = loadList.wagons.filter((wagon) => wagon.allocations.length === 0).length; + + // Container count summary (40ft, 20ft) + let count40ft = 0, count20ft = 0; + loadList.wagons.forEach((wagon) => { + wagon.allocations.forEach((allocation) => { + (allocation.containerItems ?? []).forEach((item) => { + const size = this.resolveContainerItemSize(item); + if (size === 40) count40ft++; + else if (size === 20) count20ft++; + }); + }); + }); + const allocationRows = loadList.wagons .flatMap((wagon) => { const wagonCells = `${esc(wagon.sequenceNo)} @@ -3055,13 +3114,17 @@ export class TrainSchedulingService { ]; } return wagon.allocations.map( - (allocation) => ` + (allocation) => { + const companyName = (allocation.booking as unknown as { company?: { name?: string } } | undefined)?.company?.name ?? '-'; + return ` ${wagonCells} ${esc(allocation.bookingReference ?? allocation.bookingId)} + ${esc(companyName)} ${esc(allocation.loadType)} ${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')} ${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))} - `, + `; + }, ); }) .join(''); @@ -3126,6 +3189,9 @@ export class TrainSchedulingService {
Wagons${esc(loadList.wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}
Allocations${esc(totalAllocations)}
Total weight${esc(totalWeight.toFixed(3))} T
+
Containers 40ft${esc(count40ft)}
+
Containers 20ft${esc(count20ft)}
+
Total containers${esc(count40ft + count20ft)}
Gatepass granted${esc(date(loadList.operation.gatepassGrantedAt))}
@@ -3145,6 +3211,7 @@ export class TrainSchedulingService { Seq Wagon Booking + Company Load Container numbers Weight T diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.dto.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.dto.ts index 1885b11e9..f9ffe04cb 100644 --- a/apps/edr-freight-api/src/modules/verifayda/verifayda.dto.ts +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.dto.ts @@ -13,14 +13,14 @@ export class StartVerificationDto { purpose?: 'LOGIN' | 'VERIFY'; @ApiPropertyOptional({ - enum: ['WEB', 'MOBILE'], + enum: ['WEB', 'MOBILE', 'PORTAL'], default: 'WEB', description: - 'Client platform. Selects which OAuth redirect_uri is sent to eSignet: WEB uses FAYDA_WEB_REDIRECT_URI, MOBILE uses FAYDA_REDIRECT_URI. Both land on the same /complete endpoint with identical handling.', + 'Client platform. Selects which OAuth redirect_uri is sent to eSignet: WEB (backoffice) uses FAYDA_WEB_REDIRECT_URI, PORTAL uses FAYDA_PORTAL_REDIRECT_URI, MOBILE uses FAYDA_REDIRECT_URI. All land on the same /complete handling.', }) @IsOptional() - @IsIn(['WEB', 'MOBILE']) - platform?: 'WEB' | 'MOBILE'; + @IsIn(['WEB', 'MOBILE', 'PORTAL']) + platform?: 'WEB' | 'MOBILE' | 'PORTAL'; @ApiPropertyOptional({ type: Boolean, @@ -57,6 +57,12 @@ export class CompleteVerificationResultDto { agentId?: string; }; + @ApiPropertyOptional({ + description: + 'Fayda OIDC subject — the stable key a verified identity is stored under (VERIFY flow). Pairwise pseudonymous.', + }) + sub?: string; + @ApiPropertyOptional({ description: 'Verified full name from Fayda (VERIFY flow).' }) fullName?: string; @@ -74,6 +80,11 @@ export class CompleteVerificationResultDto { @ApiPropertyOptional({ description: 'Verified gender from Fayda (VERIFY flow).' }) gender?: string; + @ApiPropertyOptional({ + description: 'Verified address from Fayda, English rendering (VERIFY flow).', + }) + address?: string; + @ApiPropertyOptional({ description: 'Whether the verified identity was saved to IAM. False if the IAM write failed.' }) userDataSaved?: boolean; diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts index 6e3e2e095..16441bd0d 100644 --- a/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts @@ -56,11 +56,15 @@ export interface CompleteVerificationResult { promptPasswordSetup?: boolean; iamUserId?: string; user?: FaydaUserSummary; + /** Fayda OIDC subject — the stable key a verified identity is stored under. */ + sub?: string; fullName?: string; email?: string; phoneNumber?: string; birthdate?: string; gender?: string; + /** Verified address, English rendering (falls back to Amharic). */ + address?: string; userDataSaved?: boolean; } @@ -125,11 +129,15 @@ export class VerifaydaService { }); } - /** WEB clients use `webRedirectUri`; MOBILE uses the base `redirectUri`. */ + /** + * Each client lands on its own registered redirect_uri: MOBILE on the base + * one, the customer portal on its own origin, everything else (backoffice) on + * the web one. All three must be registered with eSignet. + */ private redirectUriForPlatform(platform?: FaydaPlatform): string { - return platform === 'MOBILE' - ? this.faydaConfig.redirectUri - : this.faydaConfig.webRedirectUri; + if (platform === 'MOBILE') return this.faydaConfig.redirectUri; + if (platform === 'PORTAL') return this.faydaConfig.portalRedirectUri; + return this.faydaConfig.webRedirectUri; } async completeVerification( @@ -210,11 +218,13 @@ export class VerifaydaService { result = { purpose: 'VERIFY', verified: true, + sub: normalized.sub, fullName: normalized.fullName, email: normalized.email, phoneNumber: normalized.phoneNumber, birthdate: normalized.birthdate, gender: normalized.gender, + address: normalized.addressEn ?? normalized.addressAm, userDataSaved, iamUserId: iamUserId ?? undefined, token: sessionToken?.token, diff --git a/apps/edr-freight-api/src/modules/warehouses/double-handling-gate.spec.ts b/apps/edr-freight-api/src/modules/warehouses/double-handling-gate.spec.ts new file mode 100644 index 000000000..836a3bc4c --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/double-handling-gate.spec.ts @@ -0,0 +1,61 @@ +import { WarehouseFeeService } from './warehouse-fee.service'; + +/** + * Double handling bills ONLY when warehouse staff answered Yes after + * unloading. Undecided (null) or No must produce a zero charge even when a + * matching DOUBLE_HANDLING_FEE rule exists. + */ +type Item = Parameters extends unknown + ? Record + : never; + +const svc = Object.create(WarehouseFeeService.prototype) as { + computeDoubleHandling: ( + rule: Record | null, + item: Item, + now: Date, + billingCurrency: string, + ) => Promise<{ amount: number; billableUnits: number }>; + normalizeCurrency: (c?: string | null) => string; + convertAmount: (a: number, from: string, to: string) => Promise; + resolveBulkQuantity: (item: Item) => { quantity: number; unitLabel: string }; +}; +// No exchange service on a bare prototype — bill in the rule's own currency. +svc.normalizeCurrency = (c) => (c ? String(c).toUpperCase() : 'USD'); +svc.convertAmount = async (a) => a; + +const rule = { basis: 'PER_CONTAINER', ratePerDay: 100, currency: 'USD', id: 'r1', name: 'DH' }; +const item = (doubleHandling: boolean | null) => ({ + tradeDirection: 'IMPORT', + freightType: 'CONTAINER', + inventoryQuantity: 2, + bookingContainerCount: 3, + inventoryWeight: 10, + cargoUnitOfMeasure: 'PER_TON', + doubleHandling, +}) as unknown as Item; + +describe('double handling gate', () => { + it('bills rate x containers when the booking is flagged Yes', async () => { + const out = await svc.computeDoubleHandling(rule, item(true), new Date(), 'USD'); + expect(out.billableUnits).toBe(3); + expect(out.amount).toBe(300); + }); + + it('charges nothing when the answer is No', async () => { + const out = await svc.computeDoubleHandling(rule, item(false), new Date(), 'USD'); + expect(out.billableUnits).toBe(0); + expect(out.amount).toBe(0); + }); + + it('charges nothing while the answer is undecided', async () => { + const out = await svc.computeDoubleHandling(rule, item(null), new Date(), 'USD'); + expect(out.amount).toBe(0); + }); + + it('charges nothing for export even when flagged Yes', async () => { + const exportItem = { ...(item(true) as Record), tradeDirection: 'EXPORT' } as Item; + const out = await svc.computeDoubleHandling(rule, exportItem, new Date(), 'USD'); + expect(out.amount).toBe(0); + }); +}); diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts index 56b9d0810..39a90ef8e 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts @@ -1,7 +1,12 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; +import { IsArray, IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; -import { WAREHOUSE_YARD_TYPES, WarehouseYardType } from '../entities/warehouse-yard.entity'; +import { + WAREHOUSE_YARD_DIRECTIONS, + WAREHOUSE_YARD_TYPES, + WarehouseYardDirection, + WarehouseYardType, +} from '../entities/warehouse-yard.entity'; export class CreateWarehouseYardDto { @ApiPropertyOptional({ format: 'uuid', description: 'Optional — taken from the route param when omitted' }) @@ -46,4 +51,22 @@ export class CreateWarehouseYardDto { @IsNumber() @Min(0) maxVolume?: number; + + @ApiPropertyOptional({ + enum: WAREHOUSE_YARD_DIRECTIONS, + description: 'Trade direction this yard serves. Only meaningful for CONTAINER_YARD — omit/BOTH for everything else.', + }) + @IsOptional() + @IsEnum(WAREHOUSE_YARD_DIRECTIONS) + direction?: WarehouseYardDirection; + + @ApiPropertyOptional({ + type: [String], + format: 'uuid', + description: 'Cargo types this yard accepts. Empty/omitted = open to any cargo type of this yard\'s structural type.', + }) + @IsOptional() + @IsArray() + @IsUUID('4', { each: true }) + cargoTypeIds?: string[]; } diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/double-handling.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/double-handling.dto.ts new file mode 100644 index 000000000..21e54589f --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/double-handling.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsBoolean } from 'class-validator'; + +/** + * Warehouse staff's post-unloading answer: did these goods have to be + * re-handled? Only `true` makes the DOUBLE_HANDLING_FEE rule bill the booking. + */ +export class SetDoubleHandlingDto { + @ApiProperty({ + description: 'Yes (true) applies the double-handling fee rule; No (false) does not.', + }) + @IsBoolean() + doubleHandling!: boolean; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-yard.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-yard.entity.ts index 6e3c93292..5169f486a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-yard.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-yard.entity.ts @@ -1,6 +1,7 @@ import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; +import { Column, Entity, Index, JoinColumn, JoinTable, ManyToMany, ManyToOne, OneToMany } from 'typeorm'; +import { CargoType } from '../../rule-engine/entities/cargo-type.entity'; import { Warehouse } from './warehouse.entity'; import { WarehouseZone } from './warehouse-zone.entity'; @@ -16,6 +17,15 @@ export type WarehouseYardType = (typeof WAREHOUSE_YARD_TYPES)[number]; export const WAREHOUSE_YARD_STATUSES = ['ACTIVE', 'INACTIVE'] as const; export type WarehouseYardStatus = (typeof WAREHOUSE_YARD_STATUSES)[number]; +/** + * Which trade direction this yard serves. Only meaningful for CONTAINER_YARD, + * where import and export stacks are physically separate areas (e.g. Indode's + * Yard 5 for import vs Yard 6 for export) — every other yard type takes cargo + * either way, so BOTH/null is the right default there. + */ +export const WAREHOUSE_YARD_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const; +export type WarehouseYardDirection = (typeof WAREHOUSE_YARD_DIRECTIONS)[number]; + @Entity({ schema: 'freight', name: 'warehouse_yards' }) @Index(['warehouseId']) @Index(['type']) @@ -64,6 +74,25 @@ export class WarehouseYard extends BaseEntity { @Column({ name: 'is_active', type: 'boolean', default: true }) isActive!: boolean; + /** Null = BOTH (no direction restriction). Only relevant for CONTAINER_YARD. */ + @Column({ name: 'direction', type: 'varchar', length: 10, nullable: true }) + direction?: WarehouseYardDirection | null; + + /** + * Cargo types this yard accepts — e.g. Yard 3 (Ro-Ro) takes Automobile/Truck, + * Yard 9 (Coffee and Tea) takes only those two. Empty/no rows = open to any + * cargo type of the yard's structural `type` (the pre-existing behavior), + * so this is additive and never blocks a yard that hasn't been configured. + */ + @ManyToMany(() => CargoType) + @JoinTable({ + name: 'warehouse_yard_cargo_types', + schema: 'freight', + joinColumn: { name: 'yard_id', referencedColumnName: 'id' }, + inverseJoinColumn: { name: 'cargo_type_id', referencedColumnName: 'id' }, + }) + cargoTypes?: CargoType[]; + @OneToMany(() => WarehouseZone, (zone) => zone.yard) zones?: WarehouseZone[]; } diff --git a/apps/edr-freight-api/src/modules/warehouses/per-truck-detention.spec.ts b/apps/edr-freight-api/src/modules/warehouses/per-truck-detention.spec.ts new file mode 100644 index 000000000..471b82965 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/per-truck-detention.spec.ts @@ -0,0 +1,82 @@ +import { WarehouseFeeService } from './warehouse-fee.service'; + +/** + * Detention is per truck: two trucks on the same delivery with different + * windows must produce different chargeable days and amounts (the old + * leg-level clock billed them identically). + */ +const HOUR = 60 * 60 * 1000; +const DAY = 24 * HOUR; + +const svc = Object.create(WarehouseFeeService.prototype) as { + computeTruckDetention: ( + rule: Record | null, + row: { arrivedAt: Date | string | null; deliveredAt: Date | string | null; truckCount: number }, + now: Date, + billingCurrency: string, + ) => Promise<{ chargeableDays: number; billableUnits: number; amount: number; endIsOpen: boolean }>; + normalizeCurrency: (c?: string | null) => string; + convertAmount: (a: number, from: string, to: string) => Promise; + calculateTieredAmount: unknown; +}; +svc.normalizeCurrency = (c) => (c ? String(c).toUpperCase() : 'USD'); +svc.convertAmount = async (a) => a; + +// 3h grace, 50/truck/day, no tiers. +const rule = { freeHours: 3, ratePerDay: 50, currency: 'USD', id: 'r1', name: 'Detention', tiers: [] }; +const now = new Date('2026-07-25T12:00:00Z'); + +describe('per-truck detention', () => { + it('bills each truck on its own window', async () => { + // Truck A: out ~1 day past grace. Truck B: out ~3 days past grace. + const a = await svc.computeTruckDetention( + rule, + { + arrivedAt: new Date(now.getTime() - DAY - 4 * HOUR), + deliveredAt: now, + truckCount: 1, + }, + now, + 'USD', + ); + const b = await svc.computeTruckDetention( + rule, + { + arrivedAt: new Date(now.getTime() - 3 * DAY - 4 * HOUR), + deliveredAt: now, + truckCount: 1, + }, + now, + 'USD', + ); + + expect(a.chargeableDays).toBe(2); + expect(b.chargeableDays).toBe(4); + expect(a.amount).toBe(100); + expect(b.amount).toBe(200); + // The whole point: same delivery, different bills. + expect(a.amount).not.toBe(b.amount); + }); + + it('charges nothing inside the grace window', async () => { + const out = await svc.computeTruckDetention( + rule, + { arrivedAt: new Date(now.getTime() - 2 * HOUR), deliveredAt: now, truckCount: 1 }, + now, + 'USD', + ); + expect(out.chargeableDays).toBe(0); + expect(out.amount).toBe(0); + }); + + it('keeps accruing against now when a truck has not returned', async () => { + const out = await svc.computeTruckDetention( + rule, + { arrivedAt: new Date(now.getTime() - 2 * DAY), deliveredAt: null, truckCount: 1 }, + now, + 'USD', + ); + expect(out.endIsOpen).toBe(true); + expect(out.chargeableDays).toBe(2); + }); +}); diff --git a/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts index fd967cc8c..59f4b5478 100644 --- a/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts +++ b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts @@ -17,6 +17,8 @@ export interface ImportTrainRow { route: string | null; origin: string | null; destination: string | null; + /** freight.yards.id the train is heading to — lets the frontend restrict the unload warehouse picker to the warehouse actually at this station, instead of listing every warehouse. */ + destinationStationId: string | null; arrivalTime: string | null; totalBookings: number; totalContainers: number; @@ -38,6 +40,8 @@ export interface ImportTrainItemRow { freightType: string | null; containerNumber: string | null; cargoType: string | null; + /** Cargo type CODE (e.g. "WHEAT"), for matching against a yard's configured cargo types — `cargoType` above is the display name. */ + cargoTypeCode: string | null; weight: number | null; arrivalTime: string | null; currentStatus: string | null; @@ -205,6 +209,7 @@ export class SchedulingReadFacade { ts.train_number AS "trainNumber", oy.code AS "origin", dy.code AS "destination", + dy.id AS "destinationStationId", oy.country AS "originCountry", dy.country AS "destinationCountry", COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime", @@ -280,6 +285,7 @@ export class SchedulingReadFacade { WHERE c.booking_id = b.id AND c.deleted_at IS NULL ORDER BY c.container_number LIMIT 1) AS "containerNumber", COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType", + cgt.code AS "cargoTypeCode", b.cargo_total_weight_vgm AS "weight", COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime", COALESCE(inv.status, b.status) AS "currentStatus", @@ -376,6 +382,7 @@ export class SchedulingReadFacade { ts.train_number AS "trainNumber", oy.code AS "origin", dy.code AS "destination", + dy.id AS "destinationStationId", dy.label AS "destinationName", oy.country AS "originCountry", dy.country AS "destinationCountry", diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.bulk-quantity.spec.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.bulk-quantity.spec.ts new file mode 100644 index 000000000..e23c6d1f8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.bulk-quantity.spec.ts @@ -0,0 +1,201 @@ +import { WarehouseFeeService } from './warehouse-fee.service'; +import { WarehouseFeeRule } from './entities/warehouse-fee-rule.entity'; + +// Bulk storage/demurrage used to bill a flat rate per day regardless of cargo +// quantity. It now scales by the cargo type's own unit of measure — tons for +// PER_TON cargo, item count for PER_ITEM cargo (Machinery, Truck, Automobile, +// Livestock…) — read from THIS inventory row, not the whole booking's total. +describe('WarehouseFeeService bulk quantity billing', () => { + const makeService = () => + // compute() only touches its own arguments plus this.convertAmount, which + // short-circuits when rule.currency === billingCurrency — none of the + // constructor deps are exercised. + new WarehouseFeeService({} as any, {} as any, {} as any, {} as any); + + const rule = (overrides: Partial = {}): WarehouseFeeRule => + ({ + id: 'rule-1', + name: 'Bulk storage', + ruleType: 'STORAGE_FEE', + freeDays: 0, + ratePerDay: 10, + currency: 'USD', + tiers: [], + ...overrides, + }) as WarehouseFeeRule; + + const baseItem = (overrides: Record = {}) => ({ + arrivedAt: new Date('2026-01-01T00:00:00Z'), + gateClearedAt: null, + releaseDate: null, + freightType: 'BULK', + tradeDirection: 'IMPORT', + cargoTypeCode: 'WHEAT', + containerTypeCode: null, + vehicleType: null, + inventoryQuantity: 3, + inventoryWeight: 25, + bookingContainerCount: 0, + cargoUnitOfMeasure: null, + // Double handling now bills only when staff answered Yes after unloading; + // these quantity-basis cases assume that answer (the gate itself is covered + // in double-handling-gate.spec.ts). + doubleHandling: true, + facilityId: null, + warehouseId: null, + yardId: null, + zoneId: null, + ...overrides, + }); + + // 5 elapsed days, 0 free days -> 5 chargeable days throughout. + const now = new Date('2026-01-06T00:00:00Z'); + + it('bills PER_TON bulk cargo by this row\'s weight, not a flat day rate', async () => { + const service = makeService(); + const preview = await (service as any).compute( + 'STORAGE_FEE', + rule(), + baseItem({ cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 25 }), + now, + 'USD', + ); + expect(preview.unitLabel).toBe('ton'); + expect(preview.containerCount).toBe(25); + expect(preview.billableUnits).toBe(5 * 25); + expect(preview.amount).toBe(5 * 25 * 10); + }); + + it('bills PER_ITEM bulk cargo (Machinery/Truck/Automobile/Livestock) by unit count', async () => { + const service = makeService(); + const preview = await (service as any).compute( + 'STORAGE_FEE', + rule(), + baseItem({ cargoUnitOfMeasure: 'PER_ITEM', inventoryQuantity: 3, cargoTypeCode: 'MACHINERY' }), + now, + 'USD', + ); + expect(preview.unitLabel).toBe('item'); + expect(preview.containerCount).toBe(3); + expect(preview.billableUnits).toBe(5 * 3); + expect(preview.amount).toBe(5 * 3 * 10); + }); + + it('defaults to PER_TON when the cargo type has no unit of measure set', async () => { + const service = makeService(); + const preview = await (service as any).compute( + 'STORAGE_FEE', + rule(), + baseItem({ cargoUnitOfMeasure: null, inventoryWeight: 12 }), + now, + 'USD', + ); + expect(preview.unitLabel).toBe('ton'); + expect(preview.containerCount).toBe(12); + }); + + it('charges nothing yet when the row has not been weighed/counted (0 is legitimate, not floored to 1)', async () => { + const service = makeService(); + const preview = await (service as any).compute( + 'STORAGE_FEE', + rule(), + baseItem({ cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 0 }), + now, + 'USD', + ); + expect(preview.containerCount).toBe(0); + expect(preview.billableUnits).toBe(0); + expect(preview.amount).toBe(0); + }); + + it('leaves CONTAINER freight billing untouched by the new bulk fields', async () => { + const service = makeService(); + const preview = await (service as any).compute( + 'DEMURRAGE_FEE', + rule({ ruleType: 'DEMURRAGE_FEE' }), + baseItem({ + freightType: 'CONTAINER', + bookingContainerCount: 4, + cargoUnitOfMeasure: 'PER_ITEM', // must be ignored for container freight + inventoryWeight: 999, + }), + now, + 'USD', + ); + expect(preview.unitLabel).toBe('container'); + expect(preview.containerCount).toBe(4); + expect(preview.billableUnits).toBe(5 * 4); + }); + + // Double handling is a flat one-time charge, but previewForInventory() calls + // it once per warehouse_inventory ROW. Before this fix it read the whole + // booking's total on every row, so a booking split across N rows was billed + // N times against its full quantity. Reading each row's own weight/count + // fixes that: summing the rows now reproduces the booking total exactly once. + describe('double handling (row-level, not booking-wide)', () => { + const doubleHandlingRule = (basis: 'PER_CONTAINER' | 'PER_TON' | 'PER_ITEM') => + rule({ ruleType: 'DOUBLE_HANDLING_FEE', basis, ratePerDay: 20 }); + + it('bills PER_TON by this row\'s own weight', async () => { + const service = makeService(); + const preview = await (service as any).compute( + 'DOUBLE_HANDLING_FEE', + doubleHandlingRule('PER_TON'), + baseItem({ cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 10 }), + now, + 'USD', + ); + expect(preview.unitLabel).toBe('ton'); + expect(preview.billableUnits).toBe(10); + expect(preview.amount).toBe(10 * 20); + }); + + it('bills PER_ITEM by this row\'s own unit count', async () => { + const service = makeService(); + const preview = await (service as any).compute( + 'DOUBLE_HANDLING_FEE', + doubleHandlingRule('PER_ITEM'), + baseItem({ cargoUnitOfMeasure: 'PER_ITEM', inventoryQuantity: 2, cargoTypeCode: 'TRUCK' }), + now, + 'USD', + ); + expect(preview.unitLabel).toBe('item'); + expect(preview.billableUnits).toBe(2); + expect(preview.amount).toBe(2 * 20); + }); + + it('two rows of one booking sum to the booking total exactly once (no N-times overcount)', async () => { + const service = makeService(); + const ruleDef = doubleHandlingRule('PER_TON'); + const rowA = await (service as any).compute( + 'DOUBLE_HANDLING_FEE', + ruleDef, + baseItem({ cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 6 }), + now, + 'USD', + ); + const rowB = await (service as any).compute( + 'DOUBLE_HANDLING_FEE', + ruleDef, + baseItem({ cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 4 }), + now, + 'USD', + ); + // Booking total is 10 tons across the two rows — billed once in total, + // not 10 tons charged against EACH row (which the old booking-wide read did). + expect(rowA.amount + rowB.amount).toBe(10 * 20); + }); + + it('no charge for export/domestic regardless of basis', async () => { + const service = makeService(); + const preview = await (service as any).compute( + 'DOUBLE_HANDLING_FEE', + doubleHandlingRule('PER_TON'), + baseItem({ tradeDirection: 'EXPORT', cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 10 }), + now, + 'USD', + ); + expect(preview.amount).toBe(0); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts index a60261c1e..aa30b31e7 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts @@ -20,9 +20,13 @@ interface ItemAttributes { /** Vehicle type of the truck (truck detention scoping); null otherwise. */ vehicleType: string | null; inventoryQuantity: number; + /** This inventory row's own net weight (tonnes) — bulk STORAGE/DEMURRAGE for PER_TON cargo bills against this, not the booking-wide total. */ + inventoryWeight: number; bookingContainerCount: number; - /** Booking cargo total in the cargo's unit of measure: tonnes (PER_TON) or item count (PER_ITEM). */ - cargoQuantity: number; + /** This item's cargo type unit of measure (PER_TON | PER_ITEM); null defaults to PER_TON. Decides whether bulk day-based fees bill by weight or item count. */ + cargoUnitOfMeasure: string | null; + /** Booking-level Yes/No recorded after unloading; only true bills double handling (null = undecided). */ + doubleHandling: boolean | null; facilityId: string | null; warehouseId: string | null; yardId: string | null; @@ -60,7 +64,7 @@ export interface AccrualDashboardRow { export interface FeePreview { ruleType: FeeRuleType; - /** Double-handling charge basis (PER_CONTAINER | PER_TON | PER_MACHINERY); null otherwise. */ + /** Double-handling charge basis (PER_CONTAINER | PER_TON | PER_ITEM); null otherwise. */ basis: FeeRuleBasis | null; ruleId: string | null; ruleName: string | null; @@ -75,6 +79,8 @@ export interface FeePreview { elapsedDays: number; chargeableDays: number; containerCount: number; + /** What `containerCount`/`billableUnits` are counted in — 'container' | 'truck' | 'ton' | 'item'. Bulk cargo bills by weight (ton) or item count depending on the cargo type's unit of measure. */ + unitLabel: string; billableUnits: number; amount: number; tiers: Array<{ @@ -86,10 +92,20 @@ export interface FeePreview { ratePerDay: number; amount: number; }>; - /** Truck detention: per-vehicle-type breakdown — each truck-type group billed by its own matching rule. */ + /** + * Truck detention: one row PER TRUCK — each truck has its own detention + * window (it arrives and is released at its own time) and its own matching + * rule by truck type, so days and amount differ between trucks. + */ groups?: Array<{ + assignmentId: string | null; + vehicleId: string | null; + plateNumber: string | null; vehicleType: string | null; truckCount: number; + startDate: string | null; + endDate: string | null; + endIsOpen: boolean; chargeableDays: number; ratePerDay: number; amount: number; @@ -242,16 +258,18 @@ export class WarehouseFeeService { inv.gate_cleared_at AS "gateClearedAt", inv.release_date AS "releaseDate", inv.quantity AS "inventoryQuantity", + inv.weight AS "inventoryWeight", inv.warehouse_id AS "warehouseId", inv.yard_id AS "yardId", inv.zone_id AS "zoneId", w.facility_id AS "facilityId", b.freight_type AS "freightType", b.trade_direction AS "tradeDirection", + b.double_handling AS "doubleHandling", COALESCE(cgt.code, booking_cgt.code) AS "cargoTypeCode", COALESCE(ctt.code, booking_ctt.code) AS "containerTypeCode", COALESCE(container_lines.container_count, 0) AS "bookingContainerCount", - COALESCE(b.cargo_total_weight_vgm, 0) AS "cargoQuantity" + COALESCE(cgt.unit_of_measure, booking_cgt.unit_of_measure) AS "cargoUnitOfMeasure" FROM freight.warehouse_inventory inv LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id LEFT JOIN freight.bookings b ON b.id = inv.booking_id @@ -470,6 +488,22 @@ export class WarehouseFeeService { }; } + /** + * Bulk's own billing quantity for THIS inventory row — weight (tons) for + * PER_TON cargo, unit count for PER_ITEM cargo (Machinery, Truck, Automobile, + * Livestock…). Shared by every cargo-scoped fee type (storage, demurrage, + * double handling) so a booking split across several rows is never billed + * more than once against its full total. 0 is a legitimate charge (nothing + * weighed/counted yet), so no forced floor. + */ + private resolveBulkQuantity(item: ItemAttributes): { quantity: number; unitLabel: string } { + const cargoUnit = (item.cargoUnitOfMeasure ?? 'PER_TON').toUpperCase(); + if (cargoUnit === 'PER_ITEM') { + return { quantity: Math.max(0, Number(item.inventoryQuantity) || 0), unitLabel: 'item' }; + } + return { quantity: Math.max(0, Number(item.inventoryWeight) || 0), unitLabel: 'ton' }; + } + private async compute( ruleType: FeeRuleType, rule: WarehouseFeeRule | null, @@ -490,9 +524,11 @@ export class WarehouseFeeService { const targetCurrency = this.normalizeCurrency(billingCurrency); const isContainer = (item.freightType ?? '').toUpperCase() === 'CONTAINER'; const inventoryQuantity = Math.max(1, Math.round(Number(item.inventoryQuantity) || 1)); + const bulk = this.resolveBulkQuantity(item); const containerCount = isContainer ? Math.max(1, Math.round(Number(item.bookingContainerCount) || inventoryQuantity)) - : 1; + : bulk.quantity; + const unitLabel = isContainer ? 'container' : bulk.unitLabel; const elapsedDays = start ? Math.max(0, Math.ceil((new Date(endDate).getTime() - start.getTime()) / MS_PER_DAY)) @@ -533,6 +569,7 @@ export class WarehouseFeeService { elapsedDays, chargeableDays, containerCount, + unitLabel, billableUnits, amount, tiers: hasTiers ? convertedTiers : [], @@ -560,12 +597,19 @@ export class WarehouseFeeService { const containerCount = isContainer ? Math.max(1, Math.round(Number(item.bookingContainerCount) || inventoryQuantity)) : 1; - // PER_TON (tonnes) and PER_ITEM (piece count) both read the cargo total, - // which is stored in the cargo's own unit of measure. - const cargoQuantity = Math.max(0, Number(item.cargoQuantity) || 0); - // Double handling applies to IMPORT only — no charge for export/domestic. + // PER_TON (tonnes) and PER_ITEM (piece count) both read THIS row's own + // weight/count — never the whole booking's total. previewForInventory() + // computes double handling once per inventory row, so a booking-wide total + // would double- (or triple-) bill a booking split across several rows. + const bulk = this.resolveBulkQuantity(item); + // Double handling applies to IMPORT only — no charge for export/domestic — + // AND only when warehouse staff recorded that the goods were actually + // re-handled (booking flag = Yes after unloading). Undecided (null) or No + // means no charge, so the rule can exist without billing every import. const isImport = (item.tradeDirection ?? '').toUpperCase() === 'IMPORT'; - const quantity = !isImport ? 0 : basis === 'PER_CONTAINER' ? containerCount : cargoQuantity; + const applies = isImport && item.doubleHandling === true; + const quantity = !applies ? 0 : basis === 'PER_CONTAINER' ? containerCount : bulk.quantity; + const unitLabel = basis === 'PER_CONTAINER' ? 'container' : bulk.unitLabel; const sourceAmount = Math.round(rate * quantity * 100) / 100; const amount = ruleCurrency ? await this.convertAmount(sourceAmount, ruleCurrency, targetCurrency) : 0; const convertedRate = ruleCurrency ? await this.convertAmount(rate, ruleCurrency, targetCurrency) : 0; @@ -586,6 +630,7 @@ export class WarehouseFeeService { elapsedDays: 0, chargeableDays: 0, containerCount, + unitLabel, billableUnits: quantity, amount, tiers: [], @@ -771,6 +816,7 @@ export class WarehouseFeeService { return { ruleType: 'TRUCK_DETENTION_FEE', basis: null, + unitLabel: 'truck', ruleId: null, ruleName: null, freeDays: 0, @@ -791,25 +837,48 @@ export class WarehouseFeeService { }; } - // Group the leg's vehicles by CANONICAL truck type so each type is billed - // by its own matching rule (rates differ by truck type). The FK to - // truck_types is the source of truth — renaming a type's label no longer - // silently unmatches its rule; the normalized legacy vehicle_type code is - // only a fallback for vehicles without the FK (LEFT JOIN keeps them billed - // instead of dropping them). Falls back to one untyped group. - const groupRows: Array<{ vehicleType: string | null; truckCount: number | string }> = - await this.dataSource.query( - `SELECT COALESCE(t.code, NULLIF(UPPER(TRIM(v.vehicle_type)), '')) AS "vehicleType", - count(*)::int AS "truckCount" - FROM freight.last_mile_vehicle_assignments va - JOIN freight.vehicles v ON v.id = va.vehicle_id AND v.deleted_at IS NULL - LEFT JOIN freight.truck_types t - ON t.id = v.truck_type_id AND t.deleted_at IS NULL - WHERE va.last_mile_id = $1 AND va.deleted_at IS NULL - GROUP BY 1`, - [lastMileId], - ); - const groups = groupRows.length ? groupRows : [{ vehicleType: null, truckCount: 1 }]; + // One row PER TRUCK: each truck has its own detention window (it reaches the + // destination and is released at its own time) and resolves its own rule by + // CANONICAL truck type — the truck_types FK is the source of truth, with the + // normalized legacy vehicle_type code as fallback so FK-less vehicles keep + // billing. Per-truck timestamps fall back to the leg-level pair for legacy + // legs recorded before per-truck tracking. + const truckRows: Array<{ + assignmentId: string; + vehicleId: string; + plateNumber: string | null; + vehicleType: string | null; + startAt: Date | string | null; + endAt: Date | string | null; + }> = await this.dataSource.query( + `SELECT va.id AS "assignmentId", + va.vehicle_id AS "vehicleId", + COALESCE(v.power_plate_no, v.plate_number) AS "plateNumber", + COALESCE(t.code, NULLIF(UPPER(TRIM(v.vehicle_type)), '')) AS "vehicleType", + COALESCE(va.destination_arrived_at, $2::timestamptz) AS "startAt", + COALESCE(va.returned_at, $3::timestamptz) AS "endAt" + FROM freight.last_mile_vehicle_assignments va + JOIN freight.vehicles v ON v.id = va.vehicle_id AND v.deleted_at IS NULL + LEFT JOIN freight.truck_types t + ON t.id = v.truck_type_id AND t.deleted_at IS NULL + WHERE va.last_mile_id = $1 AND va.deleted_at IS NULL + ORDER BY va.created_at ASC`, + [lastMileId, leg.arrivedAt ?? null, leg.deliveredAt ?? null], + ); + // No trucks assigned yet: keep the leg-level single-truck estimate so the + // preview still tells the operator what detention would cost. + const trucks = truckRows.length + ? truckRows + : [ + { + assignmentId: null as string | null, + vehicleId: null as string | null, + plateNumber: null as string | null, + vehicleType: null as string | null, + startAt: leg.arrivedAt ?? null, + endAt: leg.deliveredAt ?? null, + }, + ]; const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } }); const detentionRules = rules.filter((r) => r.ruleType === 'TRUCK_DETENTION_FEE'); @@ -817,7 +886,7 @@ export class WarehouseFeeService { const targetCurrency = this.normalizeCurrency(billingCurrency); const computed = await Promise.all( - groups.map(async (g) => { + trucks.map(async (t) => { const item: ItemAttributes = { arrivedAt: null, gateClearedAt: null, @@ -826,46 +895,60 @@ export class WarehouseFeeService { tradeDirection: leg.tradeDirection ?? null, cargoTypeCode: null, containerTypeCode: null, - vehicleType: g.vehicleType ?? null, + vehicleType: t.vehicleType ?? null, inventoryQuantity: 1, + inventoryWeight: 0, bookingContainerCount: 1, - cargoQuantity: 0, + cargoUnitOfMeasure: null, + // Irrelevant to detention (truck-time based, never double handling). + doubleHandling: null, facilityId: null, warehouseId: null, yardId: null, zoneId: null, }; const rule = this.bestRule(detentionRules, item); + // truckCount 1 — this row IS one truck. const c = await this.computeTruckDetention( rule, - { arrivedAt: leg.arrivedAt, deliveredAt: leg.deliveredAt, truckCount: g.truckCount }, + { arrivedAt: t.startAt, deliveredAt: t.endAt, truckCount: 1 }, now, billingCurrency, ); - return { vehicleType: g.vehicleType ?? null, truckCount: Math.max(1, Math.round(Number(g.truckCount) || 1)), c }; + return { ...t, c }; }), ); const totalAmount = Math.round(computed.reduce((s, x) => s + x.c.amount, 0) * 100) / 100; - const totalTrucks = computed.reduce((s, x) => s + x.truckCount, 0); + const totalTrucks = computed.length; const totalBillable = computed.reduce((s, x) => s + x.c.billableUnits, 0); - const chargeableDays = computed[0]?.c.chargeableDays ?? 0; + // Header days: the worst truck — a single number can't represent per-truck + // windows, and the longest detention is the one operations must act on. + const chargeableDays = computed.reduce((m, x) => Math.max(m, x.c.chargeableDays), 0); const single = computed.length === 1 ? computed[0].c : null; const anyRuleName = computed.find((x) => x.c.ruleId)?.c.ruleName ?? null; + const earliestStart = computed + .map((x) => (x.startAt ? new Date(x.startAt).getTime() : null)) + .filter((n): n is number => n != null) + .sort((a, b) => a - b)[0]; + const anyOpen = computed.some((x) => x.c.endIsOpen); return { ruleType: 'TRUCK_DETENTION_FEE', basis: null, + unitLabel: 'truck', ruleId: single?.ruleId ?? null, - ruleName: single ? single.ruleName : computed.length > 1 && anyRuleName ? 'Per truck-type rules' : anyRuleName, + ruleName: single ? single.ruleName : computed.length > 1 && anyRuleName ? 'Per truck rules' : anyRuleName, freeDays: 0, ratePerDay: single?.ratePerDay ?? 0, currency: targetCurrency, ruleCurrency: single?.ruleCurrency ?? null, billingCurrency: targetCurrency, - startDate: leg.arrivedAt ? new Date(leg.arrivedAt).toISOString() : null, - endDate: (leg.deliveredAt ? new Date(leg.deliveredAt) : now).toISOString(), - endIsOpen: !leg.deliveredAt, + startDate: earliestStart != null ? new Date(earliestStart).toISOString() : null, + endDate: (anyOpen ? now : new Date(Math.max( + ...computed.map((x) => (x.endAt ? new Date(x.endAt).getTime() : now.getTime())), + ))).toISOString(), + endIsOpen: anyOpen, elapsedDays: chargeableDays, chargeableDays, containerCount: totalTrucks, @@ -873,8 +956,14 @@ export class WarehouseFeeService { amount: totalAmount, tiers: single ? single.tiers : [], groups: computed.map((x) => ({ + assignmentId: x.assignmentId, + vehicleId: x.vehicleId, + plateNumber: x.plateNumber, vehicleType: x.vehicleType, - truckCount: x.truckCount, + truckCount: 1, + startDate: x.startAt ? new Date(x.startAt).toISOString() : null, + endDate: x.c.endDate, + endIsOpen: x.c.endIsOpen, chargeableDays: x.c.chargeableDays, ratePerDay: x.c.ratePerDay, amount: x.c.amount, @@ -927,6 +1016,7 @@ export class WarehouseFeeService { return { ruleType: 'TRUCK_DETENTION_FEE', basis: null, + unitLabel: 'truck', ruleId: rule?.id ?? null, ruleName: rule?.name ?? null, freeDays: 0, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index b3868b465..b78d6f6f9 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -17,6 +17,7 @@ import { MoveInventoryDto } from './dto/move-inventory.dto'; import { StoreInventoryDto } from './dto/store-inventory.dto'; import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto'; import { ApproveDeliveryDto } from './dto/approve-delivery.dto'; +import { SetDoubleHandlingDto } from './dto/double-handling.dto'; import { ReleaseOrderDto } from './dto/release-order.dto'; import { ReserveInventoryDto } from './dto/reserve-inventory.dto'; import { UnloadBookingDto } from './dto/unload-booking.dto'; @@ -552,6 +553,23 @@ export class WarehouseInventoryController { return res.send(buffer); } + @Patch('bookings/:bookingId/double-handling') + @BookingStaff([FREIGHT_PERMS.warehouseInventory.unload, FREIGHT_PERMS.warehouseInventory.inspect]) + @ApiOperation({ + summary: 'Record Yes/No double handling after unloading (Yes applies the double-handling fee rule)', + }) + setDoubleHandling( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body() dto: SetDoubleHandlingDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.inventoryService.setDoubleHandling( + bookingId, + dto.doubleHandling, + actorLabel(user), + ); + } + @Get('bookings/:bookingId/container-items') @StaffReference() @ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' }) diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 582face93..7cd47f203 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -382,6 +382,8 @@ export interface ImportUnloadedRow { customerTruckContainerNumber: string | null; customerTruckAssignedAt: string | null; hasAssignedTruck: boolean; + /** Post-unloading Yes/No; null = not recorded yet (no double-handling charge). */ + doubleHandling: boolean | null; currentStatus: string; releaseDate: string | null; releaseOrderReference: string | null; @@ -1139,17 +1141,31 @@ export class WarehouseInventoryService { async autoUnloadArrived(): Promise { const arrived: { id: string; + /** Goods owner (company) — the GRN number is mapped to it. */ + customer: string | null; weight: string | null; freightType: string | null; tradeDirection: string | null; cargoTypeCode: string | null; }[] = await this.dataSource.query( - `SELECT b.id, b.cargo_total_weight_vgm AS weight, + `SELECT b.id, + -- Received weight must land on the inventory row: a booking with no + -- declared VGM still has per-container VGM to record. + COALESCE( + NULLIF(b.cargo_total_weight_vgm, 0), + (SELECT SUM(bcu.vgm_tons) + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc2 + ON bc2.id = bcu.booking_container_id AND bc2.deleted_at IS NULL + WHERE bc2.booking_id = b.id AND bcu.deleted_at IS NULL) + ) AS weight, b.freight_type AS "freightType", b.trade_direction AS "tradeDirection", - cgt.code AS "cargoTypeCode" + cgt.code AS "cargoTypeCode", + company.name AS customer FROM freight.bookings b LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id + LEFT JOIN freight.companies company ON company.id = b.company_id WHERE b.status = ANY($1) AND b.deleted_at IS NULL AND inv.id IS NULL`, [this.ARRIVED_BOOKING_STATUSES], ); @@ -1186,7 +1202,7 @@ export class WarehouseInventoryService { status: 'RECEIVED', arrivedAt: new Date(), ...(booking.tradeDirection === 'EXPORT' - ? { grnNumber: this.generateGrnNumber('EXPORT', booking.id, new Date()) } + ? { grnNumber: this.generateGrnNumber('EXPORT', booking.id, new Date(), booking.customer) } : {}), notes: allocated?.rule ? `Auto-unloaded → ${allocated.path}` : 'Auto-unloaded from arrival queue', }); @@ -1211,12 +1227,19 @@ export class WarehouseInventoryService { // A GRN is the receipt for cargo entering the warehouse, so every booking // gets one on unload — import as well as export. The direction only decides // the GRN prefix, not whether one is issued. - const [bookingRow]: Array<{ tradeDirection: string | null }> = await this.dataSource.query( - `SELECT trade_direction AS "tradeDirection" - FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, - [bookingId], - ); + // The GRN is mapped to the goods owner (the booking's company), so pull it + // alongside the direction rather than issuing an owner-less number. + const [bookingRow]: Array<{ tradeDirection: string | null; ownerName: string | null }> = + await this.dataSource.query( + `SELECT b.trade_direction AS "tradeDirection", + company.name AS "ownerName" + FROM freight.bookings b + LEFT JOIN freight.companies company ON company.id = b.company_id + WHERE b.id = $1 AND b.deleted_at IS NULL`, + [bookingId], + ); const grnDirection = bookingRow?.tradeDirection ?? 'WH'; + const ownerName = bookingRow?.ownerName ?? null; let location: DefaultLocation | null = dto.warehouseId && dto.yardId && dto.zoneId @@ -1240,7 +1263,7 @@ export class WarehouseInventoryService { // Keep an already-issued GRN rather than reissuing; mint one otherwise. ...(existing[0].grnNumber ? {} - : { grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt) }), + : { grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt, ownerName) }), notes: dto.notes ?? existing[0].notes ?? 'Unloaded', }); return this.findById(existing[0].id); @@ -1255,7 +1278,7 @@ export class WarehouseInventoryService { weight: 0, status: 'RECEIVED', arrivedAt, - grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt), + grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt, ownerName), notes: dto.notes ?? 'Unloaded', }); return this.findById(saved.id); @@ -1565,7 +1588,7 @@ export class WarehouseInventoryService { } const now = new Date(); - const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now); + const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now, booking.customer); const truckEntrance = dto.truckEntrance ? this.mergeSystemTruckEntrance(dto.truckEntrance, booking) : undefined; @@ -1981,6 +2004,7 @@ export class WarehouseInventoryService { WHERE lm.booking_id = b.id AND lm.vehicle_id IS NOT NULL AND lm.deleted_at IS NULL)) AS "hasAssignedTruck", + b.double_handling AS "doubleHandling", inv.status AS "currentStatus", inv.release_date AS "releaseDate", inv.release_order_reference AS "releaseOrderReference", @@ -2130,6 +2154,8 @@ export class WarehouseInventoryService { const bookings: { id: string; status: string; + /** Goods owner (company) — the GRN number is mapped to it. */ + customer: string | null; weight: string | null; freightType: string | null; tradeDirection: string | null; @@ -2140,12 +2166,24 @@ export class WarehouseInventoryService { // at an intermediate yard was already unloaded there by the checkpoint // auto-unload; without this filter it would be mis-located into the final // yard's inventory too. - `SELECT b.id, b.status, b.cargo_total_weight_vgm AS weight, + `SELECT b.id, b.status, + -- Same fallback as autoUnloadArrived: never land a 0 t receipt when + -- the booking's containers carry a VGM. + COALESCE( + NULLIF(b.cargo_total_weight_vgm, 0), + (SELECT SUM(bcu.vgm_tons) + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc2 + ON bc2.id = bcu.booking_container_id AND bc2.deleted_at IS NULL + WHERE bc2.booking_id = b.id AND bcu.deleted_at IS NULL) + ) AS weight, b.freight_type AS "freightType", b.trade_direction AS "tradeDirection", - cgt.code AS "cargoTypeCode" + cgt.code AS "cargoTypeCode", + company.name AS customer FROM freight.train_schedule_bookings tsb JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id + LEFT JOIN freight.companies company ON company.id = b.company_id WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL AND b.destination_yard_id = $2`, [scheduleId, schedule.destinationStationId], @@ -2203,11 +2241,16 @@ export class WarehouseInventoryService { zoneId: unloadLocation.zoneId, } : {}), + // Record the received weight on a row that never carried one — the + // GRN prints this, and an existing non-zero weight is left alone. + ...(Number(existing.weight) > 0 || !(Number(booking.weight) > 0) + ? {} + : { weight: Number(booking.weight) }), status: 'UNLOADED', unloadedAt: now, arrivedAt: existing.arrivedAt ?? now, // Import GRN is issued automatically at train unload. - ...(existing.grnNumber ? {} : { grnNumber: this.generateGrnNumber('IMPORT', booking.id, now) }), + ...(existing.grnNumber ? {} : { grnNumber: this.generateGrnNumber('IMPORT', booking.id, now, booking.customer) }), }); await this.activityLog.record({ activityType: 'INVENTORY_UNLOADED', @@ -2216,6 +2259,22 @@ export class WarehouseInventoryService { description: 'Unloaded from arrived import train', performedBy, }); + // Capacity follows the recorded weight: deliver() decrements by the + // item's weight, so a weight written here must be counted here too. + const addedWeight = Number(booking.weight) - Number(existing.weight ?? 0); + if (addedWeight > 0) { + await this.applyCapacityDelta( + this.dataSource.manager, + { + warehouseId: unloadLocation?.warehouseId ?? existing.warehouseId, + yardId: unloadLocation?.yardId ?? existing.yardId, + zoneId: unloadLocation?.zoneId ?? existing.zoneId, + }, + addedWeight, + 0, + 0, + ); + } result.unloadedCount += 1; result.results.push({ bookingId: booking.id, inventoryId: existing.id, status: 'UNLOADED' }); continue; @@ -2241,7 +2300,7 @@ export class WarehouseInventoryService { quantity: 1, weight: Number(booking.weight) || 0, status: 'UNLOADED', - grnNumber: this.generateGrnNumber('IMPORT', booking.id, now), + grnNumber: this.generateGrnNumber('IMPORT', booking.id, now, booking.customer), arrivedAt: now, unloadedAt: now, notes: allocated?.rule ? `Unloaded → ${allocated.path}` : 'Unloaded from arrived import train', @@ -2253,6 +2312,11 @@ export class WarehouseInventoryService { description: 'Unloaded from arrived import train', performedBy, }); + // New goods physically in the warehouse — count them, or deliver() would + // later free capacity that was never taken. + if (Number(saved.weight) > 0) { + await this.applyCapacityDelta(this.dataSource.manager, location, Number(saved.weight), 0, 0); + } result.unloadedCount += 1; result.results.push({ bookingId: booking.id, inventoryId: saved.id, status: 'UNLOADED' }); } catch (error) { @@ -2670,7 +2734,12 @@ export class WarehouseInventoryService { this.assertCapacity('Zone', zone, weight, volume, containerCount); const now = new Date(); - const grnNumber = this.generateGrnNumber(bookingDirection ?? 'WH', dto.bookingId ?? 'MANUAL', now); + const grnNumber = this.generateGrnNumber( + bookingDirection ?? 'WH', + dto.bookingId ?? 'MANUAL', + now, + truckEntrance?.ownerName ?? bookingSource?.customer, + ); const receiveNote = this.buildReceiveNote({ grnNumber, notes: dto.notes?.trim() || 'Single booking received', @@ -3610,6 +3679,7 @@ export class WarehouseInventoryService { contractId: string | null; hasLastMile: boolean; handoverSigned: boolean; + inspectionStatus: string | null; }> > { const rows: Array<{ @@ -3627,6 +3697,7 @@ export class WarehouseInventoryService { contractId: string | null; hasLastMile: boolean; delivered: boolean; + inspectionStatus: string | null; }> = await this.dataSource.query( `SELECT bcu.container_number AS "containerNumber", COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods, @@ -3641,7 +3712,8 @@ export class WarehouseInventoryService { b.reference AS "bookingReference", b.contract_id AS "contractId", (b.last_mile_delivery_address IS NOT NULL) AS "hasLastMile", - COALESCE(inv.status = 'DELIVERED', false) AS delivered + COALESCE(inv.status = 'DELIVERED', false) AS delivered, + inv.inspection_status AS "inspectionStatus" FROM freight.booking_container_units bcu JOIN freight.booking_container bc ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL @@ -3693,6 +3765,7 @@ export class WarehouseInventoryService { bookingReference: r.bookingReference, contractId: r.contractId, hasLastMile: r.hasLastMile, + inspectionStatus: r.inspectionStatus, handoverSigned, })); } @@ -3944,7 +4017,10 @@ export class WarehouseInventoryService { COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber", COALESCE(inv.arrived_at, inv.created_at) AS "receivedAt", inv.quantity, - inv.weight, + -- An unweighed item still reports the cargo weight it holds: fall + -- back to the item's container VGM, then the booking's declared + -- weight, so a GRN never prints "0 t" for goods that are present. + COALESCE(NULLIF(inv.weight, 0), item_vgm.tons, b.cargo_total_weight_vgm, 0) AS weight, inv.volume, inv.status, inv.notes, @@ -3992,6 +4068,16 @@ export class WarehouseInventoryService { WHERE bc.booking_id = b.id AND bc.deleted_at IS NULL ) booking_container ON true + LEFT JOIN LATERAL ( + SELECT SUM(bcu.vgm_tons) AS tons + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc2 + ON bc2.id = bcu.booking_container_id AND bc2.deleted_at IS NULL + WHERE bc2.booking_id = b.id + AND bcu.deleted_at IS NULL + AND (container.container_number IS NULL + OR bcu.container_number = container.container_number) + ) item_vgm ON true LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id) WHERE inv.id = $1 AND inv.deleted_at IS NULL @@ -4379,6 +4465,81 @@ export class WarehouseInventoryService { }); } + /** + * Record whether a booking's goods needed double handling. Answered by + * warehouse staff once the goods are unloaded — only Yes bills the + * DOUBLE_HANDLING_FEE rule (see WarehouseFeeService.computeDoubleHandling). + * Locked once the fee has been invoiced, so a billed charge can't be + * retro-cancelled from the operations screen. + */ + async setDoubleHandling( + bookingId: string, + doubleHandling: boolean, + performedBy?: string, + ): Promise<{ bookingId: string; doubleHandling: boolean; setAt: string }> { + const [booking]: Array<{ id: string; tradeDirection: string | null; reference: string | null }> = + await this.dataSource.query( + `SELECT id, trade_direction AS "tradeDirection", reference + FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); + if ((booking.tradeDirection ?? '').toUpperCase() !== 'IMPORT') { + throw new BadRequestException('Double handling applies to import bookings only'); + } + + // Warehouse fees are billed per inventory row (invoices.source = 'warehouse', + // source_id = the inventory id), with the fee type on the line's charge_type. + const [invoiced]: Array<{ one: number }> = await this.dataSource.query( + `SELECT 1 AS one + FROM freight.invoices i + JOIN freight.invoice_lines il ON il.invoice_id = i.id AND il.deleted_at IS NULL + JOIN freight.warehouse_inventory inv + ON inv.id::text = i.source_id AND inv.deleted_at IS NULL + WHERE inv.booking_id = $1 + AND i.source = 'warehouse' + AND i.deleted_at IS NULL + AND i.status <> 'CANCELLED' + AND il.charge_type = 'DOUBLE_HANDLING' + LIMIT 1`, + [bookingId], + ); + if (invoiced) { + throw new BadRequestException( + 'Double handling has already been invoiced for this booking — cancel the invoice to change it', + ); + } + + const setAt = new Date(); + await this.dataSource.query( + `UPDATE freight.bookings + SET double_handling = $2, + double_handling_set_at = $3, + double_handling_set_by = $4, + updated_at = NOW() + WHERE id = $1`, + [bookingId, doubleHandling, setAt, performedBy ?? null], + ); + + // Audit on the booking's inventory rows so it shows in warehouse history. + const items: Array<{ id: string; warehouseId: string | null }> = await this.dataSource.query( + `SELECT id, warehouse_id AS "warehouseId" FROM freight.warehouse_inventory + WHERE booking_id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + for (const item of items) { + await this.activityLog.record({ + activityType: 'INVENTORY_STORED', + inventoryId: item.id, + warehouseId: item.warehouseId, + description: `Double handling set to ${doubleHandling ? 'YES — fee rule applies' : 'NO'}`, + performedBy, + }); + } + + return { bookingId, doubleHandling, setAt: setAt.toISOString() }; + } + /** Resolve the primary warehouse-inventory item for a booking (most recent). */ private async primaryInventoryIdForBooking(bookingId: string): Promise { const [inv]: Array<{ id: string }> = await this.dataSource.query( @@ -5213,7 +5374,9 @@ export class WarehouseInventoryService { }); const rows: Array<[string, unknown]> = [ ['Booking Reference', data.bookingReference], - ['Customer / Consignee', data.customerName], + // The GRN is mapped to the owner (import: consignee, export: shipper) — + // named explicitly so the note reads the same for both directions. + ["Owner's Name", data.customerName], ['Customer TIN', data.customerTin], ['Booking Status', data.bookingStatus], ['Service Type', data.serviceType], @@ -5844,8 +6007,13 @@ export class WarehouseInventoryService { } /** Shared with the facility handling flow — see common/grn.util.ts. */ - private generateGrnNumber(direction: string, referenceId: string, date: Date): string { - return generateGrnNumber(direction, referenceId, date); + private generateGrnNumber( + direction: string, + referenceId: string, + date: Date, + ownerName?: string | null, + ): string { + return generateGrnNumber(direction, referenceId, date, ownerName); } private async generateReleaseReference(item: WarehouseInventory): Promise { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.repository.ts index 99bbdd21f..41f6aacaf 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.repository.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.repository.ts @@ -1,8 +1,9 @@ import { BaseRepository } from '@edr/api-common'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { DeepPartial, Repository } from 'typeorm'; +import { CargoType } from '../rule-engine/entities/cargo-type.entity'; import { WarehouseYard } from './entities/warehouse-yard.entity'; @Injectable() @@ -10,4 +11,20 @@ export class WarehouseYardsRepository extends BaseRepository { constructor(@InjectRepository(WarehouseYard) repository: Repository) { super(repository); } + + /** The cargoTypes relation can't ride a column UPDATE — sync it via entity save, like the plain columns. */ + async update(id: string, data: DeepPartial): Promise { + const { cargoTypes, ...columns } = data; + if (Object.keys(columns).length) { + await this.repository.update(id, columns as never); + } + if (cargoTypes) { + const entity = await this.repository.findOne({ where: { id } as never }); + if (entity) { + entity.cargoTypes = cargoTypes as CargoType[]; + await this.repository.save(entity); + } + } + return this.findById(id); + } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts index 5b5e2b227..874de75db 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { CargoType } from '../rule-engine/entities/cargo-type.entity'; import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto'; import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto'; import { WarehouseYard } from './entities/warehouse-yard.entity'; @@ -15,7 +16,7 @@ export class WarehouseYardsService { findAll(): Promise { return this.yardsRepository.findAll({ - relations: { warehouse: true, zones: true }, + relations: { warehouse: true, zones: true, cargoTypes: true }, order: { code: 'ASC' }, }); } @@ -23,14 +24,14 @@ export class WarehouseYardsService { findByWarehouse(warehouseId: string): Promise { return this.yardsRepository.findAll({ where: { warehouseId }, - relations: { zones: true }, + relations: { zones: true, cargoTypes: true }, order: { code: 'ASC' }, }); } async findById(id: string): Promise { const yard = await this.yardsRepository.findById(id, { - relations: { warehouse: true, zones: true }, + relations: { warehouse: true, zones: true, cargoTypes: true }, }); if (!yard) { @@ -51,6 +52,7 @@ export class WarehouseYardsService { name: dto.name.trim(), code: dto.code.trim(), type: dto.type, + direction: dto.direction ?? null, capacityWeight: dto.capacityWeight ?? null, capacityContainers: dto.capacityContainers ?? null, maxWeight: dto.maxWeight ?? dto.capacityWeight ?? null, @@ -60,6 +62,8 @@ export class WarehouseYardsService { currentVolume: 0, status: 'ACTIVE', isActive: true, + // Join rows are written by the save (RESTRICT FK rejects unknown ids). + cargoTypes: (dto.cargoTypeIds ?? []).map((id) => ({ id }) as CargoType), }); } @@ -84,12 +88,16 @@ export class WarehouseYardsService { name: dto.name?.trim() ?? existing.name, code: dto.code?.trim() ?? existing.code, type: dto.type ?? existing.type, + direction: dto.direction ?? existing.direction, capacityWeight: newCapacityWeight, capacityContainers: newCapacityContainers, maxWeight: dto.maxWeight ?? existing.maxWeight, maxVolume: dto.maxVolume ?? existing.maxVolume, status, isActive: status === 'ACTIVE', + ...(dto.cargoTypeIds + ? { cargoTypes: dto.cargoTypeIds.map((cargoTypeId) => ({ id: cargoTypeId }) as CargoType) } + : {}), }); if (!updated) { diff --git a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts index fa74f9043..87e9819cf 100644 --- a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts +++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts @@ -2,6 +2,7 @@ import { Injectable, Logger } from "@nestjs/common"; import { DataSource } from "typeorm"; import { FileUploadSetting } from "../modules/file-upload-settings/entities/file-upload-setting.entity"; +import { poaDelegationField } from "../modules/file-upload-settings/poa-delegation.constants"; interface OnboardingField { fileKey: string; @@ -17,27 +18,14 @@ interface OnboardingField { const DOC_EXTENSIONS = ["pdf", "jpg", "jpeg", "png"]; -/** fileKey of the delegation letter attached to the Power of Attorney step. */ -export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter"; - /** - * Seeded as optional: the delegation letter is only mandatory once a PoA has - * been entered, or when the company operates as a freight forwarder. That rule - * spans form fields as well as files, so it lives in the onboarding gate - * (companies.service.getOnboardingRequirements) rather than in `isRequired`. + * Listed in the sets below only so the reference defaults stay a complete + * picture of a company onboarding form. Unlike every other field here, the DARS + * delegation paper is not admin-managed: `FileUploadSettingsService.getByCode` + * injects it from poa-delegation.constants.ts whether or not a row exists. */ -const poaDelegationField = (displayOrder: number): OnboardingField => ({ - fileKey: POA_DELEGATION_FILE_KEY, - fileLabel: "PoA Delegation Letter", - helpText: - "Signed letter in which the General Manager delegates the representative named above.", - isRequired: false, - isMultiple: false, - maxFiles: 1, - allowedExtensions: DOC_EXTENSIONS, - maxSizeMb: 10, - displayOrder, -}); +const poaDelegationDefault = (displayOrder: number): OnboardingField => + poaDelegationField(displayOrder) as unknown as OnboardingField; /** Documents required from an Ethiopian company at onboarding. */ const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [ @@ -75,7 +63,7 @@ const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [ maxSizeMb: 10, displayOrder: 3, }, - poaDelegationField(4), + poaDelegationDefault(4), ]; /** Documents required from a Foreign company at onboarding. */ @@ -124,7 +112,7 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [ maxSizeMb: 10, displayOrder: 4, }, - poaDelegationField(5), + poaDelegationDefault(5), ]; /** Legacy combined set, kept for the older per-company-type codes. */ diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 77e67f210..eda320c5b 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -121,6 +121,7 @@ import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage"; import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage"; import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage"; import ExportWarehouseFlowPage from "./pages/warehouses/ExportWarehouseFlowPage"; +import ImportTrucksPage from "./pages/warehouses/ImportTrucksPage"; import ImportWarehouseFlowPage from "./pages/warehouses/ImportWarehouseFlowPage"; import InterchangeDocumentsPage from "./pages/warehouses/InterchangeDocumentsPage"; import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage"; @@ -404,6 +405,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.warehouseInventory.view, }, + { + label: "Import Trucks", + href: "/dashboard/import-trucks", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, { label: "Dispatch Queue", href: "/dashboard/dispatch-queue", @@ -1052,6 +1059,7 @@ const App = () => { } /> } /> } /> + } /> } /> } /> { config.headers.Authorization = `Bearer ${token}`; } + // Tells the backend which app is asking, so /auth/login can reject + // cross-audience credentials (EDRFREIGHT-415). + config.headers["X-Client-App"] = "backoffice"; + return config; }); diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingTrucksPanel.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingTrucksPanel.tsx new file mode 100644 index 000000000..1a77632ec --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingTrucksPanel.tsx @@ -0,0 +1,301 @@ +import { useMemo, useState } from "react"; +import { useQueries, useQuery } from "@tanstack/react-query"; +import { Badge, Button, Center, Group, Loader, SimpleGrid, Stack, Table, Text } from "@mantine/core"; +import { Coins, Truck } from "lucide-react"; + +import { api } from "@/services/api"; +import { warehouseService } from "@/services/warehouse.service"; +import { lastMileService } from "@/services/last-mile.service"; +import { FeePreviewModal } from "@/components/warehouses/FeePreviewModal"; +import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal"; + +import { SectionCard } from "./SectionCard"; +import { MetricTile } from "./MetricTile"; + +const money = (amount: number, currency: string) => + `${Number(amount).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`; + +const fmt = (iso: string | null | undefined) => (iso ? new Date(iso).toLocaleString() : "—"); + +function inspectionLabel(status: string | null | undefined): { text: string; color: string } { + if (!status) return { text: "Pending", color: "gray" }; + if (status === "PASSED") return { text: "Passed", color: "edr-green" }; + if (status === "FAILED") return { text: "Failed", color: "red" }; + return { text: status, color: "gray" }; +} + +interface TruckRow { + key: string; + plate: string; + driver: string | null; + truckType: string | null; + containers: string[]; + warehouseArrived: string | null; + warehouseDeparted: string | null; + destinationArrived: string | null; + returned: string | null; + detentionOpen: boolean; + detentionDays: number | null; + detentionAmount: number | null; + hasDetentionRule: boolean; + inspection: { text: string; color: string }; +} + +/** + * Every truck tied to a booking's last mile — EDR-dispatched or customer + * self-haul (a booking only ever uses one), each with its own warehouse-gate + * and destination-detention clocks, plus the booking's cargo-side cost totals + * (storage/demurrage/double handling — billed per row internally, always + * shown here as one booking-level total). Detention stays EDR-only; customer + * self-haul rows show "—" since EDR only bills detention on its own fleet. + */ +export function BookingTrucksPanel({ bookingId }: { bookingId: string }) { + const [feeModalOpen, setFeeModalOpen] = useState(false); + const [detentionModalOpen, setDetentionModalOpen] = useState(false); + + const inventoryQuery = useQuery( + api.warehouses.listInventory.queryOptions({ input: { filter: { bookingId } } }), + ); + const inventoryItems = inventoryQuery.data ?? []; + const latestInventory = inventoryItems[0] ?? null; + + const edrTrucksQuery = useQuery({ + queryKey: ["booking-edr-trucks", bookingId], + queryFn: () => warehouseService.getLastMileTrucks(bookingId), + }); + const edrTrucks = edrTrucksQuery.data ?? []; + + const customerTrucksQuery = useQuery({ + queryKey: ["booking-customer-trucks", bookingId], + queryFn: () => warehouseService.getCustomerTrucks(bookingId), + enabled: edrTrucksQuery.isSuccess && edrTrucks.length === 0, + }); + const customerTrucks = customerTrucksQuery.data ?? []; + + const mode: "EDR" | "CUSTOMER" | "NONE" = + edrTrucks.length > 0 ? "EDR" : customerTrucks.length > 0 ? "CUSTOMER" : "NONE"; + + const containerItemsQuery = useQuery({ + queryKey: ["booking-container-items-for-trucks", bookingId], + queryFn: () => warehouseService.getContainerItems(bookingId), + }); + const inspectionByContainer = new Map( + (containerItemsQuery.data ?? []).map((c) => [c.containerNumber, c.inspectionStatus]), + ); + + const lastMileId = edrTrucks[0]?.lastMileId ?? null; + + const detentionPreviewQuery = useQuery({ + queryKey: ["truck-detention-preview-for-trucks-tab", lastMileId], + queryFn: () => lastMileService.truckDetentionPreview(lastMileId as string).then((r) => r.data), + enabled: Boolean(lastMileId), + }); + const detentionPreview = detentionPreviewQuery.data; + const detentionByVehicle = new Map( + (detentionPreview?.groups ?? []).map((g) => [g.vehicleId ?? "", g]), + ); + + const lastMileRecordQuery = useQuery({ + queryKey: ["last-mile-record-for-trucks-tab", lastMileId], + queryFn: () => lastMileService.getById(lastMileId as string).then((r) => r.data), + enabled: Boolean(lastMileId), + }); + + // Booking-level cost strip: same per-row fee preview the accrual dashboard + // and FeePreviewModal already use, summed across every inventory row on + // this booking rather than duplicated per row. + const feeQueries = useQueries({ + queries: inventoryItems.map((item) => + api.warehouses.feePreview.queryOptions({ input: { inventoryId: item.id, billingCurrency: "USD" } }), + ), + }); + const allFees = feeQueries.flatMap((q) => q.data ?? []); + const feeCurrency = allFees[0]?.currency ?? "USD"; + const sumByType = (type: string) => + allFees.filter((f) => f.ruleType === type).reduce((sum, f) => sum + Number(f.amount || 0), 0); + + const rows: TruckRow[] = useMemo(() => { + if (mode === "EDR") { + return edrTrucks.map((t) => { + const g = detentionByVehicle.get(t.vehicleId); + return { + key: t.vehicleId, + plate: [t.truckPlateNumber, t.trailerPlateNumber].filter(Boolean).join(" + ") || "—", + driver: t.driverName, + truckType: t.truckType, + containers: t.containerNumber ? [t.containerNumber] : [], + warehouseArrived: t.arrivedAt, + warehouseDeparted: t.departedAt, + destinationArrived: g?.startDate ?? null, + returned: g?.endIsOpen ? null : g?.endDate ?? null, + detentionOpen: Boolean(g?.endIsOpen), + detentionDays: g?.chargeableDays ?? null, + detentionAmount: g?.amount ?? null, + hasDetentionRule: Boolean(g?.ruleId), + inspection: inspectionLabel(t.containerNumber ? inspectionByContainer.get(t.containerNumber) : undefined), + }; + }); + } + if (mode === "CUSTOMER") { + return customerTrucks.map((t) => { + const containers = (t.containers ?? []).map((c) => c.containerNumber); + const statuses = new Set(containers.map((cn) => inspectionByContainer.get(cn) ?? null)); + const inspection = + containers.length === 0 + ? inspectionLabel(undefined) + : statuses.size > 1 + ? { text: "Mixed", color: "yellow" } + : inspectionLabel([...statuses][0]); + return { + key: t.id, + plate: t.plateNumber, + driver: t.driverName, + truckType: t.truckType, + containers, + warehouseArrived: t.arrivedAt ?? null, + warehouseDeparted: t.departedAt ?? null, + destinationArrived: null, + returned: null, + detentionOpen: false, + detentionDays: null, + detentionAmount: null, + hasDetentionRule: false, + inspection, + }; + }); + } + return []; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mode, edrTrucks, customerTrucks, inspectionByContainer, detentionByVehicle]); + + if (inventoryQuery.isLoading || edrTrucksQuery.isLoading) { + return ( +
+ + + Loading trucks… + +
+ ); + } + + return ( + + setFeeModalOpen(true)}> + View breakdown + + ) + } + > + + + + + + + + setDetentionModalOpen(true)}> + Detention times + + ) + } + > + {rows.length === 0 ? ( + + No trucks assigned to this booking's last mile yet. + + ) : ( + + + + + Plate + Driver + Type + Container(s) + Wh. arrived + Wh. departed + Dest. arrived + Returned + Detention + Inspection + + + + {rows.map((r) => ( + + {r.plate} + {r.driver ?? "—"} + {r.truckType ?? "—"} + {r.containers.length ? r.containers.join(", ") : "—"} + {fmt(r.warehouseArrived)} + {fmt(r.warehouseDeparted)} + {fmt(r.destinationArrived)} + + {r.detentionOpen ? ( + + still out + + ) : ( + fmt(r.returned) + )} + + + {mode !== "EDR" || r.detentionDays == null ? ( + "—" + ) : ( + <> + {r.detentionDays}d · {money(r.detentionAmount ?? 0, detentionPreview?.currency ?? "USD")} + {!r.hasDetentionRule && ( + + {" "} + · no rule + + )} + + )} + + + + {r.inspection.text} + + + + ))} + +
+
+ )} +
+ + setFeeModalOpen(false)} + inventoryId={latestInventory?.id ?? null} + /> + {mode === "EDR" && ( + setDetentionModalOpen(false)} + record={lastMileRecordQuery.data ?? null} + /> + )} +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts index f4a677991..ecbb0488e 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts @@ -2,6 +2,7 @@ export * from "./booking-detail.styles"; export * from "./SectionCard"; export * from "./ClearanceReviewSection"; export * from "./BookingDocumentsPanel"; +export * from "./BookingTrucksPanel"; export * from "./ContractOrdersPanel"; export * from "./MetricTile"; export * from "./BookingDetailToolbar"; diff --git a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx index ff99be149..253d8aea5 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx @@ -59,6 +59,13 @@ const FIELD_LABELS: Record = { woreda: "Woreda", kebele: "Kebele", houseNo: "House no.", + statusDescription: "eTrade status", + dateRegistered: "Date registered", + renewedFrom: "Renewed from", + renewalDate: "Renewal date", + renewedTo: "Renewed to", + etradePhone: "eTrade phone", + ownerPassportNumber: "Owner passport number", }; /** Best-effort current value on the live company for a proposed field key. */ @@ -85,6 +92,74 @@ function currentValue(company: Company, key: string): string { return v === null || v === undefined || v === "" ? "—" : String(v); } +/** Subject a staged `snapshot.faydaIdentity` blob belongs to, from which of its `*FaydaSub` keys is present. */ +function faydaIdentitySubject( + snapshot: Record, +): "owner" | "poa" | null { + if ("ownerFaydaSub" in snapshot) return "owner"; + if ("poaFaydaSub" in snapshot) return "poa"; + return null; +} + +/** + * `stageIdentityChange` writes a nested `snapshot.faydaIdentity` object + * (attrs-key names like `ownerEmail`, not top-level DTO keys), so the generic + * `DiffRow` loop below can't render it — it would just stringify to + * `[object Object]`. Render it as its own before/after block instead, using + * the company's current `identity.owner`/`identity.poa` as the "before" side. + */ +function FaydaIdentityDiff({ + company, + snapshot, +}: { + company: Company; + snapshot: Record; +}) { + const subject = faydaIdentitySubject(snapshot); + if (!subject) return null; + const current = + subject === "owner" ? company.identity?.owner : company.identity?.poa; + const read = (key: string) => snapshot[`${subject}${key}`] as string | undefined; + const verifiedAt = read("FaydaVerifiedAt"); + const fields: { label: string; from?: string | null; to?: string }[] = [ + { label: "Name", from: current?.name, to: read("Name") }, + { label: "Email", from: current?.email, to: read("Email") }, + { label: "Phone", from: current?.phone, to: read("Phone") }, + { label: "Address", from: current?.address, to: read("Address") }, + ].filter((f) => f.to !== undefined); + + return ( + + + + {subject === "owner" ? "Owner re-verification" : "PoA re-verification"} + + {verifiedAt && ( + + Verified {formatDate(verifiedAt)} + + )} + + {fields.length > 0 ? ( + + {fields.map((f) => ( + + ))} + + ) : ( + + Identity re-verified — no name/email/phone/address change. + + )} + + ); +} + function DiffRow({ label, from, @@ -153,8 +228,11 @@ export function ChangeRequestReview({ company }: { company: Company }) { if (!pending && history.length === 0) return null; const proposedKeys = pending - ? Object.keys(pending.snapshot ?? {}) + ? Object.keys(pending.snapshot ?? {}).filter((k) => k !== "faydaIdentity") : ([] as string[]); + const faydaIdentitySnapshot = pending?.snapshot?.faydaIdentity as + | Record + | undefined; const docCount = pending?.documentFileIds?.length ?? 0; const licenseChanges = pending?.licenseChanges ?? []; const documentChanges = pending?.documentChanges ?? []; @@ -209,10 +287,14 @@ export function ChangeRequestReview({ company }: { company: Company }) { /> ))} - ) : ( + ) : !faydaIdentitySnapshot ? ( No field changes — document uploads only. + ) : null} + + {faydaIdentitySnapshot && ( + )} {documentChanges.length > 0 && ( diff --git a/apps/edr-freight-web/backoffice/src/components/operations/TruckDetentionModal.tsx b/apps/edr-freight-web/backoffice/src/components/operations/TruckDetentionModal.tsx index c9748d80a..934832c34 100644 --- a/apps/edr-freight-web/backoffice/src/components/operations/TruckDetentionModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/operations/TruckDetentionModal.tsx @@ -43,21 +43,56 @@ function Stat({ label, value, strong }: { label: string; value: React.ReactNode; ); } +type TruckRow = { + vehicleId: string; + label: string; + arrived: Date | null; + returned: Date | null; +}; + +const plateOf = (a: NonNullable[number]) => + [a.vehicle?.code, a.vehicle?.plateNumber].filter(Boolean).join(' · ') || a.vehicleId; + /** - * View/override the detention clock (arrival + delivery/return) for a last-mile - * leg, preview the per-truck-per-day charge, and generate the detention invoice. + * Detention is PER TRUCK: every truck reaches the destination and is released at + * its own time, so each row carries its own clock, days and amount. Legs with no + * trucks assigned fall back to the single leg-level window. */ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionModalProps) { const { toast } = useToast(); const qc = useQueryClient(); const id = record?.id ?? null; + const assignments = record?.vehicleAssignments ?? []; + const perTruck = assignments.length > 0; + + const [rows, setRows] = useState([]); + // Leg-level fallback (no trucks assigned yet). const [arrived, setArrived] = useState(null); const [delivered, setDelivered] = useState(null); useEffect(() => { + setRows( + assignments.map((a) => ({ + vehicleId: a.vehicleId, + label: plateOf(a), + // Fall back to the leg-level pair so a truck without its own window + // shows what it is actually being billed on today. + arrived: a.destinationArrivedAt + ? new Date(a.destinationArrivedAt) + : record?.arrivedAt + ? new Date(record.arrivedAt) + : null, + returned: a.returnedAt + ? new Date(a.returnedAt) + : record?.deliveredAt + ? new Date(record.deliveredAt) + : null, + })), + ); setArrived(record?.arrivedAt ? new Date(record.arrivedAt) : null); setDelivered(record?.deliveredAt ? new Date(record.deliveredAt) : null); - }, [record?.id, record?.arrivedAt, record?.deliveredAt, opened]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [record?.id, record?.arrivedAt, record?.deliveredAt, assignments.length, opened]); const previewQuery = useQuery({ queryKey: ['truck-detention-preview', id], @@ -65,19 +100,35 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM enabled: opened && Boolean(id), }); const preview = previewQuery.data; + // With several trucks the header rule is null by design (each truck resolves + // its own) — only warn when NO truck matched a rule. + const hasAnyRule = Boolean(preview?.ruleId) || (preview?.groups ?? []).some((g) => g.ruleId); + const byVehicle = new Map((preview?.groups ?? []).map((g) => [g.vehicleId ?? '', g])); const saveTimes = useMutation({ mutationFn: () => - lastMileService.update(id as string, { - arrivedAt: arrived ? arrived.toISOString() : null, - deliveredAt: delivered ? delivered.toISOString() : null, - }), + perTruck + ? lastMileService.setDetentionTimes( + id as string, + rows.map((r) => ({ + vehicleId: r.vehicleId, + destinationArrivedAt: r.arrived ? r.arrived.toISOString() : null, + returnedAt: r.returned ? r.returned.toISOString() : null, + })), + ) + : lastMileService.update(id as string, { + arrivedAt: arrived ? arrived.toISOString() : null, + deliveredAt: delivered ? delivered.toISOString() : null, + }), onSuccess: () => { void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT }); void previewQuery.refetch(); toast({ title: 'Detention times saved' }); }, - onError: () => toast({ title: 'Save failed', variant: 'destructive' }), + onError: (e: unknown) => { + const description = (e as { response?: { data?: { message?: string } } })?.response?.data?.message; + toast({ title: 'Save failed', description, variant: 'destructive' }); + }, }); const generate = useMutation({ @@ -93,12 +144,40 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM }, }); + const handleSave = () => { + const values = perTruck + ? rows.flatMap((r) => [r.arrived, r.returned]) + : [arrived, delivered]; + // No backdating: detention times are recorded as they happen. + if (values.some((v) => isBackdated(v))) { + toast({ variant: 'destructive', title: 'Detention times cannot be in the past' }); + return; + } + const reversed = perTruck + ? rows.find((r) => r.arrived && r.returned && r.returned < r.arrived) + : arrived && delivered && delivered < arrived + ? { label: 'this delivery' } + : undefined; + if (reversed) { + toast({ + variant: 'destructive', + title: 'Return time is before arrival', + description: `Check the times for ${reversed.label}.`, + }); + return; + } + saveTimes.mutate(); + }; + + const patchRow = (vehicleId: string, patch: Partial) => + setRows((prev) => prev.map((r) => (r.vehicleId === vehicleId ? { ...r, ...patch } : r))); + return ( Truck detention{record?.booking?.reference ? ` · ${record.booking.reference}` : ''} @@ -106,40 +185,94 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM } > - - setArrived(v ? new Date(v) : null)} - minDate={new Date()} - clearable - /> - setDelivered(v ? new Date(v) : null)} - minDate={new Date()} - clearable - /> - + {perTruck ? ( + + + Each truck has its own detention clock — record when it reached the destination and + when it was released. Days and charges are calculated per truck. + + {rows.map((r) => { + const g = byVehicle.get(r.vehicleId); + return ( + + + + + {r.label} + + {g?.vehicleType && ( + + {g.vehicleType} + + )} + + {g && ( + + + {g.chargeableDays} day{g.chargeableDays === 1 ? '' : 's'} + {g.endIsOpen ? ' · still out' : ''} + + + {money(g.amount, preview?.currency ?? 'USD')} + + + )} + + + patchRow(r.vehicleId, { arrived: v ? new Date(v) : null })} + minDate={new Date()} + clearable + /> + patchRow(r.vehicleId, { returned: v ? new Date(v) : null })} + minDate={new Date()} + clearable + /> + + {g && !g.ruleId && ( + + No detention rule matches this truck type — it will not be billed. + + )} + + ); + })} + + ) : ( + <> + + No trucks assigned yet — this records the delivery-level detention window. Assign + trucks to track each one separately. + + + setArrived(v ? new Date(v) : null)} + minDate={new Date()} + clearable + /> + setDelivered(v ? new Date(v) : null)} + minDate={new Date()} + clearable + /> + + + )} - @@ -154,7 +287,7 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM No preview available. - ) : !preview.ruleId ? ( + ) : !hasAnyRule ? ( No active Truck Detention rule matches this booking. Create one under Warehouse → Fee rules (rule type "Truck Detention Cost"). @@ -162,39 +295,47 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM ) : ( - + - + {preview.endIsOpen && ( - Still accruing — no delivery/return time yet. The amount grows until the vehicle is returned. + Still accruing — at least one truck has no release time yet. The amount grows until + every truck is returned. )} - {preview.groups && preview.groups.length > 1 ? ( + {preview.groups && preview.groups.length > 0 ? ( - Truck type - Trucks + Truck + Type Days - Rate / truck / day + Rate / day Amount {preview.groups.map((g, i) => ( - + - {g.vehicleType ?? 'Unknown'} + {g.plateNumber ?? 'Unassigned'} {!g.ruleId && ( {' '}· no rule )} - {g.truckCount} - {g.chargeableDays} + {g.vehicleType ?? 'Unknown'} + + {g.chargeableDays} + {g.endIsOpen && ( + + {' '}· open + + )} + {money(g.ratePerDay, preview.currency)} {money(g.amount, preview.currency)} diff --git a/apps/edr-freight-web/backoffice/src/components/operations/WarehouseGateTimesModal.tsx b/apps/edr-freight-web/backoffice/src/components/operations/WarehouseGateTimesModal.tsx new file mode 100644 index 000000000..6ab317271 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/operations/WarehouseGateTimesModal.tsx @@ -0,0 +1,156 @@ +import { + Button, + Divider, + Group, + Modal, + Stack, + Table, + Text, +} from '@mantine/core'; +import { DateTimePicker } from '@mantine/dates'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; + +import { useToast } from '@/hooks/use-toast'; +import { lastMileService, type LastMileRecord } from '@/services/last-mile.service'; + +interface WarehouseGateTimesModalProps { + opened: boolean; + onClose: () => void; + record: LastMileRecord | null; +} + +const plateOf = (a: NonNullable[number]) => + [a.vehicle?.code, a.vehicle?.plateNumber].filter(Boolean).join(' · ') || a.vehicleId; + +export function WarehouseGateTimesModal({ opened, onClose, record }: WarehouseGateTimesModalProps) { + const { toast } = useToast(); + const qc = useQueryClient(); + const id = record?.id ?? null; + const assignments = record?.vehicleAssignments ?? []; + + interface TruckRow { + vehicleId: string; + label: string; + arrivedAt: Date | null; + departedAt: Date | null; + } + + const [rows, setRows] = useState>([]); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (assignments.length > 0) { + setRows( + assignments.map((a) => ({ + vehicleId: a.vehicleId, + label: plateOf(a), + arrivedAt: a.arrivedAt ? new Date(a.arrivedAt) : null, + departedAt: a.departedAt ? new Date(a.departedAt) : null, + })), + ); + } + }, [assignments, opened]); + + const updateMutation = useMutation({ + mutationFn: () => { + if (!id) return Promise.resolve(null); + return lastMileService.setWarehouseGateTimes(id, rows.map(r => ({ + vehicleId: r.vehicleId, + arrivedAt: r.arrivedAt?.toISOString() ?? null, + departedAt: r.departedAt?.toISOString() ?? null, + }))); + }, + onSuccess: () => { + toast({ + title: 'Warehouse gate times updated', + }); + qc.invalidateQueries({ queryKey: ['last-mile-record-import-trucks', id] }); + onClose(); + }, + onError: (error: any) => { + toast({ + variant: 'destructive', + title: 'Failed to update warehouse gate times', + description: error?.response?.data?.message || error?.message, + }); + }, + onSettled: () => { + setSaving(false); + }, + }); + + const handleSave = async () => { + setSaving(true); + await updateMutation.mutateAsync(); + }; + + return ( + + + + Set arrival (gate-in) and departure (gate-out) times for each truck. + + + {/* @ts-ignore - DateTimePicker type inference issue with row state */} +
+ + + Plate + Arrived At (Gate-In) + Departed At (Gate-Out) + + + + {rows.map((row: TruckRow, idx: number) => ( + + + + {row.label} + + + + { + const newRows = [...rows]; + newRows[idx] = { ...row, arrivedAt: date }; + setRows(newRows); + }} + clearable + size="sm" + /> + + + { + const newRows = [...rows]; + newRows[idx] = { ...row, departedAt: date }; + setRows(newRows); + }} + clearable + size="sm" + /> + + + ))} + +
+ + + + + + + +
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx index 5eeee7d13..d3c8e1897 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx @@ -37,6 +37,14 @@ function fmtDate(iso: string | null) { return new Date(iso).toLocaleDateString(); } +const UNIT_LABEL_PLURAL: Record = { + container: 'Containers', + truck: 'Trucks', + ton: 'Tons', + item: 'Items', +}; +const unitLabelPlural = (unitLabel?: string) => UNIT_LABEL_PLURAL[unitLabel ?? 'container'] ?? 'Containers'; + const money = (amount: number, currency: string) => `${Number(amount).toLocaleString()} ${currency === 'ETB' ? 'Birr (ETB)' : currency}`; @@ -72,8 +80,11 @@ function FeeCard({ fee }: { fee: FeePreview }) { - - + + {(fee.tiers ?? []).map((tier) => ( { - const normalized = (freightType ?? '').toUpperCase(); - if (normalized === 'CONTAINER') { - return { yardTypes: ['CONTAINER_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['CONTAINER_ZONE', 'GENERAL_CARGO_ZONE'] }; - } - return { yardTypes: ['BULK_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['BULK_ZONE', 'GENERAL_CARGO_ZONE'] }; -}; - const isImportContainerFreight = (freightType: string | null | undefined) => (freightType ?? '').toUpperCase() === 'CONTAINER'; @@ -2037,10 +2031,60 @@ function ImportTrainDetailTable({ enabled: Boolean(train.scheduleId), }), ); - const warehouseOptions = useMemo( - () => warehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })), - [warehouses], + // A train only ever unloads at the warehouse actually sitting at its + // destination station — Indode's train never offers Sebeta's warehouse. + const scopedWarehouses = useMemo( + () => warehousesAtStation(warehouses, train.destinationStationId), + [warehouses, train.destinationStationId], ); + const warehouseOptions = useMemo( + () => scopedWarehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })), + [scopedWarehouses], + ); + // With exactly one warehouse at the station there is nothing to choose — + // pre-fill it so staff only has to pick yard/zone, not re-discover Indode. + useEffect(() => { + if (scopedWarehouses.length !== 1) return; + const onlyWarehouseId = scopedWarehouses[0].id; + items.filter(isImportUnloadPending).forEach((item) => { + if (!assignments[item.bookingId]?.warehouseId) { + onAssignmentChange(item.bookingId, { ...assignments[item.bookingId], warehouseId: onlyWarehouseId }); + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [scopedWarehouses, items]); + + // Once a booking's warehouse is known, its yard (and then zone) follow from + // what the cargo actually is — a Wheat booking only ever has one candidate + // yard (Dry Bulk) once Indode's real yard layout is configured, so staff + // never see a picker for something that isn't actually a choice. + useEffect(() => { + items.filter(isImportUnloadPending).forEach((item) => { + const draft = assignments[item.bookingId]; + if (!draft?.warehouseId) return; + + if (!draft.yardId) { + const candidateYards = yardsForBooking(yards, { + warehouseId: draft.warehouseId, + freightType: item.freightType, + tradeDirection: 'IMPORT', + cargoTypeCode: item.cargoTypeCode, + }); + if (candidateYards.length === 1) { + onAssignmentChange(item.bookingId, { ...draft, yardId: candidateYards[0].id }); + } + return; + } + + if (!draft.zoneId) { + const candidateZones = zones.filter((zone) => zone.yardId === draft.yardId); + if (candidateZones.length === 1) { + onAssignmentChange(item.bookingId, { ...draft, zoneId: candidateZones[0].id }); + } + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [assignments, items, yards, zones]); useEffect(() => { const pending = items.filter(isImportUnloadPending); @@ -2091,12 +2135,17 @@ function ImportTrainDetailTable({ {items.map((it: ImportTrainItem) => { const draft = assignments[it.bookingId] ?? {}; - const { yardTypes, zoneTypes } = importLocationTypesForFreight(it.freightType); - const yardOptions = yards - .filter((yard) => yard.warehouseId === draft.warehouseId && yardTypes.includes(yard.type)) - .map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` })); + const yardOptions = yardsForBooking(yards, { + warehouseId: draft.warehouseId, + freightType: it.freightType, + tradeDirection: 'IMPORT', + cargoTypeCode: it.cargoTypeCode, + }).map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` })); + // The yard is already scoped to what this cargo can go into — a + // zone's own type always matches its parent yard's purpose (see the + // Indode seed migration), so no separate zone-type filter is needed. const zoneOptions = zones - .filter((zone) => zone.yardId === draft.yardId && zoneTypes.includes(zone.type)) + .filter((zone) => zone.yardId === draft.yardId) .map((zone) => ({ value: zone.id, label: `${zone.name} (${zone.code})` })); const pending = isImportUnloadPending(it); @@ -2721,6 +2770,40 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { )} setInspectId(r.id)}>Inspect / report + {/* Double handling is decided once the goods are off + the wagon (every row here is unloaded) — Yes is + what makes the fee rule bill this booking. */} + + + Double handling —{' '} + {r.doubleHandling == null ? 'not set' : r.doubleHandling ? 'Yes' : 'No'} + + : + } + disabled={!r.bookingId || r.doubleHandling === true} + onClick={() => + runRowAction(r, 'Double handling: Yes — fee rule applies', () => + warehouseService.setDoubleHandling(r.bookingId as string, true), + ) + } + > + Yes — apply fee + + : + } + disabled={!r.bookingId || r.doubleHandling === false} + onClick={() => + runRowAction(r, 'Double handling: No', () => + warehouseService.setDoubleHandling(r.bookingId as string, false), + ) + } + > + No + } onClick={() => setFeeItem(toInventoryItem(r))}> Storage / fee preview diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/options.test.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/options.test.ts new file mode 100644 index 000000000..9c0181b40 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/options.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from "vitest"; + +import { warehousesAtStation, yardsForBooking } from "./options"; +import type { Warehouse, WarehouseYard } from "@/types/warehouse"; + +// Mirrors Indode's real 11-yard layout at a reduced scale, so these cases read +// against the actual booking-routing decisions staff rely on. +const yard = (overrides: Partial): WarehouseYard => + ({ + id: overrides.code, + warehouseId: "indode", + name: overrides.code, + code: overrides.code, + type: "GENERAL_CARGO_YARD", + capacityWeight: null, + capacityContainers: null, + maxWeight: null, + maxVolume: null, + currentWeight: 0, + currentContainers: 0, + currentVolume: 0, + status: "ACTIVE", + isActive: true, + ...overrides, + }) as WarehouseYard; + +const YARDS: WarehouseYard[] = [ + yard({ code: "Y2", type: "GENERAL_CARGO_YARD", cargoTypes: [{ id: "1", code: "STEEL_BILLET" }] }), + yard({ code: "Y3", type: "GENERAL_CARGO_YARD", cargoTypes: [{ id: "2", code: "AUTOMOBILE" }, { id: "3", code: "TRUCK" }] }), + yard({ code: "Y4", type: "BULK_YARD", status: "INACTIVE", isActive: false, cargoTypes: [{ id: "4", code: "WHEAT" }] }), + yard({ code: "Y5", type: "CONTAINER_YARD", direction: "IMPORT" }), + yard({ code: "Y6", type: "CONTAINER_YARD", direction: "EXPORT" }), + yard({ code: "Y10", type: "CONTAINER_YARD", direction: "BOTH" }), // service yard + yard({ code: "Y11", type: "CONTAINER_YARD", direction: "BOTH" }), // equipment yard +]; + +describe("yardsForBooking", () => { + it("container import narrows to exactly the import stack", () => { + const result = yardsForBooking(YARDS, { + warehouseId: "indode", + freightType: "CONTAINER", + tradeDirection: "IMPORT", + cargoTypeCode: null, + }); + expect(result.map((y) => y.code)).toEqual(["Y5"]); + }); + + it("container export narrows to exactly the export stack", () => { + const result = yardsForBooking(YARDS, { + warehouseId: "indode", + freightType: "CONTAINER", + tradeDirection: "EXPORT", + cargoTypeCode: null, + }); + expect(result.map((y) => y.code)).toEqual(["Y6"]); + }); + + it("never offers a BOTH-direction container yard (service/equipment) for ordinary cargo", () => { + const result = yardsForBooking(YARDS, { + warehouseId: "indode", + freightType: "CONTAINER", + tradeDirection: "IMPORT", + cargoTypeCode: null, + }); + expect(result.map((y) => y.code)).not.toContain("Y10"); + expect(result.map((y) => y.code)).not.toContain("Y11"); + }); + + it("bulk cargo narrows to the yard configured for that exact cargo type", () => { + const automobile = yardsForBooking(YARDS, { + warehouseId: "indode", + freightType: "BULK", + tradeDirection: "IMPORT", + cargoTypeCode: "AUTOMOBILE", + }); + expect(automobile.map((y) => y.code)).toEqual(["Y3"]); + + const steel = yardsForBooking(YARDS, { + warehouseId: "indode", + freightType: "BULK", + tradeDirection: "IMPORT", + cargoTypeCode: "STEEL_BILLET", + }); + expect(steel.map((y) => y.code)).toEqual(["Y2"]); + }); + + it("falls back to every non-container yard when the one configured for this cargo type is closed", () => { + // Y4 (Dry Bulk, WHEAT) is inactive — never strand staff with an empty + // picker just because the ideal yard is closed; same safety net as + // warehousesAtStation falling back when a station has no mapped warehouse. + const result = yardsForBooking(YARDS, { + warehouseId: "indode", + freightType: "BULK", + tradeDirection: "IMPORT", + cargoTypeCode: "WHEAT", + }); + expect(result.map((y) => y.code).sort()).toEqual(["Y2", "Y3"]); + }); + + it("falls back to every non-container yard when no yard is configured for that cargo type yet", () => { + const result = yardsForBooking(YARDS, { + warehouseId: "indode", + freightType: "BULK", + tradeDirection: "IMPORT", + cargoTypeCode: "SOMETHING_UNMAPPED", + }); + expect(result.map((y) => y.code).sort()).toEqual(["Y2", "Y3"]); + }); + + it("a yard with no configured cargo types is open to anything (unconfigured, not restrictive)", () => { + const openYard = yard({ code: "GENERIC", type: "BULK_YARD" }); + const result = yardsForBooking([...YARDS, openYard], { + warehouseId: "indode", + freightType: "BULK", + tradeDirection: "IMPORT", + cargoTypeCode: "STEEL_BILLET", + }); + expect(result.map((y) => y.code).sort()).toEqual(["GENERIC", "Y2"]); + }); + + it("only offers yards at the requested warehouse", () => { + const otherWarehouseYard = yard({ code: "SEBETA-Y1", warehouseId: "sebeta", type: "GENERAL_CARGO_YARD" }); + const result = yardsForBooking([...YARDS, otherWarehouseYard], { + warehouseId: "indode", + freightType: "BULK", + tradeDirection: "IMPORT", + cargoTypeCode: null, + }); + expect(result.map((y) => y.code)).not.toContain("SEBETA-Y1"); + }); +}); + +describe("warehousesAtStation", () => { + const warehouse = (id: string, stationId: string | null): Warehouse => + ({ id, stationId, name: id, code: id } as Warehouse); + + it("restricts to the warehouse at the given station", () => { + const warehouses = [warehouse("indode", "station-a"), warehouse("sebeta", "station-b")]; + const result = warehousesAtStation(warehouses, "station-a"); + expect(result.map((w) => w.id)).toEqual(["indode"]); + }); + + it("falls back to every warehouse when the station has no match", () => { + const warehouses = [warehouse("indode", "station-a"), warehouse("sebeta", "station-b")]; + const result = warehousesAtStation(warehouses, "station-unknown"); + expect(result).toEqual(warehouses); + }); + + it("falls back to every warehouse when the station is null", () => { + const warehouses = [warehouse("indode", "station-a")]; + expect(warehousesAtStation(warehouses, null)).toEqual(warehouses); + }); +}); diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/options.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/options.ts index d124a3325..958e2d7f7 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/options.ts +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/options.ts @@ -4,6 +4,8 @@ import { WAREHOUSE_ZONE_TYPES, WAREHOUSE_STATUSES, INVENTORY_STATUSES, + type Warehouse, + type WarehouseYard, } from '@/types/warehouse'; export const humanizeEnum = (value: string) => @@ -16,6 +18,58 @@ export const humanizeEnum = (value: string) => const toOptions = (values: readonly string[]) => values.map((value) => ({ value, label: humanizeEnum(value) })); +/** + * Warehouses actually located at a train's station — e.g. a train destined for + * Indode should only offer Indode's own warehouse, not Sebeta's or Modjo's. + * Falls back to every warehouse when the station is unmapped (no `stationId` + * match anywhere), so unusual/legacy data never blocks the unload flow entirely. + */ +export const warehousesAtStation = (warehouses: Warehouse[], stationId: string | null | undefined) => { + if (!stationId) return warehouses; + const atStation = warehouses.filter((w) => w.stationId === stationId); + return atStation.length ? atStation : warehouses; +}; + +/** + * Yards at ONE warehouse eligible to receive a booking, given what it actually + * is — e.g. at Indode: container import always narrows to Yard 5, export to + * Yard 6; a Wheat booking narrows to Yard 4 (Dry Bulk), not Break Bulk or + * Coffee/Tea. Mirrors `warehousesAtStation`'s fallback philosophy: an + * unconfigured yard (no cargo types set) stays open rather than disappearing, + * but a yard that IS configured for other cargo never shows for a mismatch. + * + * Container yards are the one case with no such fallback: a CONTAINER_YARD + * left at direction BOTH/null (Indode's Yard 10 service yard, Yard 11 + * equipment yard) is a service/equipment yard, not a customer cargo yard, and + * must never be offered just because the exact-direction stack is missing. + */ +export const yardsForBooking = ( + yards: WarehouseYard[], + params: { + warehouseId: string | null | undefined; + freightType: string | null | undefined; + tradeDirection: string | null | undefined; + cargoTypeCode: string | null | undefined; + }, +): WarehouseYard[] => { + const atWarehouse = yards.filter((y) => y.warehouseId === params.warehouseId && y.isActive); + const isContainer = (params.freightType ?? '').toUpperCase() === 'CONTAINER'; + + if (isContainer) { + const direction = (params.tradeDirection ?? '').toUpperCase(); + return atWarehouse.filter((y) => y.type === 'CONTAINER_YARD' && y.direction === direction); + } + + const nonContainer = atWarehouse.filter((y) => y.type !== 'CONTAINER_YARD'); + if (!params.cargoTypeCode) return nonContainer; + + const cargoMatched = nonContainer.filter((y) => { + const codes = (y.cargoTypes ?? []).map((c) => c.code); + return codes.length === 0 || codes.includes(params.cargoTypeCode as string); + }); + return cargoMatched.length ? cargoMatched : nonContainer; +}; + export const warehouseTypeOptions = toOptions(WAREHOUSE_TYPES); export const yardTypeOptions = toOptions(WAREHOUSE_YARD_TYPES); export const zoneTypeOptions = toOptions(WAREHOUSE_ZONE_TYPES); diff --git a/apps/edr-freight-web/backoffice/src/locales/am/translation.json b/apps/edr-freight-web/backoffice/src/locales/am/translation.json index 99c1e0329..f8e26a07a 100644 --- a/apps/edr-freight-web/backoffice/src/locales/am/translation.json +++ b/apps/edr-freight-web/backoffice/src/locales/am/translation.json @@ -1649,6 +1649,8 @@ "notFoundError": "የፈለጉትን መረጃ አልተገኘም።", "fileTooLarge": "ፋይሉ በጣም ትልቅ ነው። እባክዎ ፋይሉን አሳንሰው ዳግም ይሞክሩ።", "serverError": "ከአገልጋይ በኩል ችግር አለ። እባክዎ ዳግመኛ ይሞክሩ።", + "userRoleNotFound": "ይህ የአስተዳዳሪ ሚና ምደባ አልተገኘም — ቀደም ብሎ ተወግዶ ሊሆን ይችላል።", + "unitEmployeeLimitReached": "ይህ ክፍል የ{{limit}} ሰራተኞች ገደብ ላይ ደርሷል።", "attachmentDeleted": "አባሪው በተሳካ ሁኔታ ተሰርዟል።", "replyAdded": "ምላሹ በተሳካ ሁኔታ ታክሏል!", "replyError": "ምላሹን በመጨመር ላይ ስህተት አጋጥሟል።", @@ -2653,6 +2655,7 @@ "copyPermissionsHint": "የነበረ የቦታ አይነት ይምረጡ፤ ፍቃዶቹ አስቀድመው ይሞላሉ፣ ከታች ማስተካከል ይችላሉ።", "copyPermissionsFailed": "ፍቃዶችን መቅዳት አልተቻለም", "selectOrganizationToCopy": "መቅዳት የሚችሏቸውን የቦታ ዓይነቶች ለማየት መጀመሪያ ድርጅት ይምረጡ", + "selectUnitToCopy": "መቅዳት የሚችሏቸውን የቦታ ዓይነቶች ለማየት መጀመሪያ ክፍል ይምረጡ", "cannotClearAllPermissions": "ተቀምጧል። ፍቃዶቹ አልተቀየሩም — ይህ የቦታ ዓይነት ቢያንስ አንድ ፍቃድ ሊኖረው ይገባል።", "permissionsSelected": "{{count}} ተመርጠዋል", "positionTypeCreated": "የቦታ ዓይነት ተፈጥሯል", @@ -7491,12 +7494,22 @@ "loadError": "አስተዳዳሪዎችን መጫን አልተሳካም።", "pickerError": "ድርጅቶችን መጫን አልተሳካም።", "addAdmin": "አስተዳዳሪ ጨምር", + "managePermissions": "ፍቃዶችን ያስተዳድሩ", "add": { "title": "አስተዳዳሪ ጨምር", "description": "የተጠቃሚ መለያ ይፍጠሩ እና በዚህ ድርጅት ውስጥ የአስተዳዳሪ መዳረሻ ይስጡ።", "submit": "አስተዳዳሪ ጨምር", "inviteNote": "ተጠቃሚው ይፈጠራል እና የይለፍ ቃሉን እንዲያዘጋጅ የኤስኤምኤስ ግብዣ ይደርሰዋል።", "noUnitsOrgAdmin": "ይህ ድርጅት ክፍሎች የሉትም — አስተዳዳሪው እንደ የድርጅት አስተዳዳሪ ይጨመራል።" + }, + "permissions": { + "title": "የድርጅት አስተዳዳሪ ፍቃዶች", + "subtitle": "እያንዳንዱ የድርጅት አስተዳዳሪ በመድረኩ ላይ ምን ማድረግ እንደሚችል ይምረጡ።", + "backToAdmins": "ወደ ድርጅት አስተዳዳሪዎች ይመለሱ", + "roleNotFound": "የድርጅት አስተዳዳሪ ሚና ማግኘት አልተቻለም።", + "saved": "የድርጅት አስተዳዳሪ ፍቃዶች ተዘምነዋል።", + "saveFailed": "የድርጅት አስተዳዳሪ ፍቃዶችን ማዘመን አልተቻለም።", + "cannotClearAll": "ተቀምጧል። ፍቃዶቹ አልተቀየሩም — የድርጅት አስተዳዳሪ ሚና ቢያንስ አንድ ፍቃድ ሊኖረው ይገባል።" } } } diff --git a/apps/edr-freight-web/backoffice/src/locales/en/translation.json b/apps/edr-freight-web/backoffice/src/locales/en/translation.json index f1357c2bf..66deb4395 100644 --- a/apps/edr-freight-web/backoffice/src/locales/en/translation.json +++ b/apps/edr-freight-web/backoffice/src/locales/en/translation.json @@ -1669,6 +1669,8 @@ "notFoundError": "We couldn't find what you were looking for.", "fileTooLarge": "The file is too large. Please reduce the file size and try again.", "serverError": "Something went wrong on our side. Please try again in a moment.", + "userRoleNotFound": "This admin role assignment could not be found — it may have already been removed.", + "unitEmployeeLimitReached": "This unit has reached its limit of {{limit}} employees.", "attachmentDeleted": "Attachment deleted successfully.", "replyAdded": "Reply added successfully!", "replyError": "An error occurred while adding the reply.", @@ -2762,6 +2764,7 @@ "copyPermissionsHint": "Pick an existing position type to pre-fill its permissions, then edit below.", "copyPermissionsFailed": "Failed to copy permissions", "selectOrganizationToCopy": "Select an organization to see the position types you can copy from", + "selectUnitToCopy": "Select a unit to see the position types you can copy from", "cannotClearAllPermissions": "Saved. Permissions were left unchanged — this position type must keep at least one permission.", "permissionsSelected": "{{count}} selected", "positionTypeCreated": "Position type created", @@ -7492,12 +7495,22 @@ "loadError": "Failed to load admins.", "pickerError": "Failed to load organizations.", "addAdmin": "Add Admin", + "managePermissions": "Manage Permissions", "add": { "title": "Add Admin", "description": "Create a user account and grant admin access in this organization.", "submit": "Add Admin", "inviteNote": "The user is created and receives an SMS invitation to set their password.", "noUnitsOrgAdmin": "This organization has no units — the admin will be added as an organization admin." + }, + "permissions": { + "title": "Organization Admin Permissions", + "subtitle": "Choose what every Organization Admin can do across the platform.", + "backToAdmins": "Back to Organization Admins", + "roleNotFound": "Could not find the Organization Admin role.", + "saved": "Organization Admin permissions updated.", + "saveFailed": "Failed to update Organization Admin permissions.", + "cannotClearAll": "Saved. Permissions were left unchanged — the Organization Admin role must keep at least one permission." } } } diff --git a/apps/edr-freight-web/backoffice/src/locales/fr/translation.json b/apps/edr-freight-web/backoffice/src/locales/fr/translation.json index bd5e17fae..b888a2419 100644 --- a/apps/edr-freight-web/backoffice/src/locales/fr/translation.json +++ b/apps/edr-freight-web/backoffice/src/locales/fr/translation.json @@ -1169,6 +1169,8 @@ "notFoundError": "Nous n’avons pas trouvé ce que vous cherchiez.", "fileTooLarge": "Le fichier est trop volumineux. Veuillez réduire sa taille et réessayer.", "serverError": "Un problème est survenu de notre côté. Veuillez réessayer dans un instant.", + "userRoleNotFound": "Cette attribution de rôle d'administrateur est introuvable — elle a peut-être déjà été supprimée.", + "unitEmployeeLimitReached": "Cette unité a atteint sa limite de {{limit}} employés.", "attachmentDeleted": "Pièce jointe supprimée avec succès.", "replyAdded": "Réponse ajoutée avec succès !", "replyError": "Une erreur s’est produite lors de l’ajout de la réponse.", @@ -1888,6 +1890,7 @@ "copyPermissionsHint": "Choisissez un type de poste existant pour préremplir ses autorisations, puis modifiez ci-dessous.", "copyPermissionsFailed": "Échec de la copie des autorisations", "selectOrganizationToCopy": "Sélectionnez une organisation pour voir les types de poste que vous pouvez copier", + "selectUnitToCopy": "Sélectionnez une unité pour voir les types de poste que vous pouvez copier", "cannotClearAllPermissions": "Enregistré. Les autorisations n'ont pas été modifiées — ce type de poste doit conserver au moins une autorisation.", "permissionsSelected": "{{count}} sélectionné(s)", "positionTypeCreated": "Type de poste créé", diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx index fbd851d73..657bcc0ae 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx @@ -6,6 +6,7 @@ import { LayoutGrid, Milestone, Package, + Truck, } from "lucide-react"; import { Container, @@ -36,6 +37,7 @@ import { BookingContractSummaryCard, BookingContainerUnitsCard, BookingDocumentsPanel, + BookingTrucksPanel, ContractOrdersPanel, } from "@/components/bookings/detail"; import { WarehouseInfoCard } from "@/components/warehouses"; @@ -141,7 +143,9 @@ export default function BookingRequestDetailPage() { ? "orders" : requestedTab === "documents" ? "documents" - : "overview"; + : requestedTab === "trucks" + ? "trucks" + : "overview"; const setActiveTab = (tab: string | null) => { const next = new URLSearchParams(searchParams); if (tab && tab !== "overview") next.set("tab", tab); @@ -207,6 +211,9 @@ export default function BookingRequestDetailPage() { > Documents + }> + Trucks + @@ -223,6 +230,9 @@ export default function BookingRequestDetailPage() { + + + diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index 35488b94f..666069d98 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -607,8 +607,13 @@ export default function CustomerDetailPage() { { label: "PoA address", value: company?.poaAddress }, ]; const hasPoaDetails = poaFields.some((f) => f.value?.trim()); + // Shared with the portal (buildCompanyIdentityState) — same derivation, so + // this page can never disagree with the rule the API actually enforces. + const ownerIdentity = company?.identity?.owner; + const poaIdentity = company?.identity?.poa; + const hasEtradeRecord = Boolean(company?.licenceNumber?.trim()); // A freight forwarder acts on other companies' behalf, so its PoA — details - // and delegation letter both — is mandatory rather than optional. + // and DARS delegation paper both — is mandatory rather than optional. const poaMandatory = (company?.companyProfiles ?? []).some( (p) => p.type === "freight_forwarder", ); @@ -752,6 +757,16 @@ export default function CustomerDetailPage() { + @@ -783,6 +798,97 @@ export default function CustomerDetailPage() { + + + + + eTrade registration + + {hasEtradeRecord ? ( + + Verified with eTrade + + ) : ( + + No eTrade record + + )} + + {hasEtradeRecord ? ( + + + + + + + + + + + + + + ) : ( + + No eTrade registration record on file for this customer's + TIN. + + )} + + + + + + + + Owner identity + + {ownerIdentity?.verified ? ( + + Fayda verified + + ) : ( + + Not verified + + )} + + {ownerIdentity?.verified ? ( + + + + + + + + + + + ) : ( + + {ownerIdentity?.passportNumber + ? `Not Fayda verified — identified by passport ${ownerIdentity.passportNumber}.` + : "The company owner has not verified their identity with Fayda."} + + )} + + + @@ -798,11 +904,11 @@ export default function CustomerDetailPage() { {delegationMissing ? ( - Delegation letter missing + DARS delegation paper missing ) : poaLive.length > 0 ? ( - Delegation letter on file + DARS delegation paper on file ) : ( @@ -820,6 +926,25 @@ export default function CustomerDetailPage() { value={f.value} /> ))} + + {poaIdentity?.verified && ( + <> + + + + + )} ) : ( @@ -838,7 +963,7 @@ export default function CustomerDetailPage() { tt="uppercase" style={{ letterSpacing: "0.04em" }} > - Delegation letter + DARS delegation paper {documentsQuery.isLoading ? ( @@ -864,7 +989,7 @@ export default function CustomerDetailPage() { ) : poaDocuments.length === 0 ? ( - No delegation letter uploaded. + No DARS delegation paper uploaded. ) : ( poaDocuments.map((doc) => ( diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index a8edab7ae..4145beb31 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -945,6 +945,45 @@ const LastMilePage = () => { return out.length ? out : activeRecord ? bookingContainerNumbers(activeRecord) : []; }, [assignBooking, activeRecord]); + // Container number → size ("20ft"/"40ft"), driving the per-truck cap: a 40ft + // fills the truck alone; two 20ft may share (no size mixing). + const sizeByNumber = useMemo(() => { + const map = new Map(); + const lines = assignBooking?.bookingContainers?.length + ? assignBooking.bookingContainers + : activeRecord?.booking?.bookingContainers ?? []; + for (const line of lines) { + // The two payload shapes differ: the list record carries `containerSize`, + // the booking detail exposes the size on its container type. + const c = line as { + containerSize?: string | null; + containerNumber?: string | null; + containerType?: { code?: string; label?: string; sizeFt?: number }; + units?: Array<{ containerNumber?: string | null }>; + }; + const size = String( + c.containerSize ?? c.containerType?.sizeFt ?? c.containerType?.code ?? c.containerType?.label ?? "", + ); + for (const u of c.units ?? []) { + if (u.containerNumber) map.set(u.containerNumber, size); + } + if (c.containerNumber) map.set(c.containerNumber, size); + } + return map; + }, [assignBooking, activeRecord]); + const is40 = (n: string) => (sizeByNumber.get(n) ?? "").includes("40"); + + // Trucks that already arrived/left keep their load locked — the API rejects + // changing or removing them; the modal greys those rows out. + const lockedVehicles = useMemo(() => { + const map = new Map(); + for (const a of activeRecord?.vehicleAssignments ?? []) { + if (a.departedAt) map.set(a.vehicleId, "left the warehouse"); + else if (a.arrivedAt) map.set(a.vehicleId, "arrived at the warehouse"); + } + return map; + }, [activeRecord]); + const pickupReadyByBooking = useMemo(() => { const map = new Map(); for (const row of pickupReadyRows) { @@ -1101,6 +1140,22 @@ const LastMilePage = () => { if (!targetIds.length) return; + // A 40ft container fills its truck — backstop for pre-filled reassignment + // rows the MultiSelect guard never saw. + const overloaded = vehicles.filter( + (v) => v.containerNumbers.length > 1 && v.containerNumbers.some(is40), + ); + if (overloaded.length) { + toast({ + title: "40ft fills the truck", + description: `${overloaded + .map((v) => vehicleLabelFor(v.vehicleId)) + .join("; ")} — a 40ft container travels alone.`, + variant: "destructive", + }); + return; + } + // Backstop for rows the Select guard never saw (pre-filled reassignments). const unpriced = vehicles .map((v) => ({ label: vehicleLabelFor(v.vehicleId), gap: pricingGapById.get(v.vehicleId) })) @@ -1385,7 +1440,21 @@ const LastMilePage = () => { status === "PAYMENT_PENDING" || (status === "READY_TO_TRANSIT" && assigned) || (status === "IN_TRANSIT" && hasDistance); - const canAssignStep = !assigned && status !== "DELIVERED"; + // Assign stays active until the whole load has trucks: container + // bookings until every container is on a truck; bulk until the + // tonnage is drawn down (trucks depart one by one). Already-departed + // trucks keep their rows locked in the modal. + const totalContainers = containerCount(row.original); + const assignedContainers = (row.original.vehicleAssignments ?? []).reduce( + (s, a) => s + (a.containers?.length ?? (a.containerNumber ? 1 : 0)), + 0, + ); + const containersRemain = totalContainers > 0 && assignedContainers < totalContainers; + const bulkCargo = totalContainers === 0; + const canAssignStep = + status !== "DELIVERED" && + !row.original.invoice && + (!assigned || containersRemain || (bulkCargo && status !== "IN_TRANSIT")); const canDistance = status === "IN_TRANSIT"; // Truck arrival/leaving are independent — each driven by its own // warehouse state — but both are done once the leg is IN_TRANSIT/DELIVERED. @@ -1822,16 +1891,22 @@ const LastMilePage = () => { ); } - const ok = picked === needed; + const coveredContainers = vehicleRows.reduce( + (s, r) => s + (r.vehicleId ? r.containerNumbers.length : 0), + 0, + ); + const ok = picked === needed && coveredContainers === containers; return ( - One truck (with trailer) carries {CONTAINERS_PER_VEHICLE} containers. - {picked > 0 && !ok && - ` You've selected ${picked} — ${picked < needed ? "add more" : "that's more than needed"}.`} + One 40ft container fills a truck; two 20ft share one (no size mixing). + {containers - coveredContainers > 0 && + ` ${containers - coveredContainers} container${containers - coveredContainers === 1 ? "" : "s"} still unassigned — keep adding trucks.`} + {picked > 0 && picked !== needed && + ` You've selected ${picked} vehicle${picked === 1 ? "" : "s"} — ${picked < needed ? "add more" : "that's more than needed"}.`} ); })()} @@ -1851,7 +1926,11 @@ const LastMilePage = () => { )} - {vehicleRows.map((row, i) => ( + {vehicleRows.map((row, i) => { + const lockReason = row.vehicleId ? lockedVehicles.get(row.vehicleId) : undefined; + const rowLocked = Boolean(lockReason); + const rowHas40 = row.containerNumbers.some(is40); + return ( + + + + + {applications?.map((app) => ( + + {localizedName(app.name)} + + ))} + + + + +
+ + +
+ +
+ +
+ + )} + +
+ + ); +} diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsColumnDefn.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsColumnDefn.tsx index 97c3bfbc6..8bbe7c587 100644 --- a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsColumnDefn.tsx +++ b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsColumnDefn.tsx @@ -32,13 +32,15 @@ export interface AdminRoleInfo { /** * all-admins/:id returns users who are org admins of the org OR unit admins of * one of its units; userRoles carries every role of the user, so match the org - * explicitly for the org-admin grant. + * explicitly for the org-admin grant. The unit-admin grant carries no + * organizationId of its own, only a unitId, so orgUnitIds (every unit that + * belongs to the selected org) is required to tell a same-org unit-admin + * grant apart from a same-user unit-admin grant in a different org. */ -// ponytail: unit relation isn't loaded, so a unit_admin grant from another org -// can't be told apart — acceptable, the server only returns admins of this org. export function getAdminRoleInfo( admin: OrgAdminUser, selectedOrgId: string, + orgUnitIds: Set, ): AdminRoleInfo { const roles = admin.userRoles ?? []; const isOrgAdmin = roles.some( @@ -47,7 +49,10 @@ export function getAdminRoleInfo( r.organizationId === selectedOrgId, ); const unitRole = roles.find( - (r) => r.role?.key === UNIT_ADMIN_ROLE_KEY && r.unitId, + (r) => + r.role?.key === UNIT_ADMIN_ROLE_KEY && + !!r.unitId && + orgUnitIds.has(r.unitId), ); return { isOrgAdmin, @@ -58,6 +63,7 @@ export function getAdminRoleInfo( interface ColumnCallbacks { selectedOrgId: string; + orgUnitIds: Set; localizedName: (name?: { am?: string; en?: string }) => string; onEdit: (admin: OrgAdminUser) => void; onResend: (admin: OrgAdminUser) => void; @@ -67,6 +73,7 @@ interface ColumnCallbacks { export function getOrgAdminsColumnDefn({ selectedOrgId, + orgUnitIds, localizedName, onEdit, onResend, @@ -118,7 +125,7 @@ export function getOrgAdminsColumnDefn({ id: "role", header: () => t("orgAdmins.columns.role"), cell: ({ row }) => { - const info = getAdminRoleInfo(row.original, selectedOrgId); + const info = getAdminRoleInfo(row.original, selectedOrgId, orgUnitIds); return (
{info.isOrgAdmin && ( @@ -179,7 +186,7 @@ export function getOrgAdminsColumnDefn({ enableHiding: false, cell: ({ row }) => { const admin = row.original; - const roleInfo = getAdminRoleInfo(admin, selectedOrgId); + const roleInfo = getAdminRoleInfo(admin, selectedOrgId, orgUnitIds); return ( diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsPage.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsPage.tsx index d5844a603..adebd98a5 100644 --- a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsPage.tsx @@ -1,7 +1,15 @@ import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; -import { Building2, Loader2, Plus, UserPlus, Users2 } from "lucide-react"; +import { Link } from "react-router-dom"; +import { + Building2, + Loader2, + Plus, + ShieldCheck, + UserPlus, + Users2, +} from "lucide-react"; import { Button } from "@/shared/common/ui/button"; import { Card, @@ -23,6 +31,8 @@ import { Badge } from "@/shared/common/ui/badge"; import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable"; import { useLocalizedName } from "@/shared/common/localizedName"; import { OrganizationDto } from "@/shared/dto/organization/organizationDto"; +import { useUnit } from "@/user-management/hooks/useUnit"; +import { UnitDto } from "@/user-management/dto/unit/unitDto"; import { OrgAdminUser, useOrgAdmins, @@ -81,6 +91,22 @@ export default function OrgAdminsPage() { skip: pageIndex * pageSize, }); + const { getList: getUnitList } = useUnit(); + // A unit-admin grant only carries a unitId, no organizationId — this is the + // set that tells "unit_admin of this org" apart from "unit_admin of some + // other org the same user also administers" (see getAdminRoleInfo). + const { data: orgUnitsResponse } = getUnitList(selectedOrg?.id ?? "", { + take: 3000, + skip: 0, + }); + const orgUnitIds = useMemo( + () => + new Set( + (orgUnitsResponse?.data?.items ?? []).map((unit: UnitDto) => unit.id), + ), + [orgUnitsResponse], + ); + useEffect(() => { setPageIndex(0); }, [selectedOrg?.id, pageSize]); @@ -170,6 +196,7 @@ export default function OrgAdminsPage() { () => getOrgAdminsColumnDefn({ selectedOrgId: selectedOrg?.id ?? "", + orgUnitIds, localizedName: localizedName as (name?: { am?: string; en?: string; @@ -183,19 +210,29 @@ export default function OrgAdminsPage() { onRemove: (admin, roleInfo) => setRemoveTarget({ admin, roleInfo }), }), // eslint-disable-next-line react-hooks/exhaustive-deps - [selectedOrg?.id], + [selectedOrg?.id, orgUnitIds], ); return (
- - {t("orgAdmins.title")} - -

- {t("orgAdmins.subtitle")} -

+
+
+ + {t("orgAdmins.title")} + +

+ {t("orgAdmins.subtitle")} +

+
+ +
{/* Org selector + summary */} diff --git a/apps/edr-freight-web/backoffice/src/super-admin/services/api/rolePermissionService.ts b/apps/edr-freight-web/backoffice/src/super-admin/services/api/rolePermissionService.ts new file mode 100644 index 000000000..1d96edc1f --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/super-admin/services/api/rolePermissionService.ts @@ -0,0 +1,25 @@ +import { withHeaders } from "@/record-management/services/api/withHeaders"; +import axiosInstance from "@/shared/services/axiosInstance"; +import { PermissionListResponse } from "@/user-management/dto/permissions/permissonDto"; +import { AxiosResponse } from "axios"; + +export interface AssignRolePermissionsPayload { + firstId: string; + secondIds: string[]; +} + +// GET /role-permissions/given-first/{roleId} +export const getPermissionsByRoleId = async ( + roleId: string, +): Promise> => + axiosInstance.get(`/role-permissions/given-first/${roleId}`, { + headers: withHeaders(), + }); + +// POST /role-permissions/assign-seconds-for-first +export const assignPermissionsToRole = async ( + payload: AssignRolePermissionsPayload, +): Promise> => + axiosInstance.post("/role-permissions/assign-seconds-for-first", payload, { + headers: withHeaders(), + }); diff --git a/apps/edr-freight-web/backoffice/src/super-admin/services/api/roleService.ts b/apps/edr-freight-web/backoffice/src/super-admin/services/api/roleService.ts new file mode 100644 index 000000000..6ede479d8 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/super-admin/services/api/roleService.ts @@ -0,0 +1,20 @@ +import { withHeaders } from "@/record-management/services/api/withHeaders"; +import axiosInstance from "@/shared/services/axiosInstance"; +import { AxiosResponse } from "axios"; + +export interface RoleDto { + id: string; + name: { am: string; en: string }; + key: string; +} + +export interface RoleListResponse { + count: number; + items: RoleDto[]; +} + +export const getRoles = async (): Promise> => + axiosInstance.get("/roles", { + headers: withHeaders(), + params: { take: 100 }, + }); diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts index d95084851..7e69b19d7 100644 --- a/apps/edr-freight-web/backoffice/src/types/customer.ts +++ b/apps/edr-freight-web/backoffice/src/types/customer.ts @@ -135,6 +135,36 @@ export interface CustomerResetTarget { phoneIsDomestic: boolean | null; } +/** One person's Fayda verification state — mirrors `IdentityVerificationStateDto`. */ +export interface IdentityVerificationState { + verified: boolean; + name: string | null; + phone: string | null; + email: string | null; + address: string | null; + verifiedAt: string | null; + birthdate: string | null; + gender: string | null; +} + +/** Mirrors `OwnerIdentityStateDto`. */ +export interface OwnerIdentityState extends IdentityVerificationState { + passportNumber: string | null; +} + +/** + * Owner/PoA Fayda verification, shared with the portal's derivation + * (`buildCompanyIdentityState`) so backoffice never re-derives — or + * disagrees with — the rule the API actually enforces. + */ +export interface CompanyIdentityState { + faydaRequired: boolean; + passportRequired: boolean; + owner: OwnerIdentityState; + poa: IdentityVerificationState; + complete: boolean; +} + /** Mirrors backend `Company` (+ its `companyProfiles`). */ export interface Company { id: string; @@ -161,6 +191,20 @@ export interface Company { poaAddress?: string | null; website?: string | null; attributes?: Record | null; + // eTrade-sourced registration record — populated by the onboarding TIN + // lookup, locked/read-only on the portal from the moment it's fetched. + licenceNumber?: string | null; + statusDescription?: string | null; + dateRegistered?: string | null; + renewedFrom?: string | null; + renewalDate?: string | null; + renewedTo?: string | null; + region?: string | null; + zone?: string | null; + woreda?: string | null; + kebele?: string | null; + houseNo?: string | null; + identity?: CompanyIdentityState; companyProfiles: CompanyProfile[]; /** * Whether the customer submitted their onboarding application. A company row diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts index 37d2897c2..8ff87c251 100644 --- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts +++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts @@ -118,12 +118,19 @@ export interface WarehouseZone { isActive: boolean; } +/** IMPORT | EXPORT | BOTH | null. Only meaningful for CONTAINER_YARD — everything else takes cargo either way. */ +export type WarehouseYardDirection = 'IMPORT' | 'EXPORT' | 'BOTH'; + export interface WarehouseYard { id: string; warehouseId: string; name: string; code: string; type: WarehouseYardType; + /** For CONTAINER_YARD: which direction this stack serves. BOTH/null on a container yard means "not a customer cargo yard" (service/equipment), not "any direction". */ + direction?: WarehouseYardDirection | null; + /** Cargo types this yard accepts. Empty/absent = open to any cargo type of this yard's structural type. */ + cargoTypes?: Array<{ id: string; code: string }>; capacityWeight: number | null; capacityContainers: number | null; maxWeight: number | null; @@ -518,6 +525,8 @@ export interface ImportTrain { route: string | null; origin: string | null; destination: string | null; + /** freight.yards.id the train is heading to — matches Warehouse.stationId, so the unload picker can be scoped to the warehouse actually at this station. */ + destinationStationId: string | null; departureTime?: string | null; arrivalTime: string | null; totalBookings: number; @@ -603,6 +612,8 @@ export interface ImportUnloadedItem { customerTruckContainerNumber: string | null; customerTruckAssignedAt: string | null; hasAssignedTruck: boolean; + /** Post-unloading Yes/No; null = not recorded yet (no double-handling charge). */ + doubleHandling: boolean | null; currentStatus: string; releaseDate: string | null; releaseOrderReference: string | null; @@ -630,6 +641,8 @@ export interface ImportTrainItem { freightType: string | null; containerNumber: string | null; cargoType: string | null; + /** Cargo type CODE (e.g. "WHEAT"), for matching against a yard's configured cargo types — `cargoType` above is the display name. */ + cargoTypeCode: string | null; weight: number | null; arrivalTime: string | null; currentStatus: string | null; @@ -849,13 +862,21 @@ export interface FeePreview { elapsedDays: number; chargeableDays: number; containerCount: number; + /** What containerCount/billableUnits are counted in: 'container' | 'truck' | 'ton' | 'item'. */ + unitLabel?: string; billableUnits: number; amount: number; tiers?: FeePreviewTier[]; - /** Truck detention: per-vehicle-type breakdown. */ + /** Truck detention: one row per truck — each has its own window and rule. */ groups?: Array<{ + assignmentId?: string | null; + vehicleId?: string | null; + plateNumber?: string | null; vehicleType: string | null; truckCount: number; + startDate?: string | null; + endDate?: string | null; + endIsOpen?: boolean; chargeableDays: number; ratePerDay: number; amount: number; diff --git a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/CreatePositionForm.tsx b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/CreatePositionForm.tsx index f8fc0cfdd..29ac1486f 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/CreatePositionForm.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/CreatePositionForm.tsx @@ -115,6 +115,7 @@ export const CreatePositionForm = ({ }); const selectedOrganizationId = form.watch("organizationId"); + const selectedUnitId = form.watch("unitId"); const { organizationsResponse, isLoading: isLoadingOrgs } = useOrganizations( "Org", @@ -163,36 +164,32 @@ export const CreatePositionForm = ({ enabled: mode === "edit" && !!positionTypeId, }); - // A position type belongs to a unit, and a unit to an organization — IAM has - // no organizationId on the type itself and no organization-scoped route, so - // the picked org narrows the list through its units. isSystem types are the - // shared "commons" and stay available to every organization. - const orgUnitIds = useMemo( - () => - new Set( - (unitsResponse?.data?.items ?? []).map((unit: UnitDto) => unit.id), - ), - [unitsResponse], - ); - + // A position type belongs to a single unit — scope copy sources to the + // selected unit, same as the "Select Unit" filter on the position list page. + // isSystem types are the shared "commons" and stay available everywhere. const copyFromOptions = useMemo(() => { - if (!selectedOrganizationId) return []; + if (!selectedUnitId) return []; return positionTypes.filter( (type: PositionTypeDto) => type.id !== positionTypeId && - (type.isSystem || (!!type.unitId && orgUnitIds.has(type.unitId))), + (type.isSystem || type.unitId === selectedUnitId), ); - }, [positionTypes, orgUnitIds, selectedOrganizationId, positionTypeId]); + }, [positionTypes, selectedUnitId, positionTypeId]); // Reset the selected unit when the organization changes so a unit from a - // different org can't be submitted by mistake. The copy source is cleared - // too — it is scoped to the old organization. + // different org can't be submitted by mistake. useEffect(() => { if (mode === "edit") return; form.setValue("unitId", ""); - setCopyFromPositionId(""); }, [selectedOrganizationId, mode, form]); + // The copy source is scoped to the selected unit — clear it whenever the + // unit changes (including as a side effect of the org reset above) so a + // stale selection from a different unit can't be submitted. + useEffect(() => { + setCopyFromPositionId(""); + }, [selectedUnitId]); + useEffect(() => { if (mode !== "edit" || !initialValues || !positionTypeId) return; if (hasLoadedEditData.current) return; @@ -335,11 +332,13 @@ export const CreatePositionForm = ({ const copyFromPlaceholder = !selectedOrganizationId ? t("contentManagement.selectOrganizationToCopy") - : isCopying || isLoadingPositionTypes || isLoadingUnits - ? t("common.loading") - : isErrorPositionTypes - ? t("contentManagement.failedToLoadPositionTypes") - : t("contentManagement.selectPositionToCopy"); + : !selectedUnitId + ? t("contentManagement.selectUnitToCopy") + : isCopying || isLoadingPositionTypes || isLoadingUnits + ? t("common.loading") + : isErrorPositionTypes + ? t("contentManagement.failedToLoadPositionTypes") + : t("contentManagement.selectPositionToCopy"); return (
@@ -454,6 +453,7 @@ export const CreatePositionForm = ({ onValueChange={handleCopyFrom} disabled={ !selectedOrganizationId || + !selectedUnitId || isLoadingPositionTypes || isLoadingUnits || isCopying diff --git a/apps/edr-freight-web/backoffice/src/user-management/route.tsx b/apps/edr-freight-web/backoffice/src/user-management/route.tsx index 76d3182ad..af29cdc5e 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/route.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/route.tsx @@ -19,6 +19,7 @@ import { AppLayout } from "./Applayout"; import ActivityLogPage from "@/pages/ActivityLogPage"; import AdminRegistrationPage from "@/pages/Organizations/AdminRegistrationPage"; import OrganizationAdminsPage from "@/pages/OrganizationAdminsPage"; +import OrgAdminPermissionsPage from "@/super-admin/components/org-admins/OrgAdminPermissionsPage"; import UserProfileEditPage from "@/pages/UserProfileEditPage"; import UploadedDocumentViewPage from "@/pages/UploadedDocumentViewPage"; import EditOrganizationPage from "@/pages/Organizations/EditOrganizationPage"; @@ -197,6 +198,10 @@ export function UserManagementRoutes(): ReactElement { path="user-management/organization_admins" element={} /> + } + /> } diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index a115e1363..2f661af75 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -54,6 +54,7 @@ import NewShipmentPage from "./pages/contracts/NewShipmentPage"; import NewShipmentRequestPage from "./pages/contracts/NewShipmentRequestPage"; import CheckPaymentPage from "./pages/payments/CheckPaymentPage"; import PaymentFailurePage from "./pages/payments/PaymentFailurePage"; +import FaydaCallbackPage from "./pages/FaydaCallbackPage"; import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage"; import TrackingPage from "./pages/tracking/TrackingPage"; @@ -261,6 +262,9 @@ const App = () => { element={} /> {/* Payment provider browser redirects (PAYMENT_RETURN_URL / PAYMENT_FAILURE_URL) */} + {/* Fayda (eSignet) redirect_uri — runs in the verification popup and + relays the code/state back to the form that opened it. */} + } /> } /> } /> diff --git a/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx b/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx new file mode 100644 index 000000000..fc00cfabe --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx @@ -0,0 +1,230 @@ +import { useEffect, useRef, useState } from "react"; +import { + Alert, + Badge, + Button, + Card, + Group, + SimpleGrid, + Stack, + Text, +} from "@mantine/core"; +import { BadgeCheck, Clock, ShieldCheck, XCircle } from "lucide-react"; + +import { + verifaydaService, + type CompanyIdentityState, + type FaydaCallbackMessage, + type IdentitySubject, + type IdentityVerificationState, +} from "@/services/verifayda.service"; + +interface FaydaVerifyPanelProps { + subject: IdentitySubject; + /** Heading — "General Manager" / "Power of Attorney". */ + title: string; + /** What this person's verification is currently known to be. */ + state?: IdentityVerificationState; + /** + * False for a foreign company: verification is offered but nothing is gated + * on it, so the panel says so rather than nagging. + */ + required: boolean; + /** Called with the fresh company-wide state once a verification lands. */ + onVerified: (next: CompanyIdentityState) => void; + disabled?: boolean; + /** + * True when a fresh verification for this person is already staged in a + * pending change request. On an active company a re-verification never + * touches the live record — it's staged for review — so `state` alone + * would keep showing the OLD verified data with no sign anything happened. + */ + pendingReview?: boolean; +} + +function formatDate(iso: string | null): string { + if (!iso) return ""; + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? "" : d.toLocaleDateString(); +} + +/** + * Verify one of the company's people through Fayda and show what came back. + * + * The identity is proved in an eSignet popup; that popup lands on /callback, + * which relays the code+state here by postMessage. This window then completes + * the exchange — once, in one place — and the API writes the person's name, + * phone, email and address from the verified payload. Nothing on this panel + * is typed. + */ +export default function FaydaVerifyPanel({ + subject, + title, + state, + required, + onVerified, + disabled, + pendingReview, +}: FaydaVerifyPanelProps) { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + // The listener closes over `subject`; keep it in a ref so remounting the + // panel between steps can't complete a verification against the wrong person. + const subjectRef = useRef(subject); + subjectRef.current = subject; + + useEffect(() => { + const onMessage = async (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + if (event.data?.type !== "fayda-callback") return; + + if (event.data.error) { + setLoading(false); + setError(event.data.errorDescription ?? event.data.error); + return; + } + if (!event.data.code || !event.data.state) return; + + try { + const next = await verifaydaService.completeIdentity( + subjectRef.current, + event.data.code, + event.data.state, + ); + setError(null); + onVerified(next); + } catch (err) { + setError( + (err as { response?: { data?: { message?: string } } })?.response?.data + ?.message ?? + (err instanceof Error ? err.message : "Verification failed"), + ); + } finally { + setLoading(false); + } + }; + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const startVerification = async () => { + setError(null); + setLoading(true); + try { + const authorizationUrl = await verifaydaService.start(); + const popup = window.open( + authorizationUrl, + "fayda-verify", + "width=480,height=760,noopener=no", + ); + if (!popup) { + setLoading(false); + setError("Pop-up blocked — allow pop-ups for this site and try again."); + } + // Loading stays on until the popup posts back. + } catch (err) { + setLoading(false); + setError( + (err as { response?: { data?: { message?: string } } })?.response?.data + ?.message ?? + (err instanceof Error ? err.message : "Could not start verification"), + ); + } + }; + + const verified = state?.verified ?? false; + + return ( + + + + + + {title} identity + + {verified ? ( + } + > + Fayda verified + + ) : ( + required && ( + + Verification required + + ) + )} + {pendingReview && ( + } + > + Re-verification pending review + + )} + + + + + {!verified && ( + + {required + ? "Verify this person with Fayda. Their name, phone and address come from the verification — there is nothing to fill in by hand." + : "Optional for a foreign company. If this person holds a Fayda ID, verifying it fills in their details."} + + )} + + {verified && state && ( + + + + + + + + )} + + {error && ( + }> + {error} + + )} + + ); +} + +function VerifiedField({ + label, + value, +}: { + label: string; + value: string | null; +}) { + if (!value) return null; + return ( + + + {label} + + + {value} + + + ); +} diff --git a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx index 6c38bba46..d36845dd9 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx @@ -1,19 +1,19 @@ -import { - Alert, - Button, - Group, - Loader, - Stack, - Text, - TextInput, -} from "@mantine/core"; +import { Alert, Button, Group, Loader, Stack, TextInput } from "@mantine/core"; import { useEffect, useRef } from "react"; import type { UseFormRegisterReturn } from "react-hook-form"; -import { AlertCircle, CheckCircle2, Download, Info } from "lucide-react"; +import { AlertCircle, Download } from "lucide-react"; import { useETradeData } from "@/hooks/useETradeData"; import { extractApiError } from "@/utils/result"; import type { CompanyRegistrationData } from "@edr/types"; +export type ETradeStatus = + | "idle" + | "loading" + | "verified" + | "not-found" + | "taken" + | "error"; + interface ETradeInfoProps { /** Current TIN value (drives button enablement). */ tin: string; @@ -22,6 +22,8 @@ interface ETradeInfoProps { /** Validation error for the TIN field, if any. */ error?: string; onDataLoaded: (data: CompanyRegistrationData) => void; + /** Reports the live lookup status so the parent step can gate on it. */ + onStatusChange?: (status: ETradeStatus) => void; } const isValidTin = (tin: string) => tin.length === 10; @@ -31,12 +33,11 @@ export default function ETradeInfo({ register, error, onDataLoaded, + onStatusChange, }: ETradeInfoProps) { const mutation = useETradeData(); const isLoading = mutation.isPending; const tinTaken = mutation.data?.tinTaken; - const hasData = - mutation.data && !mutation.data.tinTaken ? mutation.data : null; const handleFetch = async () => { if (!isValidTin(tin)) return; @@ -47,8 +48,10 @@ export default function ETradeInfo({ }; // Auto-fetch as soon as the TIN reaches its full 10-digit length — only - // once per distinct value, so retyping the same TIN doesn't refetch. - const lastFetchedTin = useRef(null); + // once per distinct value, so retyping the same TIN doesn't refetch. Seeded + // from the initial value so a resumed draft with an already-verified TIN + // doesn't refire the lookup the moment this mounts. + const lastFetchedTin = useRef(tin || null); useEffect(() => { if (isValidTin(tin) && lastFetchedTin.current !== tin) { lastFetchedTin.current = tin; @@ -61,16 +64,36 @@ export default function ETradeInfo({ mutation.isError && mutation.error ? extractApiError(mutation.error) : null; - // A 400 here means eTrade simply has no record for this TIN — not a - // failure. Soft-pedal it as an FYI, not a red error, so filling in - // manually doesn't feel like something went wrong. + // A 400 here means eTrade simply has no record for this TIN. const notFound = apiError?.statusCode === 400; const errorMessage = apiError && !notFound ? apiError.message || - "We couldn't reach eTrade to fetch your company information. Please try again, or fill in the details manually below." + "We couldn't reach eTrade to fetch your company information. Please try again." : null; + const status: ETradeStatus = isLoading + ? "loading" + : tinTaken + ? "taken" + : mutation.isSuccess && mutation.data && !mutation.data.tinTaken + ? "verified" + : notFound + ? "not-found" + : errorMessage + ? "error" + : "idle"; + + const lastReportedStatus = useRef(null); + useEffect(() => { + if (lastReportedStatus.current === status) return; + lastReportedStatus.current = status; + onStatusChange?.(status); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [status]); + + const showRetry = isValidTin(tin) && status !== "verified" && status !== "loading"; + return ( @@ -86,7 +109,7 @@ export default function ETradeInfo({ error={error} {...register} /> - {errorMessage && ( + {showRetry && (
+
+ {isLoading &&

Loading…

} + {isError &&

Failed to load report.

} + + + {!scheduleId && ( +
+ +

Select a schedule above to load the boarding report

+
+ )} + + {data && ( + <> + {/* Schedule info */} +
+
+ +
+
+

{data.schedule.trainName}

+

+ {data.schedule.origin} → {data.schedule.destination} · + Departure: {formatDateTime(data.schedule.departureAt)} +

+
+
+ + {/* Tabs */} +
+ + +
+ + {/* Summary Tab */} + {tab === "summary" && ( +
+ {/* KPI cards */} +
+
+
+
+

Total Tickets

+

{data.summary.total}

+

Confirmed passengers

+
+ +
+
+ +
+
+
+

Boarded

+

+ {data.summary.boardedCount} +

+

Scanned at gate

+
+ +
+
+ +
+
+
+

Not Boarded

+

+ {data.summary.notBoardedCount} +

+

No-shows / pending

+
+ +
+
+ +
+
+
+

Boarding Rate

+

+ {data.summary.boardingRate}% +

+
+
+
+
+ +
+
+
+ + {/* By Coach */} + {data.byCoach.length > 0 && ( +
+
+ + + + + + + + + + + + {data.byCoach.map((c) => { + const rate = c.total > 0 ? +((c.boarded / c.total) * 100).toFixed(1) : 0; + return ( + + + + + + + + ); + })} + +
CoachTotalBoardedNot BoardedRate
{c.coachNumber}{c.total}{c.boarded}{c.total - c.boarded} +
+
+
+
+ {rate}% +
+
+
+
+ )} +
+ )} + + {/* Details Tab */} + {tab === "details" && ( +
+
+ { setSearch(e.target.value); reset(); }} + /> + + + Export CSV + +
+
+ + + + {["Booking Ref", "Passenger", "Seat Class · Coach · Seat", "Route", "Status", "Boarded At"].map((h) => ( + + ))} + + + + {paged.map((row, i) => ( + + + + + + + + + ))} + {paged.length === 0 && ( + + + + )} + +
+ {h} +
{row.bookingRef}{row.passengerName} + {row.seatClassName ?? "—"} + {row.coachNumber && · {row.coachNumber}} + {row.seatNumber && · #{row.seatNumber}} + + {row.origin && row.destination ? `${row.origin} → ${row.destination}` : (row.origin ?? row.destination ?? "—")} + + + {row.boarded ? "Boarded" : "Not Boarded"} + + + {row.boardedAt ? formatDateTime(row.boardedAt) : "—"} +
+ No passengers found +
+
+ +
+ )} + + )} + + {!data && !isLoading && scheduleId && ( +
+ No data found for this schedule. +
+ )} +
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx index 17a31832a..35d0290ea 100644 --- a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useRef } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { Plus, Loader2, Zap, Trash2, Edit, Search, X, GripVertical, RefreshCw } from 'lucide-react'; +import { Plus, Loader2, Zap, Trash2, Edit, Search, X, GripVertical, Clock, RefreshCw } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import ActionButton from '@/components/ui/ActionButton'; import Modal from '@/components/ui/Modal'; @@ -28,6 +28,7 @@ interface Schedule { destinationStation?: { id: string; name: string }; coachAssignments?: Array<{ coachId: string; positionNumber: number; coach?: { id: string; number: string } }>; isPackageOnly?: boolean; + liveStatus?: { delayMinutes: number } | null; } interface Train { @@ -461,6 +462,16 @@ export default function SchedulesPage() { {formatDateTime(schedule.arrivalAt)} ), }, + { + key: 'liveStatus.delayMinutes', + label: 'Delay', + sortable: true, + render: (schedule: Schedule) => { + const delay = schedule.liveStatus?.delayMinutes ?? 0; + if (delay <= 0) return On time; + return +{delay} min; + }, + }, { key: 'coachAssignments', label: 'Coaches', @@ -493,6 +504,15 @@ export default function SchedulesPage() { const [cancelConfirm, setCancelConfirm] = useState<{ isOpen: boolean; item: Schedule | null }>({ isOpen: false, item: null }); + const applyDelayMutation = useMutation({ + mutationFn: ({ id, minutes }: { id: string; minutes: number }) => + apiClient.post(`/schedules/${id}/delay`, { delayMinutes: minutes }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['schedules'] }), + }); + const [delayPrompt, setDelayPrompt] = useState<{ isOpen: boolean; item: Schedule | null }>({ isOpen: false, item: null }); + const [delayMinutesInput, setDelayMinutesInput] = useState(''); + const [delayError, setDelayError] = useState(null); + const scheduleActions = [ { label: 'Edit', @@ -500,6 +520,17 @@ export default function SchedulesPage() { variant: 'secondary' as const, icon: Edit, }, + { + label: 'Report Delay', + onClick: (schedule: Schedule) => { + setDelayMinutesInput(''); + setDelayError(null); + setDelayPrompt({ isOpen: true, item: schedule }); + }, + variant: 'secondary' as const, + icon: Clock, + hidden: (schedule: Schedule) => schedule.status === 'CANCELLED', + }, { label: 'Cancel', onClick: (schedule: Schedule) => setCancelConfirm({ isOpen: true, item: schedule }), @@ -660,6 +691,67 @@ export default function SchedulesPage() { isLoading={cancelScheduleMutation.isPending} /> + setDelayPrompt({ isOpen: false, item: null })} + title={`Report Delay${delayPrompt.item ? `: ${delayPrompt.item.originStation?.name ?? ''} → ${delayPrompt.item.destinationStation?.name ?? ''}` : ''}`} + size="sm" + > + {delayPrompt.item && ( + { + e.preventDefault(); + const minutes = parseInt(delayMinutesInput, 10); + if (Number.isNaN(minutes)) { setDelayError('Enter a whole number of minutes.'); return; } + try { + await applyDelayMutation.mutateAsync({ id: delayPrompt.item!.id, minutes }); + setDelayPrompt({ isOpen: false, item: null }); + } catch (err: any) { + setDelayError(err?.response?.data?.message || 'Failed to apply delay.'); + } + }} + className="space-y-4" + > + {delayError && ( +
{delayError}
+ )} +
+ Current reported delay + {(delayPrompt.item.liveStatus?.delayMinutes ?? 0) > 0 ? ( + +{delayPrompt.item.liveStatus?.delayMinutes} min + ) : ( + On time + )} +
+
+ + setDelayMinutesInput(e.target.value)} + placeholder="e.g. 60" + className="input" + required + autoFocus + /> +

+ Adds to the current reported delay above and pushes every downstream station's + check-in cutoff back by this many minutes. Use a negative number to correct an + over-reported delay. +

+
+
+ setDelayPrompt({ isOpen: false, item: null })}> + Cancel + + + Apply Delay + +
+ + )} +
+ setDeleteConfirm({ isOpen: false, item: null })} diff --git a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx index be5449e97..f1289d24a 100644 --- a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx @@ -2,11 +2,13 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { seatsApi, schedulesApi, fleetApi, routeCoachTemplatesApi } from '@/lib/api'; +import { seatsApi, schedulesApi, fleetApi, routeCoachTemplatesApi, bookingsApi } from '@/lib/api'; import { routesApi } from '@/lib/api/routes'; +import { usePermissionStrict } from '@/lib/use-permission'; +import { PERMS } from '@/lib/permissions'; import Modal from '@/components/ui/Modal'; import ActionButton from '@/components/ui/ActionButton' -import { Armchair, Lock, Unlock, Bed, X, RotateCcw, ChevronDown, Train, Wrench } from 'lucide-react'; +import { Armchair, Lock, Unlock, Bed, X, RotateCcw, ChevronDown, Train, Wrench, Ticket as TicketIcon } from 'lucide-react'; export default function SeatsPage() { const [activeTab, setActiveTab] = useState<'route' | 'schedule'>('route'); @@ -24,8 +26,29 @@ export default function SeatsPage() { const [coachToUnblock, setCoachToUnblock] = useState(null); const [showMaintenanceModal, setShowMaintenanceModal] = useState(false); const [maintenanceReason, setMaintenanceReason] = useState(''); + const [showIssueBookingModal, setShowIssueBookingModal] = useState(false); + const [issueBookingCoach, setIssueBookingCoach] = useState(null); + const [issueBookingForm, setIssueBookingForm] = useState({ + bookingKind: 'STAFF' as 'STAFF' | 'PASSENGER', + // No seatClassId — the seat's class is already fixed by the reservation; the backend + // resolves it from the seat's own coach type + nationality tier. + passengerName: '', + dateOfBirth: '', + idDocumentType: 'PASSPORT' as 'NATIONAL_ID' | 'PASSPORT', + idDocumentNumber: '', + passportNumber: '', + nationality: '' as '' | 'Ethiopian' | 'Djiboutian' | 'Other', + phone: '', + email: '', + }); + const [issueBookingResult, setIssueBookingResult] = useState<{ payUrl?: string; bookingRef?: string } | null>(null); const queryClient = useQueryClient(); + // Issuing a booking off a reserved seat needs edr_passenger_app:tickets:generate + // (POST /bookings/reservations/:seatId/issue is guarded by @PassengerStaffStrict). + // Strict: being an admin is not enough, the permission has to be granted. + const canIssueBooking = usePermissionStrict(PERMS.tickets.generate); + const { data: schedulesData } = useQuery({ queryKey: ['schedules'], queryFn: () => schedulesApi.getAll(), @@ -105,6 +128,27 @@ export default function SeatsPage() { }, }); + const issueBookingMutation = useMutation({ + mutationFn: ({ seatId, data }: { seatId: string; data: any }) => + bookingsApi.issueFromReservation(seatId, data), + onSuccess: (result: any) => { + invalidateSeatData(); + // Always show the reference — the PASSENGER path also needs the PNR alongside the + // pay link (staff need to know which booking a seat belongs to, whether it's + // awaiting payment or already ticketed), so no longer auto-closing for STAFF. + setIssueBookingResult({ payUrl: result?.payUrl, bookingRef: result?.booking?.bookingRef }); + }, + }); + + // Cancels a seat's still-unpaid reservation (payment link sent) and releases the seat — + // distinct from unblockMutation, which only handles a plain SeatBlock (no booking involved). + const cancelReservationMutation = useMutation({ + mutationFn: (seatId: string) => bookingsApi.cancelReservation(seatId, selectedSchedule), + onSuccess: () => { + invalidateSeatData(); + }, + }); + const removeSeatMutation = useMutation({ mutationFn: (seatId: string) => seatsApi.removeSeat(seatId), onSuccess: () => { @@ -186,11 +230,76 @@ export default function SeatsPage() { }; const handleUnblock = async (seat: any) => { - if (confirm('Are you sure you want to unblock this seat?')) { + if (confirm('Release this reservation and make the seat available to the public?')) { await unblockMutation.mutateAsync(seat.id); } }; + // Distinct from handleUnblock — this seat has no SeatBlock (issuing the reservation already + // released it), it's HELD by the SeatHold behind an unpaid booking. Cancelling that booking + // invalidates its payment link immediately, so warn staff explicitly about that. + const handleCancelReservation = async (seat: any) => { + if (!selectedSchedule) return; + if (confirm(`Cancel the reservation for seat ${seat.seatNumber} (PNR ${seat.bookingRef})? The payment link already sent to the traveler will stop working.`)) { + await cancelReservationMutation.mutateAsync(seat.id); + } + }; + + const handleIssueBooking = (seat: any, coach: any) => { + if (activeTab !== 'schedule' || !selectedSchedule) { + alert('Select a specific schedule (Schedule tab) to issue a booking for a reserved seat.'); + return; + } + setSelectedSeat(seat); + setIssueBookingCoach(coach); + setIssueBookingResult(null); + setIssueBookingForm({ + bookingKind: 'STAFF', + passengerName: '', + dateOfBirth: '', + idDocumentType: 'PASSPORT', + idDocumentNumber: '', + passportNumber: '', + nationality: '', + phone: '', + email: '', + }); + setShowIssueBookingModal(true); + }; + + const submitIssueBooking = async () => { + const schedule = schedules.find((s: any) => s.id === selectedSchedule); + if (!schedule?.originStation?.id || !schedule?.destinationStation?.id) { + alert('Could not resolve this schedule\'s origin/destination stations.'); + return; + } + if (!issueBookingForm.passengerName.trim() || !issueBookingForm.dateOfBirth) { + alert('Traveler name and date of birth are required.'); + return; + } + if (!issueBookingForm.nationality) { + alert('Select a nationality.'); + return; + } + if (issueBookingForm.idDocumentType === 'PASSPORT' && !issueBookingForm.passportNumber.trim()) { + alert('Passport number is required.'); + return; + } + if (issueBookingForm.bookingKind === 'PASSENGER' && !issueBookingForm.phone.trim()) { + alert('Phone number is required for a passenger booking (used to send the payment link).'); + return; + } + await issueBookingMutation.mutateAsync({ + seatId: selectedSeat.id, + data: { + scheduleId: selectedSchedule, + originStationId: schedule.originStation.id, + destinationStationId: schedule.destinationStation.id, + ...issueBookingForm, + }, + }); + }; + const handleRemoveSeat = (seat: any) => { setSelectedSeat(seat); setShowRemoveModal(true); @@ -252,7 +361,7 @@ export default function SeatsPage() { const submitBlock = async () => { if (!blockReason.trim()) { - alert('Please provide a reason for blocking'); + alert('Please provide a reason for the reservation'); return; } await blockMutation.mutateAsync({ seatId: selectedSeat.id, reason: blockReason }); @@ -347,9 +456,12 @@ export default function SeatsPage() { handleBlock={handleBlock} handleRemoveSeat={handleRemoveSeat} handleUnblock={handleUnblock} + handleCancelReservation={handleCancelReservation} handleUndoRemove={handleUndoRemove} handleSetMaintenance={handleSetMaintenance} handleClearMaintenance={handleClearMaintenance} + handleIssueBooking={handleIssueBooking} + canIssueBooking={canIssueBooking} hideNumber={true} /> ))} @@ -442,9 +554,12 @@ export default function SeatsPage() { handleBlock={handleBlock} handleRemoveSeat={handleRemoveSeat} handleUnblock={handleUnblock} + handleCancelReservation={handleCancelReservation} handleUndoRemove={handleUndoRemove} handleSetMaintenance={handleSetMaintenance} handleClearMaintenance={handleClearMaintenance} + handleIssueBooking={handleIssueBooking} + canIssueBooking={canIssueBooking} hideNumber={true} /> ))} @@ -464,9 +579,12 @@ export default function SeatsPage() { handleBlock={handleBlock} handleRemoveSeat={handleRemoveSeat} handleUnblock={handleUnblock} + handleCancelReservation={handleCancelReservation} handleUndoRemove={handleUndoRemove} handleSetMaintenance={handleSetMaintenance} handleClearMaintenance={handleClearMaintenance} + handleIssueBooking={handleIssueBooking} + canIssueBooking={canIssueBooking} hideNumber={true} /> ))} @@ -749,15 +867,15 @@ export default function SeatsPage() { setSelectedSeat(null); setBlockReason(''); }} - title="Block Seat" + title="Reserve Seat" size="md" >

- Block seat {selectedSeat?.seatNumber} in Coach {selectedSeat?.coach?.coachNumber} + Reserve seat {selectedSeat?.seatNumber} in Coach {selectedSeat?.coach?.coachNumber}

- +