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..58fb60b18 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 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 aab9e0e4a..64df33f35 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -12,7 +12,8 @@ import { ensurePostgresSchemas, APPLICATION_SEARCH_PATH, } from "./config/ensure-postgres-schemas"; -import { IamModule, DataSeeder } from "@tria-plc/iamapi-common"; +import { IamBaselineSeeder, IamSeedModule } from "@edr/iam-seed"; +import { IamModule } from "@tria-plc/iamapi-common"; import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module"; import appConfig from "./config/app.config"; @@ -153,6 +154,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, @@ -231,7 +244,7 @@ import { LoggerMiddleware } from "./logger.middleware"; }) 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, @@ -261,13 +274,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. diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index 92958b364..854594ffc 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -23,6 +23,14 @@ export const StaffReference = () => applyDecorators(UseGuards(JwtGuard)); export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view); +/** + * The document-review countdown in the backoffice header. Its own permission so + * it can be granted to exactly the position types that decide operation + * requests, instead of every holder of bookings:view. + */ +export const BookingDocReviewAlert = () => + BookingStaff(FREIGHT_PERMS.bookings.docReviewAlert); + export const TrainSchedulingView = () => BookingStaff(FREIGHT_PERMS.trainScheduling.view); @@ -73,6 +81,32 @@ export const WagonTransferFulfill = () => export const WagonTransferHistoryAll = () => BookingStaff(FREIGHT_PERMS.wagons.transferHistoryAll); +/** + * Open the transfer-requests desk. `wagons:view` is accepted as a one-of + * fallback so staff who could already reach the queue keep it without a + * re-grant — same pattern the granular fleet keys use. + */ +export const WagonTransferView = () => + BookingStaff([FREIGHT_PERMS.wagons.transferView, FREIGHT_PERMS.wagons.view]); + +/** Withdraw a request that has not moved any wagon yet. */ +export const WagonTransferCancel = () => + BookingStaff([ + FREIGHT_PERMS.wagons.transferCancel, + FREIGHT_PERMS.wagons.transferRequest, + ]); + +/** + * End a request short of the requested count. Whoever may move wagons may also + * declare the yard has no more to give, so fulfil is accepted alongside the + * dedicated key. + */ +export const WagonTransferCloseShort = () => + BookingStaff([ + FREIGHT_PERMS.wagons.transferCloseShort, + FREIGHT_PERMS.wagons.transferFulfill, + ]); + /** Org-administration endpoints (user mgmt, billing config, company CRUD, settings). */ export const FreightAdmin = () => BookingStaff(FREIGHT_PERMS.admin); diff --git a/apps/edr-freight-api/src/common/freight-permission.hazardous.spec.ts b/apps/edr-freight-api/src/common/freight-permission.hazardous.spec.ts new file mode 100644 index 000000000..4658a5434 --- /dev/null +++ b/apps/edr-freight-api/src/common/freight-permission.hazardous.spec.ts @@ -0,0 +1,47 @@ +import { ForbiddenException } from '@nestjs/common'; + +import { + assertCanApproveContractStep, + canEditContractStep, +} from './freight-permission.util'; +import { FREIGHT_PERMS } from '../seed/freight-permissions.registry'; + +const userWith = (...keys: string[]) => ({ + permissions: keys.map((key) => ({ key })), +}); + +describe('hazardous contract approval steps', () => { + it('rejects an approver who only holds ordinary contract-approve permissions', () => { + // The blanket "any contract approve permission" fallback must NOT reach + // dangerous goods — that is the whole point of the dedicated desks. + const lineStaff = userWith(FREIGHT_PERMS.contracts.approveLineStaff); + + expect(() => + assertCanApproveContractStep(lineStaff, 'HAZARDOUS_APPROVAL_ONE'), + ).toThrow(ForbiddenException); + expect(canEditContractStep(lineStaff, 'HAZARDOUS_APPROVAL_ONE')).toBe(false); + }); + + it('accepts only the matching hazardous permission', () => { + const first = userWith(FREIGHT_PERMS.contracts.hazardousApprovalOne); + + expect(() => + assertCanApproveContractStep(first, 'HAZARDOUS_APPROVAL_ONE'), + ).not.toThrow(); + // Holding step one does not confer step two. + expect(() => + assertCanApproveContractStep(first, 'HAZARDOUS_APPROVAL_TWO'), + ).toThrow(ForbiddenException); + }); + + it('does not let a hazardous approver stand in for the commercial chain', () => { + const hazardOnly = userWith( + FREIGHT_PERMS.contracts.hazardousApprovalOne, + FREIGHT_PERMS.contracts.hazardousApprovalTwo, + ); + + expect(() => assertCanApproveContractStep(hazardOnly, 'CEO')).toThrow( + ForbiddenException, + ); + }); +}); diff --git a/apps/edr-freight-api/src/common/freight-permission.util.ts b/apps/edr-freight-api/src/common/freight-permission.util.ts index 429c910d3..56c0e77c2 100644 --- a/apps/edr-freight-api/src/common/freight-permission.util.ts +++ b/apps/edr-freight-api/src/common/freight-permission.util.ts @@ -151,6 +151,24 @@ const APPROVE_ROLE_PERMISSION: Record = { CEO: FREIGHT_PERMS.bookings.approveCeo, }; +/** + * Approval-chain roles synthesized for hazardous contracts (see + * `instantiateApprovalSteps`). Unlike the legacy roles below they are NOT + * position types — they authorize purely on their own dedicated permission, and + * they deliberately opt out of the blanket "holds any contract-approve + * permission" fallback so a normal approver cannot sign off dangerous goods. + */ +export const HAZARDOUS_APPROVAL_ROLE_PERMISSION: Record = { + HAZARDOUS_APPROVAL_ONE: FREIGHT_PERMS.contracts.hazardousApprovalOne, + HAZARDOUS_APPROVAL_TWO: FREIGHT_PERMS.contracts.hazardousApprovalTwo, +}; + +/** The two hazardous steps, in the order they are prepended to the chain. */ +export const HAZARDOUS_APPROVAL_ROLES = [ + 'HAZARDOUS_APPROVAL_ONE', + 'HAZARDOUS_APPROVAL_TWO', +] as const; + const CONTRACT_APPROVE_ROLE_PERMISSION: Record = { LINE_STAFF: FREIGHT_PERMS.contracts.approveLineStaff, DIRECTOR: FREIGHT_PERMS.contracts.approveDirector, @@ -183,6 +201,16 @@ export function assertCanApproveContractStep( ): void { if (isFreightApprovalAdmin(user)) return; + // Hazardous steps are permission-only and strict — no legacy alias, no + // blanket approve fallback. + const hazardousPermission = HAZARDOUS_APPROVAL_ROLE_PERMISSION[requiredRole]; + if (hazardousPermission) { + if (hasFreightPermission(user, hazardousPermission)) return; + throw new ForbiddenException( + `Missing permission: ${hazardousPermission}`, + ); + } + const positionTypes = collectPositionTypeKeys(user); if (positionTypes.includes(requiredRole)) return; @@ -219,6 +247,11 @@ export function canEditContractStep( ): boolean { if (isFreightApprovalAdmin(user)) return true; + const hazardousPermission = HAZARDOUS_APPROVAL_ROLE_PERMISSION[requiredRole]; + if (hazardousPermission) { + return hasFreightPermission(user, hazardousPermission); + } + const positionTypes = collectPositionTypeKeys(user); if (positionTypes.includes(requiredRole)) return true; 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/contracts/contract-document-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts index 363008660..eb9541d8e 100644 --- a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts @@ -1,4 +1,5 @@ import { Injectable, NotFoundException } from '@nestjs/common'; +import { hazardClassLabel } from '@edr/types'; import { ContractsRepository } from '../modules/contracts/contracts.repository'; import { @@ -30,6 +31,8 @@ export interface ContractDocumentSignatureView { signerDisplayName: string; signedAt: string; signatureImageUrl?: string | null; + /** Company stamp/seal; rendered next to the signature when present. */ + stampImageUrl?: string | null; } /** A single unit-rate row on the contract PDF — price per unit, NO total. */ @@ -141,6 +144,11 @@ export class ContractDocumentViewModelBuilder { const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER'); const hasStaff = signatures.some((s) => s.role === 'STAFF'); + // Signed before company stamps were required — the customer has to sign + // again to attach one, otherwise EDR can never counter-sign the contract. + const customerStampMissing = signatures.some( + (s) => s.role === 'CUSTOMER' && !s.stampImageUrl, + ); const hasContractFile = Boolean( contract.files?.some((f) => f.code === 'contract'), ); @@ -183,7 +191,9 @@ export class ContractDocumentViewModelBuilder { // Cast: contract signers (CUSTOMER|STAFF|DIRECTOR|CEO) widen the booking // view-model's narrower CUSTOMER|STAFF role union. signatures: signatures as unknown as ContractViewModel['signatures'], - canSignCustomer: contract.status === 'CONTRACT_READY' && !hasCustomer, + canSignCustomer: + (contract.status === 'CONTRACT_READY' && !hasCustomer) || + (contract.status === 'SIGNED_CUSTOMER' && customerStampMissing), canSignStaff: contract.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff, hasContractDocument: hasContractFile, @@ -208,6 +218,7 @@ export class ContractDocumentViewModelBuilder { signerDisplayName: row.signerDisplayName, signedAt: this.formatDate(row.signedAt), signatureImageUrl: row.signatureFile?.url ?? null, + stampImageUrl: row.stampFile?.url ?? null, }; } @@ -292,7 +303,16 @@ export class ContractDocumentViewModelBuilder { cargoDescription: this.valueOrDash(cargoName), totalWeightVgm: '—', equipmentReturn: this.valueOrDash(contract.equipmentReturn), - hazardousLabel: contract.isHazardous ? 'Yes' : 'No', + // A hazardous contract names the declared class + UN number on the + // schedule — the flag alone is not a dangerous-goods declaration. + hazardousLabel: contract.isHazardous + ? [ + hazardClassLabel(contract.hazardClass) ?? 'Yes', + contract.unNumber ? `UN ${contract.unNumber}` : null, + ] + .filter(Boolean) + .join(' · ') + : 'No', firstMilePickupAddress: this.valueOrDash(contract.firstMilePickupAddress), lastMileDeliveryAddress: this.valueOrDash(contract.lastMileDeliveryAddress), }; diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/signatures_block.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/signatures_block.hbs index 05dd450e4..59cfceb0b 100644 --- a/apps/edr-freight-api/src/contracts/templates/_partials/signatures_block.hbs +++ b/apps/edr-freight-api/src/contracts/templates/_partials/signatures_block.hbs @@ -11,6 +11,12 @@

Name: {{signerDisplayName}}

Role: Authorized EDR representative

Date: {{signedAt}}

+ {{#if stampImageUrl}} +
+ Company stamp +
Service provider stamp
+
+ {{/if}} {{/if}} {{/each}} {{else}} @@ -32,6 +38,12 @@

Name: {{signerDisplayName}}

Role: Authorized client representative

Date: {{signedAt}}

+ {{#if stampImageUrl}} +
+ Company stamp +
Client stamp
+
+ {{/if}} {{/if}} {{/each}} {{else}} diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs index 118606065..d9bc9927f 100644 --- a/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs +++ b/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs @@ -372,25 +372,30 @@ font-size: 9pt; margin: 4px 0; } - - /* ── Witnesses ────────────────────────────────────────────────────────── */ - .witnesses { margin-top: 20px; } - .witness-table { - font-size: 9.5pt; - margin-top: 6px; + .sig-stamp { + margin-top: 12px; } - .witness-table th, - .witness-table td { - border-bottom: 1px solid #c9e4d9; - padding: 9px 8px; - text-align: left; - } - .witness-table th { + .sig-stamp-label { color: #0e5b45; font-family: Arial, sans-serif; - font-size: 8.5pt; + font-size: 7.5pt; + font-weight: 700; + letter-spacing: 0.4pt; text-transform: uppercase; } + .sig-stamp-box { + align-items: center; + display: flex; + height: 30mm; + justify-content: center; + margin-top: 5px; + } + .sig-stamp-box img { + display: block; + max-height: 30mm; + max-width: 45mm; + mix-blend-mode: multiply; + } @media print { body { background: #fff; } diff --git a/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs b/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs index 0e06d9f7b..9c4957b2b 100644 --- a/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs +++ b/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs @@ -147,19 +147,6 @@ authorized to sign and execute this Contract Agreement.

{{> signatures_block}} - -
-

Witnesses

- - - - - - - - -
NameSignatureDate
1.
2.
-
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/2910000000000-AddContractSignatureStamp.ts b/apps/edr-freight-api/src/migrations/2910000000000-AddContractSignatureStamp.ts new file mode 100644 index 000000000..e28748cb7 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2910000000000-AddContractSignatureStamp.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Company stamp (seal) attached alongside the drawn signature, for both the + * client and the EDR side. Stored the same way the signature image is: a + * FileRecord on the contract (`resource: 'contracts'`, `code: 'stamp_'`) + * referenced from the signature row. + * + * Nullable — existing signature rows predate the stamp requirement. The + * "both stamps recorded" gate lives in ContractTransitionService.counterSign, + * not in a NOT NULL constraint, so historical rows stay readable. + */ +export class AddContractSignatureStamp2910000000000 implements MigrationInterface { + name = 'AddContractSignatureStamp2910000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.contract_signatures ADD COLUMN IF NOT EXISTS stamp_file_id uuid;`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.contract_signatures DROP COLUMN IF EXISTS stamp_file_id;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2920000000000-AddRevisionActorName.ts b/apps/edr-freight-api/src/migrations/2920000000000-AddRevisionActorName.ts new file mode 100644 index 000000000..2263d78fc --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2920000000000-AddRevisionActorName.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Store WHO made a contract edit as a name, not just an id. Denormalised on + * purpose: an audit trail must still read correctly after the user is renamed, + * deactivated or deleted, and `iam.users` lives outside this module's schema. + */ +export class AddRevisionActorName2920000000000 implements MigrationInterface { + name = 'AddRevisionActorName2920000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.contract_document_revisions ADD COLUMN IF NOT EXISTS actor_name varchar(200);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.contract_document_revisions DROP COLUMN IF EXISTS actor_name;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2930000000000-AddWagonTransferPartialFulfilment.ts b/apps/edr-freight-api/src/migrations/2930000000000-AddWagonTransferPartialFulfilment.ts new file mode 100644 index 000000000..a9cc5e74d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2930000000000-AddWagonTransferPartialFulfilment.ts @@ -0,0 +1,40 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Partial wagon-transfer fulfilment. + * + * A request for 50 wagons no longer has to be met in one go: OCC moves what the + * source yard can spare, whenever it can, and the request stays open until the + * full count is met (FULFILLED) or OCC ends it short (CLOSED_SHORT) so the + * requester can ask another yard for the rest. + * + * Existing rows are back-filled so history keeps reading correctly: a FULFILLED + * request delivered its whole quantity; anything else delivered nothing. + */ +export class AddWagonTransferPartialFulfilment2930000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagon_transfer_requests + ADD COLUMN IF NOT EXISTS fulfilled_quantity integer NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS closed_short_at timestamptz NULL, + ADD COLUMN IF NOT EXISTS closed_short_by_user_id uuid NULL + `); + await queryRunner.query(` + UPDATE freight.wagon_transfer_requests + SET fulfilled_quantity = quantity + WHERE status = 'FULFILLED' + AND fulfilled_quantity = 0 + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagon_transfer_requests + DROP COLUMN IF EXISTS fulfilled_quantity, + DROP COLUMN IF EXISTS closed_short_at, + DROP COLUMN IF EXISTS closed_short_by_user_id + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2940000000000-AddFileVersionHistory.ts b/apps/edr-freight-api/src/migrations/2940000000000-AddFileVersionHistory.ts new file mode 100644 index 000000000..ad9bae99c --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2940000000000-AddFileVersionHistory.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Keep every version of a stored document. + * + * Replacing a file used to DELETE the previous row outright, so a staff + * correction erased the customer's original upload with no trail. Superseded + * versions are now soft-deleted (already excluded from every read by TypeORM's + * soft-delete filter) and stamped with who replaced them and why, which is what + * the document's version history reads back. + */ +export class AddFileVersionHistory2940000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.files + ADD COLUMN IF NOT EXISTS replaced_by_user_id uuid NULL, + ADD COLUMN IF NOT EXISTS replace_reason text NULL + `); + // History reads walk one document's versions, deleted rows included. + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_files_version_history" + ON freight.files (resource, resource_id, code, created_at DESC) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_files_version_history"`); + await queryRunner.query(` + ALTER TABLE freight.files + DROP COLUMN IF EXISTS replaced_by_user_id, + DROP COLUMN IF EXISTS replace_reason + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2950000000000-AddTransitAssigneeHandshake.ts b/apps/edr-freight-api/src/migrations/2950000000000-AddTransitAssigneeHandshake.ts new file mode 100644 index 000000000..bd9647d44 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2950000000000-AddTransitAssigneeHandshake.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Transit-assignee handshake before the customs declaration. + * + * GL Ethiopia must ask GL Djibouti who will handle the shipment in transit, and + * Djibouti answers with a name, before the declaration can be filed. The whole + * exchange lives on the clearance cycle so it repeats naturally with each cycle + * of a GENERAL contract. + */ +export class AddTransitAssigneeHandshake2950000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.contract_clearance_cycles + ADD COLUMN IF NOT EXISTS transit_assignee_requested_at timestamptz NULL, + ADD COLUMN IF NOT EXISTS transit_assignee_requested_by_user_id uuid NULL, + ADD COLUMN IF NOT EXISTS transit_assignee_request_note text NULL, + ADD COLUMN IF NOT EXISTS transit_assignee_name text NULL, + ADD COLUMN IF NOT EXISTS transit_assignee_assigned_at timestamptz NULL, + ADD COLUMN IF NOT EXISTS transit_assignee_assigned_by_user_id uuid NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.contract_clearance_cycles + DROP COLUMN IF EXISTS transit_assignee_requested_at, + DROP COLUMN IF EXISTS transit_assignee_requested_by_user_id, + DROP COLUMN IF EXISTS transit_assignee_request_note, + DROP COLUMN IF EXISTS transit_assignee_name, + DROP COLUMN IF EXISTS transit_assignee_assigned_at, + DROP COLUMN IF EXISTS transit_assignee_assigned_by_user_id + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2960000000000-AddContractHazardDeclaration.ts b/apps/edr-freight-api/src/migrations/2960000000000-AddContractHazardDeclaration.ts new file mode 100644 index 000000000..810f59a07 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2960000000000-AddContractHazardDeclaration.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Hazardous contracts now declare WHAT the dangerous good is, not just that it + * exists: the UN/ADR class (CLASS_1..CLASS_9) and the shipment's UN number. Both + * are captured in the portal alongside the hazard documents and reviewed by the + * two hazardous approval desks. + * + * Nullable — non-hazardous contracts leave both null, and contracts created + * before this change have no declaration to backfill. + */ +export class AddContractHazardDeclaration2960000000000 + implements MigrationInterface +{ + name = 'AddContractHazardDeclaration2960000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.contracts ADD COLUMN IF NOT EXISTS hazard_class varchar(16);`, + ); + await queryRunner.query( + `ALTER TABLE freight.contracts ADD COLUMN IF NOT EXISTS un_number varchar(16);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.contracts DROP COLUMN IF EXISTS un_number;`, + ); + await queryRunner.query( + `ALTER TABLE freight.contracts DROP COLUMN IF EXISTS hazard_class;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2970000000000-AddDoCollectionDates.ts b/apps/edr-freight-api/src/migrations/2970000000000-AddDoCollectionDates.ts new file mode 100644 index 000000000..90b3a9298 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2970000000000-AddDoCollectionDates.ts @@ -0,0 +1,42 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Djibouti GL must record WHEN the vessel arrived and WHEN the Delivery Order + * was collected, not just attach the DO file. Both are mandatory on DO upload + * (enforced in the clearance services), so the columns are new and nullable — + * DOs uploaded before this change have no dates to backfill. + * + * `vessel_departure_date` is the EXPORT Release-Order date and stays as-is; the + * import arrival date gets its own column rather than overloading it. + */ +export class AddDoCollectionDates2970000000000 implements MigrationInterface { + name = 'AddDoCollectionDates2970000000000'; + + public async up(queryRunner: QueryRunner): Promise { + for (const table of [ + 'freight.contract_clearance_cycles', + 'freight.bookings', + ]) { + await queryRunner.query( + `ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS vessel_arrival_date date;`, + ); + await queryRunner.query( + `ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS do_collected_date date;`, + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + for (const table of [ + 'freight.contract_clearance_cycles', + 'freight.bookings', + ]) { + await queryRunner.query( + `ALTER TABLE ${table} DROP COLUMN IF EXISTS do_collected_date;`, + ); + await queryRunner.query( + `ALTER TABLE ${table} DROP COLUMN IF EXISTS vessel_arrival_date;`, + ); + } + } +} diff --git a/apps/edr-freight-api/src/migrations/2980000000000-AddBookingRequestCurrency.ts b/apps/edr-freight-api/src/migrations/2980000000000-AddBookingRequestCurrency.ts new file mode 100644 index 000000000..5f92cff68 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2980000000000-AddBookingRequestCurrency.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Currency moved from the contract to the shipment: a contract now quotes in + * USD and the customer picks the billing currency per booking. On a customs + * contract GL books on the customer's behalf, so the shipment request is where + * the customer states the currency — GL reads it when creating the booking. + * + * Nullable: requests submitted before this change fall back to the contract's + * own currency, which is exactly what their bookings already used. + */ +export class AddBookingRequestCurrency2980000000000 implements MigrationInterface { + name = 'AddBookingRequestCurrency2980000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.booking_requests ADD COLUMN IF NOT EXISTS payment_currency varchar(5);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.booking_requests DROP COLUMN IF EXISTS payment_currency;`, + ); + } +} 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/bookings/booking-lifecycle-notifier.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.spec.ts new file mode 100644 index 000000000..2c21d804c --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.spec.ts @@ -0,0 +1,76 @@ +import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service'; +import type { Booking } from './entities/booking.entity'; + +/** + * Who hears "Operations wants changes" depends on who owns the booking. A + * customs (Path B) booking is created BY GL Ethiopia on the customer's behalf — + * the customer can neither edit nor resubmit it, so the note has to reach the GL + * who made it, not the portal. + */ +describe('BookingLifecycleNotifierService — operation changes requested', () => { + const booking = (over: Partial = {}): Booking => + ({ + id: 'b-1', + reference: 'BKG-0001', + companyId: 'co-1', + contractId: 'ctr-1', + createdByRole: 'CUSTOMER', + company: { email: 'customer@example.com' }, + ...over, + }) as Booking; + + let notifications: { directSend: jest.Mock }; + let inbox: { notify: jest.Mock }; + let service: BookingLifecycleNotifierService; + + const flush = () => new Promise((resolve) => setImmediate(resolve)); + + beforeEach(() => { + notifications = { directSend: jest.fn().mockResolvedValue(undefined) }; + inbox = { notify: jest.fn().mockResolvedValue(undefined) }; + service = new BookingLifecycleNotifierService( + notifications as never, + inbox as never, + { query: jest.fn().mockResolvedValue([{ phone: '+251900000000' }]) } as never, + ); + }); + + it('sends a GL-created booking back to the GL who created it, not the customer', async () => { + service.operationChangesRequested( + booking({ createdByRole: 'GL_ET', createdByUserId: 'gl-user-1' }), + 'Cargo weight does not match the declaration', + ); + await flush(); + + expect(inbox.notify).toHaveBeenCalledTimes(1); + const sent = inbox.notify.mock.calls[0][0]; + expect(sent.recipients).toEqual({ userIds: ['gl-user-1'] }); + expect(sent.audience).toBe('BACKOFFICE'); + expect(sent.body).toContain('Cargo weight does not match the declaration'); + // Deep-links the clearance page GL works from, not the portal booking. + expect(sent.link).toBe('/dashboard/contracts/clearance/ctr-1'); + // The customer is not told to fix something they cannot touch. + expect(notifications.directSend).not.toHaveBeenCalled(); + }); + + it('still tells the customer when the booking is their own', async () => { + service.operationChangesRequested(booking(), 'Please attach the packing list'); + await flush(); + + const sent = inbox.notify.mock.calls[0][0]; + expect(sent.recipients).toEqual({ companyId: 'co-1' }); + expect(sent.audience).toBe('PORTAL'); + expect(sent.link).toBe('/bookings/b-1'); + expect(notifications.directSend).toHaveBeenCalled(); + }); + + it('falls back to the customer when the GL creator is unknown (legacy rows)', async () => { + service.operationChangesRequested( + booking({ createdByRole: 'GL_ET', createdByUserId: null }), + 'Fix the declaration', + ); + await flush(); + + expect(inbox.notify.mock.calls[0][0].recipients).toEqual({ companyId: 'co-1' }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts index caa41f5e9..6bef20630 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts @@ -179,8 +179,35 @@ export class BookingLifecycleNotifierService { }); } - /** Operations returned the operation request for changes. */ + /** + * Operations returned the operation request for changes. + * + * A customs (Path B) booking was created BY GL Ethiopia on the customer's + * behalf — the customer cannot edit or resubmit it, so telling them to "update + * from the portal" is a dead end. Those go to the GL who created it, linking + * the contract clearance page they work from. Everything else (customer-made + * bookings) keeps the portal message. + */ operationChangesRequested(b: Booking, note: string): void { + if (b.createdByRole === 'GL_ET' && b.createdByUserId) { + const msg = + `Operations returned booking ${b.reference} for changes: ${note}. ` + + `Address it on the contract clearance page and resubmit to Operations.`; + this.logger.log(`OPERATION CHANGES REQUESTED (to GL) — ${this.ref(b)}`); + void this.inbox.notify({ + recipients: { userIds: [b.createdByUserId] }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.BOOKING_STATUS, + title: `Booking ${b.reference} needs changes`, + body: msg, + link: b.contractId + ? `/dashboard/contracts/clearance/${b.contractId}` + : `/dashboard/bookings/${b.id}/clearance`, + data: { bookingId: b.id, reference: b.reference, note }, + }); + return; + } + const msg = `Your operation request for booking ${b.reference} needs changes: ${note}. ` + `Please update and resubmit from the portal.`; @@ -197,6 +224,24 @@ export class BookingLifecycleNotifierService { this.inApp(b, 'Operation request accepted', msg); } + /** + * GL Ethiopia created this booking on the customer's behalf. On a customs + * (Path B) contract the customer never books themselves, so without this they + * would have no signal that their shipment now exists and is priced. + */ + createdByGlForCustomer(b: Booking): void { + const total = Number(b.totalAmount ?? 0); + const priced = + total > 0 + ? ` The total is ${total.toLocaleString()} ${b.paymentCurrency}.` + : ''; + const msg = + `Global Logistics has created shipment ${b.reference} under your contract.${priced} ` + + `You can review it in the portal.`; + void this.notifyContact(b, msg, 'CREATED BY GL'); + this.inApp(b, 'Shipment created for you', msg); + } + /** Shipment started → in transit. */ inTransit(b: Booking): void { const msg = `Your shipment for booking ${b.reference} is now in transit.`; diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts index 667abe842..4996f45a9 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts @@ -473,3 +473,247 @@ describe('BookingPricingService — customs clearance fee billed on the booking expect(result.lineItems.some((l) => l.code.startsWith('CUSTOMS_CLEARANCE'))).toBe(false); }); }); + +/** + * Bulk freight bills in the commodity's own unit: tonnage for a weighed + * commodity (PER_TON), item count for a counted one (PER_ITEM). Both read the + * booking's cargo amount; PER_WAGON bills the wagons the cargo occupies. + */ +describe('BookingPricingService — bulk base freight units', () => { + const DJ = 'yard-dj-bulk'; + const DIRE_B = 'yard-dire-bulk'; + + const bulkRate = (overrides: Partial = {}): Rate => + ({ + id: 'rate-bulk', + rateType: 'BULK_IMPORT', + appliesTo: 'BULK', + trigger: 'ALWAYS', + currency: 'USD', + rateValue: 200, + rateUnit: 'PER_ITEM', + status: 'LIVE', + containerTypeId: null, + cargoTypeId: null, + tradeDirection: 'IMPORT', + originYardId: DJ, + destinationYardId: DIRE_B, + ...overrides, + }) as Rate; + + const makeService = (liveRates: Rate[], wagonCapacity?: number) => + new BookingPricingService( + { + calculateWagonCount: jest.fn().mockResolvedValue(0), + findContractRateSnapshots: jest.fn().mockResolvedValue([]), + } as never, + { + evaluate: jest.fn().mockResolvedValue({ + priorityScore: 0, + appliedModifiers: [], + containerWeightResults: [], + warnings: [], + hardBlocked: [], + requiresDirectorApproval: false, + }), + } as never, + { findById: jest.fn() } as never, + { findLiveRates: jest.fn().mockResolvedValue(liveRates) } as never, + { getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never, + { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, + { + findById: jest.fn().mockResolvedValue({ + wagonTypes: wagonCapacity !== undefined ? [{ capacityTons: wagonCapacity }] : [], + }), + } as never, + ); + + // 12 machines, not 12 tonnes — a PER_ITEM commodity records its count here. + const booking = (overrides: Record = {}) => + ({ + id: 'b-bulk', + freightType: 'BULK', + tradeDirection: 'IMPORT', + paymentCurrency: 'USD', + cargoTypeId: 'cargo-machinery', + cargoTotalWeightVgm: 12, + originYardId: DJ, + destinationYardId: DIRE_B, + bookingContainers: [], + ...overrides, + }) as unknown as Booking; + + it('bills a PER_ITEM rate on the item count', async () => { + const result = await makeService([bulkRate()]).computePriceForBooking(booking()); + + const line = result.lineItems.find((l) => l.code === 'BULK_IMPORT'); + expect(line!.unit).toBe('PER_ITEM'); + expect(line!.quantity).toBe(12); + expect(line!.amount).toBe(2400); + }); + + it('bills a PER_TON rate on the tonnage', async () => { + const result = await makeService([ + bulkRate({ rateUnit: 'PER_TON', rateValue: 35 }), + ]).computePriceForBooking(booking({ cargoTotalWeightVgm: 120 })); + + const line = result.lineItems.find((l) => l.code === 'BULK_IMPORT'); + expect(line!.unit).toBe('PER_TON'); + expect(line!.amount).toBe(35 * 120); + }); + + it('bills a PER_WAGON rate on the wagons the cargo occupies, not zero', async () => { + const result = await makeService( + [bulkRate({ rateUnit: 'PER_WAGON', rateValue: 500 })], + 60, + ).computePriceForBooking(booking({ cargoTotalWeightVgm: 120 })); + + const line = result.lineItems.find((l) => l.code === 'BULK_IMPORT'); + expect(line!.unit).toBe('PER_WAGON'); + expect(line!.quantity).toBe(2); // 120 t ÷ 60 t per wagon + expect(line!.amount).toBe(1000); + }); + + it('prices off the rate scoped to the booking commodity, not another one', async () => { + const result = await makeService([ + bulkRate({ id: 'rate-wheat', cargoTypeId: 'cargo-wheat', rateUnit: 'PER_TON', rateValue: 35 }), + bulkRate({ id: 'rate-machinery', cargoTypeId: 'cargo-machinery', rateValue: 200 }), + ]).computePriceForBooking(booking()); + + const line = result.lineItems.find((l) => l.code === 'BULK_IMPORT'); + expect(line!.unit).toBe('PER_ITEM'); + expect(line!.amount).toBe(2400); + }); + + it('hard-blocks when the leg only carries another commodity’s rate', async () => { + const result = await makeService([ + bulkRate({ id: 'rate-wheat', cargoTypeId: 'cargo-wheat' }), + ]).computePriceForBooking(booking()); + + expect(result.lineItems.some((l) => l.code === 'BULK_IMPORT')).toBe(false); + expect(result.hardBlocked.some((m) => m.includes('rate is configured'))).toBe(true); + }); +}); + +/** + * A PER_WAGON container rate bills the wagons the LINE occupies — two 20ft share + * one wagon, a 40ft takes a whole one. Regression cases taken from real + * bookings on Doraleh → Gelan, where the 20ft line was being charged for the + * 40ft line's wagons as well. + */ +describe('BookingPricingService — PER_WAGON container freight', () => { + const DJ = 'yard-dj-w'; + const ET = 'yard-et-w'; + + const perWagon20: Rate = { + id: 'rate-20-wagon', + rateType: 'CONTAINER_IMPORT', + currency: 'USD', + rateValue: 1690, + rateUnit: 'PER_WAGON', + status: 'LIVE', + containerTypeId: 'ct-20', + originYardId: DJ, + destinationYardId: ET, + } as Rate; + + const perContainer40: Rate = { + ...perWagon20, + id: 'rate-40-container', + rateValue: 1676, + rateUnit: 'PER_CONTAINER', + containerTypeId: 'ct-40', + } as Rate; + + const makeService = () => + new BookingPricingService( + { + // Booking-wide aggregate — deliberately larger than any single line, so + // a regression that reads it instead of the line's own wagons shows up. + calculateWagonCount: jest.fn().mockResolvedValue(5), + findContractRateSnapshots: jest.fn().mockResolvedValue([]), + } as never, + { + evaluate: jest.fn().mockResolvedValue({ + priorityScore: 0, + appliedModifiers: [], + containerWeightResults: [], + warnings: [], + hardBlocked: [], + requiresDirectorApproval: false, + }), + } as never, + { + findById: jest.fn(async (id: string) => ({ + id, + sizeFt: id === 'ct-40' ? 40 : 20, + isReefer: false, + code: id === 'ct-40' ? 'C40' : 'C20', + label: id === 'ct-40' ? 'C40' : 'C20', + })), + } as never, + { findLiveRates: jest.fn().mockResolvedValue([perWagon20, perContainer40]) } as never, + { getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never, + { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, + { findById: jest.fn() } as never, + ); + + const booking = ( + lines: Array<{ containerTypeId: string; quantity: number }>, + ) => + ({ + id: 'b-wagon', + freightType: 'CONTAINER', + tradeDirection: 'IMPORT', + paymentCurrency: 'USD', + originYardId: DJ, + destinationYardId: ET, + bookingContainers: lines.map((l) => ({ + containerTypeId: l.containerTypeId, + quantity: l.quantity, + vgmPerUnitTons: 10, + })), + }) as unknown as Booking; + + const price = async ( + lines: Array<{ containerTypeId: string; quantity: number }>, + ) => { + const service = makeService(); + const result = await service.computePriceForBooking(booking(lines)); + return result.lineItems.filter((l) => l.code === 'CONTAINER_IMPORT'); + }; + + it('bills 2× 20ft as one wagon', async () => { + const [line] = await price([{ containerTypeId: 'ct-20', quantity: 2 }]); + expect(line.unit).toBe('PER_WAGON'); + expect(line.quantity).toBe(1); + expect(line.amount).toBe(1690); + }); + + it('bills 10× 20ft as five wagons', async () => { + const [line] = await price([{ containerTypeId: 'ct-20', quantity: 10 }]); + expect(line.quantity).toBe(5); + expect(line.amount).toBe(5 * 1690); + }); + + it('does not charge the 20ft line for the 40ft line’s wagons', async () => { + const lines = await price([ + { containerTypeId: 'ct-20', quantity: 4 }, + { containerTypeId: 'ct-40', quantity: 1 }, + ]); + const twenty = lines.find((l) => l.description.startsWith('C20'))!; + const forty = lines.find((l) => l.description.startsWith('C40'))!; + // 4× 20ft = 2 wagons, NOT the booking-wide 3. + expect(twenty.quantity).toBe(2); + expect(twenty.amount).toBe(2 * 1690); + // The 40ft line keeps billing per container. + expect(forty.quantity).toBe(1); + expect(forty.amount).toBe(1676); + }); + + it('rounds an odd 20ft count up to a whole wagon', async () => { + const [line] = await price([{ containerTypeId: 'ct-20', quantity: 5 }]); + expect(line.quantity).toBe(3); + expect(line.amount).toBe(3 * 1690); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index 89825e100..ec2fc5f2e 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -4,6 +4,7 @@ import { CargoTypesService } from '../rule-engine/services/cargo-types.service'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { RatesService } from '../rule-engine/services/rates.service'; import { Rate } from '../rule-engine/entities/rate.entity'; +import { isBulkQuantityUnit } from '../rule-engine/entities/rate-unit.util'; import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity'; import { ExchangeService } from '@edr/api-common'; import { @@ -199,7 +200,7 @@ export class BookingPricingService { // route's container freight, never a frozen OVERWEIGHT_PER_TON value. const frozen = isDerived ? null - : this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency); + : this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency, usdToEtb); const unitAmount = frozen ? Number(frozen.unitPrice) : isEtbBooking @@ -529,8 +530,6 @@ export class BookingPricingService { const usedRatesMap = new Map(); const warnings: string[] = []; const blocked: string[] = []; - const wagonCount = await this.resolveWagonCount(booking); - for (const container of evalInput.containers) { const rate = this.pickRate( liveRates, @@ -540,14 +539,15 @@ export class BookingPricingService { booking.originYardId, booking.destinationYardId, ); - // H15: frozen contract rate for this container size, when present — its - // unitPrice is already in the booking currency (no USD→currency convert). - // It also stands on its own: a contract line prices off the agreed rate - // even when nobody configured a live rate for this leg + type yet. + // H15: frozen contract rate for this container size, when present — + // converted into the booking currency by frozenRateForContainer. It also + // stands on its own: a contract line prices off the agreed rate even when + // nobody configured a live rate for this leg + type yet. const frozen = await this.frozenRateForContainer( frozenRates, container.containerTypeId, paymentCurrency, + usdToEtb, ); const label = await this.containerTypeLabel(container.containerTypeId); if (!rate && !frozen) { @@ -565,6 +565,10 @@ export class BookingPricingService { } const rateUnit = rate?.rateUnit ?? 'PER_CONTAINER'; + // A PER_WAGON line bills the wagons THIS line occupies (two 20ft share + // one), never the booking-wide count — otherwise a booking with a 20ft + // and a 40ft line charges each line for the other's wagons too. + const lineWagons = await this.lineWagonCount(container); let amount: number; let unitAmount: number; if (frozen) { @@ -573,11 +577,11 @@ export class BookingPricingService { rateUnit, unitAmount, container.quantity, - wagonCount, + lineWagons, ); } else { const unitUsd = Number(rate!.rateValue); - const usdAmount = this.amountForRate(rate!, container.quantity, wagonCount); + const usdAmount = this.amountForRate(rate!, container.quantity, lineWagons); amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd; } @@ -588,7 +592,7 @@ export class BookingPricingService { amount, unitAmount, unit: rateUnit, - quantity: this.effectiveUnitQuantity(rateUnit, container.quantity, wagonCount), + quantity: this.effectiveUnitQuantity(rateUnit, container.quantity, lineWagons), currency: paymentCurrency, }); } @@ -600,7 +604,10 @@ export class BookingPricingService { // container type above or stay unpriced with a warning — falling back to // a corridor rate of a DIFFERENT container type billed once (qty 1) is // how a 38-container booking was invoiced 40 USD instead of 1900. - const fallback = liveRates.find( + // Within the leg, the rate scoped to the booking's own commodity wins over + // the commodity-wide catch-all — a per-item machinery rate must never + // price a per-ton wheat booking (or the reverse). + const onLeg = liveRates.filter( (r) => r.rateType === rateType && r.currency === 'USD' && @@ -608,15 +615,29 @@ export class BookingPricingService { r.originYardId === booking.originYardId && r.destinationYardId === booking.destinationYardId, ); + const fallback = + (booking.cargoTypeId + ? onLeg.find((r) => r.cargoTypeId === booking.cargoTypeId) + : undefined) ?? onLeg.find((r) => !r.cargoTypeId); if (fallback) { usedRatesMap.set(fallback.id, fallback); - const bulkTons = Number(booking.cargoTotalWeightVgm ?? 0); + // Bulk has no container lines to count wagons from, so a PER_WAGON bulk + // rate bills the tonnage-derived estimate for the WHOLE booking (there + // is only ever this one line). + const wagonCount = isBulk + ? Number(evalInput.bulkWagons ?? 0) || + (await this.bulkWagonCount(booking)) || + 0 + : await this.resolveWagonCount(booking); + // Bulk quantity is stored in the commodity's own unit — tonnes for a + // PER_TON commodity, item count for a PER_ITEM one. + const bulkQuantity = Number(booking.cargoTotalWeightVgm ?? 0); const quantity = - isBulk && fallback.rateUnit === 'PER_TON' ? Math.max(bulkTons, 0) : 1; + isBulk && isBulkQuantityUnit(fallback.rateUnit) ? Math.max(bulkQuantity, 0) : 1; const unitUsd = Number(fallback.rateValue); // H15: bulk freight uses the frozen BULK_FREIGHT snapshot when present. const frozen = isBulk - ? this.frozenRateByCode(frozenRates, 'BULK_FREIGHT', paymentCurrency) + ? this.frozenRateByCode(frozenRates, 'BULK_FREIGHT', paymentCurrency, usdToEtb) : null; let amount: number; let unitAmount: number; @@ -719,6 +740,7 @@ export class BookingPricingService { quantity = containerCount; break; case 'PER_TON': + case 'PER_ITEM': quantity = bulkTons; break; case 'FLAT': @@ -727,12 +749,13 @@ export class BookingPricingService { break; } - // H15: frozen mile rate (already in booking currency) when the contract - // has one; else the live USD rate converted as before. + // H15: frozen mile rate (converted into the booking currency) when the + // contract has one; else the live USD rate converted as before. const frozen = this.frozenRateByCode( frozenRates, leg.rateType, paymentCurrency, + usdToEtb, ); let amount: number; let unitAmount: number; @@ -764,6 +787,34 @@ export class BookingPricingService { return { lineItems: lines, usedRates: [...usedRatesMap.values()] }; } + /** + * Wagons ONE container line occupies: two 20ft share a wagon, a 40ft takes a + * whole one. This — not the booking-wide total — is what a PER_WAGON base + * freight line bills, so a booking of 4×20ft + 1×40ft charges the 20ft line + * for 2 wagons and the 40ft line for its own 1, instead of billing each line + * for all 3. + */ + private async lineWagonCount(container: { + containerTypeId: string; + quantity: number; + wagonsPerUnit?: number; + }): Promise { + let perUnit = container.wagonsPerUnit; + if (perUnit == null) { + // Preview bookings build their eval input without the fraction — read it + // off the container type instead of assuming one wagon per box. + try { + const ct = await this.containerTypesService.findById( + container.containerTypeId, + ); + perUnit = wagonsPerUnitForSize(Number(ct.sizeFt)); + } catch { + perUnit = 1; // unknown type: never under-bill + } + } + return Math.max(1, Math.ceil(container.quantity * perUnit)); + } + /** * Wagon count for PER_WAGON rates. A persisted booking uses the SQL aggregate; * an unsaved preview booking (no id) sums the wagonsRequired already computed @@ -804,6 +855,7 @@ export class BookingPricingService { return 1; case 'PER_CONTAINER': case 'PER_TON': + case 'PER_ITEM': default: return quantity; } @@ -860,6 +912,7 @@ export class BookingPricingService { case 'PER_WAGON': return unitValue * wagonCount; case 'PER_TON': + case 'PER_ITEM': return unitValue * quantity; case 'FLAT': return unitValue; @@ -889,20 +942,45 @@ export class BookingPricingService { } /** - * The frozen snapshot for a rate code, or null when there is none, its price - * is negative, or it is in a different currency than the booking (in which - * case the live-rate path is safer than a mis-converted frozen price). + * The frozen snapshot for a rate code, expressed in the BOOKING's currency. + * + * A contract quotes in USD and freezes USD unit prices; the customer chooses + * the billing currency per booking. So a currency mismatch is the normal case + * now, not an error — the snapshot is converted rather than discarded. (It + * previously returned null on mismatch, which silently dropped the agreed + * contract price and re-priced the booking at whatever the live rate had + * drifted to.) Grandfathered ETB contracts convert the other way for the same + * reason. + * + * Returns null only when there is no snapshot or its price is unusable. */ private frozenRateByCode( frozenRates: Map | null, code: string, bookingCurrency: string, + usdToEtb: number, ): ContractRateSnapshot | null { const snap = frozenRates?.get(code); if (!snap) return null; - if (snap.currency !== bookingCurrency) return null; - if (!(Number(snap.unitPrice) >= 0)) return null; - return snap; + const unitPrice = Number(snap.unitPrice); + if (!(unitPrice >= 0)) return null; + if (snap.currency === bookingCurrency) return snap; + + // Only USD <-> ETB exist; a rate of 0/NaN would silently zero the price. + if (!(usdToEtb > 0)) return null; + const converted = + snap.currency === 'USD' && bookingCurrency === 'ETB' + ? Math.round(unitPrice * usdToEtb) + : snap.currency === 'ETB' && bookingCurrency === 'USD' + ? unitPrice / usdToEtb + : null; + if (converted == null) return null; + + // A copy — the snapshot rows are shared across the pricing pass. + return Object.assign(Object.create(Object.getPrototypeOf(snap)), snap, { + unitPrice: converted, + currency: bookingCurrency, + }) as ContractRateSnapshot; } /** @@ -914,6 +992,7 @@ export class BookingPricingService { frozenRates: Map | null, containerTypeId: string, bookingCurrency: string, + usdToEtb: number, ): Promise { if (!frozenRates) return null; let sizeFt: number | null = null; @@ -923,7 +1002,7 @@ export class BookingPricingService { return null; } if (!sizeFt) return null; - return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency); + return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency, usdToEtb); } /** @@ -966,7 +1045,7 @@ export class BookingPricingService { const hasPerSizeSnapshot = frozenRates?.has('CUSTOMS_CLEARANCE_20FT') || frozenRates?.has('CUSTOMS_CLEARANCE_40FT'); - const legacyFlat = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency); + const legacyFlat = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency, usdToEtb); if (legacyFlat && !hasPerSizeSnapshot) { const amount = Number(legacyFlat.unitPrice); if (amount > 0) { @@ -995,7 +1074,7 @@ export class BookingPricingService { // unknown type — falls through to the live per-type lookup below } const frozen = sizeFt - ? this.frozenRateByCode(frozenRates, `CUSTOMS_CLEARANCE_${sizeFt}FT`, currency) + ? this.frozenRateByCode(frozenRates, `CUSTOMS_CLEARANCE_${sizeFt}FT`, currency, usdToEtb) : null; const live = onLeg.find((r) => r.containerTypeId === bc.containerTypeId); if (!frozen && !live) { @@ -1030,7 +1109,7 @@ export class BookingPricingService { // flat snapshot share the CUSTOMS_CLEARANCE code; both are the agreed fee. // Live lookup: the rate scoped to the booking's commodity wins; a // commodity-less rate (legacy) is the catch-all fallback. - const frozen = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency); + const frozen = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency, usdToEtb); const live = (booking.cargoTypeId ? onLeg.find( @@ -1044,7 +1123,7 @@ export class BookingPricingService { const unit = frozen ? this.rateUnitFromSnapshot(frozen.unitOfMeasure) : live!.rateUnit; const unitAmount = frozen ? Number(frozen.unitPrice) : convert(Number(live!.rateValue)); let billedQty = 1; - if (unit === 'PER_TON') { + if (isBulkQuantityUnit(unit)) { billedQty = Math.max(0, Number(booking.cargoTotalWeightVgm ?? 0)); } else if (unit === 'PER_WAGON') { const wagons = await this.bulkWagonCount(booking); @@ -1081,6 +1160,8 @@ export class BookingPricingService { return 'PER_WAGON'; case 'per_ton': return 'PER_TON'; + case 'per_item': + return 'PER_ITEM'; case 'per_container': return 'PER_CONTAINER'; default: diff --git a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts index 507ef43d8..06268342b 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts @@ -33,41 +33,86 @@ import { BookingReferenceYardDto, } from "./dto/booking-reference-data.dto"; +/** + * Reference cargo tree: top-level groups, each carrying its selectable + * commodities. + * + * `cargo_types` is an arbitrary-depth tree (Bulk → Steel Billet → S1 → …), but + * only a LEAF is a real commodity — an intermediate node is a container for + * finer types, and booking against it would be ambiguous. So each group's + * `children` are all of its leaf descendants, flattened, whatever the depth. + * Deep leaves carry their path below the group ("Steel Billet → S1") so a + * generically-named leaf still reads unambiguously in a dropdown. + * + * A group with no active descendants is its own leaf and is emitted as its + * single child — otherwise it is selectable as a group but offers no commodity, + * which dead-ends every form that requires one. + */ export function buildCargoTypeTree( rows: CargoType[], ): BookingReferenceCargoTypeGroupDto[] { const active = rows.filter((r) => r.isActive); - const parents = active - .filter((r) => !r.parentGroupId) - .sort( - (a, b) => a.displayOrder - b.displayOrder || a.code.localeCompare(b.code), - ); + + const byOrder = (a: CargoType, b: CargoType) => + a.displayOrder - b.displayOrder || a.code.localeCompare(b.code); + + const childrenOf = new Map(); + for (const row of active) { + if (!row.parentGroupId) continue; + const siblings = childrenOf.get(row.parentGroupId) ?? []; + siblings.push(row); + childrenOf.set(row.parentGroupId, siblings); + } + for (const siblings of childrenOf.values()) siblings.sort(byOrder); + + const parents = active.filter((r) => !r.parentGroupId).sort(byOrder); + + /** Depth-first leaf walk; `trail` is the path below the group. */ + const collectLeaves = ( + node: CargoType, + trail: string[], + seen: Set, + ): BookingReferenceCargoTypeChildDto[] => { + // Admin-entered parent pointers could in principle cycle — never loop. + if (seen.has(node.id)) return []; + seen.add(node.id); + + const kids = childrenOf.get(node.id) ?? []; + if (kids.length === 0) { + return [ + { + id: node.id, + name: [...trail, node.cargoTypeName].join(" → "), + code: node.code, + unit_of_measure: node.unitOfMeasure ?? null, + }, + ]; + } + const nextTrail = [...trail, node.cargoTypeName]; + return kids.flatMap((kid) => collectLeaves(kid, nextTrail, seen)); + }; return parents.map((parent) => { - const children = active - .filter((r) => r.parentGroupId === parent.id) - .sort( - (a, b) => - a.displayOrder - b.displayOrder || a.code.localeCompare(b.code), - ) - .map( - (child): BookingReferenceCargoTypeChildDto => ({ - id: child.id, - name: child.cargoTypeName, - code: child.code, - unit_of_measure: child.unitOfMeasure ?? null, - }), - ); + const kids = childrenOf.get(parent.id) ?? []; + const children = + kids.length === 0 + ? // The group itself is the commodity. + [ + { + id: parent.id, + name: parent.cargoTypeName, + code: parent.code, + unit_of_measure: parent.unitOfMeasure ?? null, + }, + ] + : kids.flatMap((kid) => collectLeaves(kid, [], new Set())); - const group: BookingReferenceCargoTypeGroupDto = { + return { id: parent.id, name: parent.cargoTypeName, code: parent.code, + children, }; - if (children.length > 0) { - group.children = children; - } - return group; }); } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index c292a0395..4ebfc9fab 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -916,14 +916,15 @@ export class BookingsController { async uploadBookingDeliveryOrder( @Param('id', ParseUUIDPipe) id: string, @UploadedFile() file: Express.Multer.File, - @Body('vesselDepartureDate') vesselDepartureDate: string | undefined, + @Body('vesselArrivalDate') vesselArrivalDate: string | undefined, + @Body('doCollectedDate') doCollectedDate: string | undefined, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingClearanceService.uploadDeliveryOrder( id, file, resolveAuthUserId(user), - vesselDepartureDate, + { vesselArrivalDate, doCollectedDate }, ); return this.transitionService.enrichBookingResponse(booking); } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 590bd7262..a0b3b4e0e 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -123,7 +123,9 @@ export class BookingsRepository extends BaseRepository { 'booking.files', FileRecord, 'file', - "file.resource_id = booking.id AND file.resource = 'bookings'", + // Superseded versions are soft-deleted, not dropped — keep them out of + // the live file list (a manual join condition is not filtered for us). + "file.resource_id = booking.id AND file.resource = 'bookings' AND file.deleted_at IS NULL", ) .getOne(); diff --git a/apps/edr-freight-api/src/modules/bookings/cargo-type-tree.spec.ts b/apps/edr-freight-api/src/modules/bookings/cargo-type-tree.spec.ts new file mode 100644 index 000000000..aaa819505 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/cargo-type-tree.spec.ts @@ -0,0 +1,72 @@ +import { buildCargoTypeTree } from './booking-reference-data.service'; +import type { CargoType } from '../rule-engine/entities/cargo-type.entity'; + +const node = ( + id: string, + name: string, + parentGroupId: string | null, + isActive = true, +): CargoType => + ({ + id, + cargoTypeName: name, + code: name.toUpperCase().replace(/\s+/g, '_'), + parentGroupId, + displayOrder: 0, + isActive, + unitOfMeasure: 'PER_TON', + }) as unknown as CargoType; + +describe('buildCargoTypeTree', () => { + // Bulk ──┬─ Wheat (leaf, depth 2) + // └─ Steel Billet ──┬─ S1 (leaf, depth 3) + // └─ S2 ─ S2a (leaf, depth 4) + const rows = [ + node('bulk', 'Bulk', null), + node('wheat', 'Wheat', 'bulk'), + node('steel', 'Steel Billet', 'bulk'), + node('s1', 'S1', 'steel'), + node('s2', 'S2', 'steel'), + node('s2a', 'S2a', 's2'), + node('general', 'General Cargo', null), + ]; + + it('offers only leaves as commodities, at any depth', () => { + const [bulk] = buildCargoTypeTree(rows); + + // Leaves stay grouped under their branch (siblings ordered by + // displayOrder then code — STEEL_BILLET before WHEAT here). + expect(bulk.children?.map((c) => c.id)).toEqual(['s1', 's2a', 'wheat']); + // "Steel Billet" is a container for finer types, never bookable itself. + expect(bulk.children?.some((c) => c.id === 'steel')).toBe(false); + }); + + it('labels deep leaves with their path below the group', () => { + const [bulk] = buildCargoTypeTree(rows); + const byId = new Map(bulk.children?.map((c) => [c.id, c.name])); + + expect(byId.get('wheat')).toBe('Wheat'); + expect(byId.get('s1')).toBe('Steel Billet → S1'); + expect(byId.get('s2a')).toBe('Steel Billet → S2 → S2a'); + }); + + it('emits a childless group as its own commodity', () => { + const general = buildCargoTypeTree(rows).find((g) => g.id === 'general'); + + expect(general?.children).toEqual([ + expect.objectContaining({ id: 'general', name: 'General Cargo' }), + ]); + }); + + it('skips inactive nodes and their descendants', () => { + const withRetired = [ + ...rows, + node('retired', 'Retired', 'bulk', false), + node('retiredKid', 'Retired Kid', 'retired', false), + ]; + const [bulk] = buildCargoTypeTree(withRetired); + + expect(bulk.children?.map((c) => c.id)).not.toContain('retired'); + expect(bulk.children?.map((c) => c.id)).not.toContain('retiredKid'); + }); +}); 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 821b9c9f6..4ab9e397e 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; @@ -526,6 +540,14 @@ export class Booking extends BaseEntity { @Column({ name: 'vessel_departure_date', type: 'date', nullable: true }) vesselDepartureDate?: string | null; + /** Import DO: when the vessel arrived in Djibouti. Required on DO upload. */ + @Column({ name: 'vessel_arrival_date', type: 'date', nullable: true }) + vesselArrivalDate?: string | null; + + /** Import DO: when GL Djibouti collected the DO. Required on DO upload. */ + @Column({ name: 'do_collected_date', type: 'date', nullable: true }) + doCollectedDate?: string | null; + @Column({ name: 'ro_amendment_requested_at', type: 'timestamptz', nullable: true }) roAmendmentRequestedAt?: Date | null; 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 3867bef3a..6650dfca5 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -5,7 +5,7 @@ import { BadRequestException, ForbiddenException, } from "@nestjs/common"; -import { DataSource } from "typeorm"; +import { DataSource, EntityManager } from "typeorm"; import { CompaniesRepository } from "./companies.repository"; import { CompanyProfileRepository } from "./company-profile.repository"; import { CompanyChangeRequestRepository } from "./company-change-request.repository"; @@ -1111,9 +1111,11 @@ export class CompaniesService { } // Anything other than approval has no document gate and no concurrency - // hazard — apply it directly. + // hazard — no row lock, just the write. if (status !== ProfileStatus.Active) { - return this.applyProfileStatus(existing, status, note, reviewerId); + return this.dataSource.transaction((manager) => + this.applyProfileStatus(manager, existing, status, note, reviewerId), + ); } // Approving over an outstanding document correction would silently accept the @@ -1149,7 +1151,7 @@ export class CompaniesService { ); } - return this.applyProfileStatus(existing, status, note, reviewerId); + return this.applyProfileStatus(manager, existing, status, note, reviewerId); }); } @@ -1160,11 +1162,21 @@ export class CompaniesService { * transaction while every other status skips that overhead. */ private async applyProfileStatus( + manager: EntityManager, existing: CompanyProfile, status: ProfileStatus, note?: string, reviewerId?: string, ): Promise { + // Every write below goes through `manager`. The approval path holds a + // pessimistic_write lock on the company row, and the injected repositories + // are bound to the DataSource's default pool — writing the same row through + // one of them would block on a lock this very transaction holds, hanging the + // request until the statement timed out. That deadlocked the first approval + // of any customer: the profile went Active on its own connection while the + // company stayed Pending and the caller never got a response. + const profileRepo = manager.getRepository(CompanyProfile); + const companyRepo = manager.getRepository(Company); // A reference number is only minted the first time a profile is approved // (status → Active). Pending/unapproved profiles carry no reference. const patch: Partial = { status }; @@ -1190,7 +1202,8 @@ export class CompaniesService { patch.reviewedAt = new Date(); } - const updated = await this.companyProfilesRepo.update(existing.id, patch); + await profileRepo.update(existing.id, patch); + const updated = await profileRepo.findOne({ where: { id: existing.id } }); if (!updated) throw new NotFoundException(`Company profile ${existing.id} not found`); @@ -1208,7 +1221,9 @@ export class CompaniesService { : "approved" : null; if (change) { - const company = await this.companiesRepo.findById(updated.companyId); + const company = await companyRepo.findOne({ + where: { id: updated.companyId }, + }); if (company) { this.companyNotifier.profileStatusChanged( company, @@ -1222,7 +1237,7 @@ export class CompaniesService { status === ProfileStatus.Active && company.status === CompanyStatus.Pending ) { - await this.companiesRepo.update(updated.companyId, { + await companyRepo.update(updated.companyId, { status: CompanyStatus.Active, }); this.companyNotifier.companyApproved(company); diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts index 810232993..69773010c 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts @@ -19,6 +19,7 @@ import { } from './entities/clearance-milestone.entity'; import { Booking } from '../bookings/entities/booking.entity'; import { clearanceCodesForBooking } from '../bookings/clearance.util'; +import { assertDoCollectionDates } from './contract-clearance.util'; import { ClearanceWorkflowService } from './clearance-workflow.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { GlOperationsService } from './gl-operations.service'; @@ -64,6 +65,9 @@ export interface BookingClearanceView { roHold?: boolean; roHoldReason?: string | null; vesselDepartureDate?: string | null; + /** Import DO dates recorded by GL Djibouti on upload. */ + vesselArrivalDate?: string | null; + doCollectedDate?: string | null; roAmendmentRequestedAt?: string | null; operationReady?: boolean; preClearanceFinalized?: boolean; @@ -261,6 +265,8 @@ export class BookingClearanceService { roHold: Boolean(booking.roHoldReason), roHoldReason: booking.roHoldReason ?? null, vesselDepartureDate: booking.vesselDepartureDate ?? null, + vesselArrivalDate: booking.vesselArrivalDate ?? null, + doCollectedDate: booking.doCollectedDate ?? null, roAmendmentRequestedAt: booking.roAmendmentRequestedAt ? booking.roAmendmentRequestedAt.toISOString() : null, @@ -547,7 +553,7 @@ export class BookingClearanceService { bookingId: string, file: Express.Multer.File, userId?: string, - vesselDepartureDate?: string, + dates?: { vesselArrivalDate?: string; doCollectedDate?: string }, ): Promise { const booking = await this.loadBooking(bookingId); if (booking.tradeDirection !== 'IMPORT') { @@ -556,6 +562,8 @@ export class BookingClearanceService { if (!file) throw new BadRequestException('No Delivery Order uploaded'); + const { vesselArrivalDate, doCollectedDate } = assertDoCollectionDates(dates); + // DO upload is deliberately un-gated: GL Djibouti may attach it at any point, // any file type. The DO_COLLECTED milestone (and operation readiness) still // waits for GL Ethiopia to finalize pre-clearance so the workflow order holds. @@ -566,11 +574,10 @@ export class BookingClearanceService { file, }); - if (vesselDepartureDate?.trim()) { - await this.bookingsRepository.update(bookingId, { - vesselDepartureDate: vesselDepartureDate.trim(), - } as never); - } + await this.bookingsRepository.update(bookingId, { + vesselArrivalDate, + doCollectedDate, + } as never); if (booking.preClearanceFinalizedAt) { await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId); diff --git a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts index a17406d64..99fed335a 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts @@ -30,7 +30,11 @@ export class BookingRequestService { private readonly notifier: ContractNotifierService, ) {} - /** Only GENERAL contracts that bundle customs use the request → GL → clearance flow. */ + /** + * Only GENERAL contracts that bundle customs use the request → GL → clearance + * flow. A ONE_TIME customs contract runs its clearance at the contract level + * and GL books it directly, with no customer-facing request step. + */ private assertGeneralCustoms(contract: Contract): void { if ( contract.contractKind !== 'GENERAL' || @@ -121,7 +125,11 @@ export class BookingRequestService { // instance is created first so a failure leaves no half-linked request. const booking = await this.contractBookingService.initiateForShipmentRequest( contract, - { contractRouteId: dto.contractRouteId, userId }, + { + contractRouteId: dto.contractRouteId, + userId, + paymentCurrency: dto.paymentCurrency, + }, ); const reference = await this.generateReference(); @@ -134,6 +142,11 @@ export class BookingRequestService { status: 'ACCEPTED', createdBookingId: booking.id, requestedLines, + // Intercity is invoiced in birr whatever the customer picked. + paymentCurrency: + contract.tradeDirection === 'DOMESTIC' + ? 'ETB' + : (dto.paymentCurrency ?? contract.paymentCurrency ?? 'USD'), notes: dto.notes ?? null, } as never); this.notifier.shipmentRequestedToStaff(contract, request.id, request.reference); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts index bb74f062c..4bc768cc1 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts @@ -57,7 +57,10 @@ describe('ContractBookingService — drawdown consolidation gate', () => { milestoneService as never, {} as never, // workflowService invoiceService as never, - { createdToStaff: jest.fn() } as never, // bookingNotifier + { + createdToStaff: jest.fn(), + createdByGlForCustomer: jest.fn(), + } as never, // bookingNotifier {} as never, // dataSource {} as never, // trainSchedulingService {} as never, // bookingBatchService diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 8e102e5a1..7e657cc07 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -277,7 +277,7 @@ export class ContractBookingService { createdByUserId: user?.id ?? null, scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null, serviceTypeId: contract.serviceTypeId, - paymentCurrency: contract.paymentCurrency, + paymentCurrency: this.resolveShipmentCurrency(contract, dto.paymentCurrency), contractType: 'NEW', customsClearingEnabled: contract.customsClearingEnabled, customsClearingAgent: contract.customsClearingAgent ?? null, @@ -481,7 +481,7 @@ export class ContractBookingService { createdByUserId: user?.id ?? null, scheduledDate: null, serviceTypeId: contract.serviceTypeId, - paymentCurrency: contract.paymentCurrency, + paymentCurrency: this.resolveShipmentCurrency(contract, null), contractType: 'NEW', customsClearingEnabled: contract.customsClearingEnabled, customsClearingAgent: contract.customsClearingAgent ?? null, @@ -520,7 +520,12 @@ export class ContractBookingService { */ async initiateForShipmentRequest( contract: Contract, - opts: { contractRouteId?: string; userId?: string | null }, + opts: { + contractRouteId?: string; + userId?: string | null; + /** Billing currency the customer chose on the shipment request. */ + paymentCurrency?: string | null; + }, ): Promise { const generalCustoms = contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled); @@ -555,7 +560,7 @@ export class ContractBookingService { createdByUserId: opts.userId ?? null, scheduledDate: null, serviceTypeId: contract.serviceTypeId, - paymentCurrency: contract.paymentCurrency, + paymentCurrency: this.resolveShipmentCurrency(contract, opts?.paymentCurrency), contractType: 'NEW', customsClearingEnabled: contract.customsClearingEnabled, customsClearingAgent: contract.customsClearingAgent ?? null, @@ -733,6 +738,13 @@ export class ContractBookingService { cargoFreeText: dto.cargoFreeText?.trim() || null, cargoTotalWeightVgm: this.resolveBulkTons(dto), equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto), + // Completion is where the cargo — and therefore the price — is fixed, so + // it is also where the billing currency is chosen. A bare instance was + // created before the customer had any figure to look at. + paymentCurrency: this.resolveShipmentCurrency( + contract, + dto.paymentCurrency ?? booking.paymentCurrency, + ), } as never); const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id); @@ -921,6 +933,33 @@ export class ContractBookingService { }`, ), ); + + // On a customs contract the customer never books — GL Ethiopia does it for + // them (assertGate enforces that) — so tell them their shipment now exists. + // + // Gated on the contract, NOT on booking.createdByRole: a GENERAL customs + // instance is stamped CUSTOMER when the customer's shipment request opens + // it, yet it is GL who later completes it with cargo and a price. Keying on + // the role would silently skip exactly that case. + // + // Sent from here because this is the single funnel every contract booking + // passes through exactly once (create, complete, and the deferred + // consolidation-pairing replay), and it runs after invoicing so the message + // can quote the priced total. + if (contract.customsClearingEnabled) { + // Never let a notification failure read as a finalize failure — the + // booking is already committed by this point. + try { + const priced = await this.bookingsRepository.findByIdWithFiles(bookingId); + this.bookingNotifier.createdByGlForCustomer(priced ?? booking); + } catch (err) { + this.logger.warn( + `Could not notify the customer that GL created booking ${booking.reference}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } } /** @@ -1565,6 +1604,23 @@ export class ContractBookingService { * booking-level override (dto.equipmentReturn ?? contract default) applies. * Bulk freight keeps the legacy behaviour untouched. */ + /** + * The billing currency for a shipment under this contract. + * + * A contract quotes in USD only — the currency is a per-shipment choice now. + * Precedence: intercity is always ETB (domestic transport is invoiced in + * birr), then the customer's explicit choice, then the contract's own + * currency, which is USD for contracts created under the current rule and the + * grandfathered value for older ones. + */ + private resolveShipmentCurrency( + contract: Contract, + requested?: string | null, + ): string { + if (contract.tradeDirection === 'DOMESTIC') return 'ETB'; + return requested?.trim() || contract.paymentCurrency || 'USD'; + } + private resolveShipmentEquipmentReturn( contract: Contract, dto: CreateBookingUnderContractDto, @@ -1795,7 +1851,7 @@ export class ContractBookingService { contractId: contract.id, freightType: contract.freightType, tradeDirection: contract.tradeDirection, - paymentCurrency: contract.paymentCurrency, + paymentCurrency: this.resolveShipmentCurrency(contract, dto.paymentCurrency), serviceTypeId: contract.serviceTypeId, cargoTypeId: this.resolveCargoTypeId(contract, dto), isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'), diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index d76391f42..0ceea38ed 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -1,4 +1,9 @@ -import { BadRequestException, ConflictException, Injectable } from '@nestjs/common'; +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { ContractDocPhase, type ClearanceFinalInvoiceSummary, @@ -13,7 +18,10 @@ import { FilesService } from '../files/files.service'; import { ContractsRepository } from './contracts.repository'; import { ContractsService, PaginatedContracts } from './contracts.service'; import { BookingsService } from '../bookings/bookings.service'; -import { contractClearanceCodes } from './contract-clearance.util'; +import { + assertDoCollectionDates, + contractClearanceCodes, +} from './contract-clearance.util'; import { ClearanceWorkflowService } from './clearance-workflow.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ContractNotifierService } from './contract-notifier.service'; @@ -72,9 +80,23 @@ export interface ContractClearanceView { blockedReason?: string | null; } | null; dutyRequired?: boolean | null; + /** + * Pre-declaration handshake with GL Djibouti: who will handle the shipment in + * transit. `name` is null until Djibouti answers, and the declaration step is + * shut until it is set. + */ + transitAssignee?: { + requestedAt: string | null; + requestNote: string | null; + name: string | null; + assignedAt: string | null; + } | null; roHold?: boolean; roHoldReason?: string | null; vesselDepartureDate?: string | null; + /** Import DO dates recorded by GL Djibouti on upload. */ + vesselArrivalDate?: string | null; + doCollectedDate?: string | null; roAmendmentRequestedAt?: string | null; bookingReady?: boolean; preClearanceFinalized?: boolean; @@ -84,12 +106,30 @@ export interface ContractClearanceView { /** Reference + status of the GL-created shipment booking, once it exists. */ linkedBookingReference?: string | null; linkedBookingStatus?: string | null; + /** + * Operations' latest "needs changes" note on that booking. GL created the + * booking, so GL is the one who has to act on it — surfaced here because the + * clearance page is where GL works, not the portal. + */ + linkedBookingReviewNote?: string | null; + /** Shipment day the booking currently holds — the default when GL resubmits. */ + linkedBookingScheduledDate?: string | null; dutyAdvice?: { amount: number; currency: string; declarationSerial?: string | null; noticeFile?: { id: string; name: string; url: string } | null; } | null; + /** + * The customer's open objection to the advised duty — present only while GL + * has not re-advised (the advice milestone is back to PENDING). `rounds` is + * how many times it has been sent back, so both sides can see the loop. + */ + dutyDispute?: { + note: string; + raisedAt: string; + rounds: number; + } | null; workflowFiles?: ReturnType; /** Import post-allocation T1 transit document state (null until a booking is linked). */ t1?: ClearanceT1State | null; @@ -239,6 +279,19 @@ export class ContractClearanceService { contract = await this.reconcilePrematureBookingReady(contractId, contract, boundary); const phase = this.workflowService.resolvePhase(contract, cycle, milestones); const dutyAdvice = this.buildDutyAdvice(files, milestones); + const dutyDispute = await this.buildDutyDispute(contractId, milestones); + const transitAssignee = cycle + ? { + requestedAt: cycle.transitAssigneeRequestedAt + ? cycle.transitAssigneeRequestedAt.toISOString() + : null, + requestNote: cycle.transitAssigneeRequestNote ?? null, + name: cycle.transitAssigneeName ?? null, + assignedAt: cycle.transitAssigneeAssignedAt + ? cycle.transitAssigneeAssignedAt.toISOString() + : null, + } + : null; let workflowFiles = buildWorkflowFiles( files, contract.tradeDirection ?? 'IMPORT', @@ -301,11 +354,24 @@ export class ContractClearanceService { // shortly" message. Reuse the export booking load; fetch for import too. let linkedBookingReference: string | null = null; let linkedBookingStatus: string | null = null; + let linkedBookingReviewNote: string | null = null; + let linkedBookingScheduledDate: string | null = null; if (cycle?.bookingId) { const booking = await this.bookingsService.findById(cycle.bookingId); if (booking) { linkedBookingReference = booking.reference ?? null; linkedBookingStatus = booking.status ?? null; + linkedBookingScheduledDate = booking.scheduledDate + ? new Date(booking.scheduledDate).toISOString() + : null; + // Newest changes-requested note (reviewNotes ride along on findById). + linkedBookingReviewNote = + [...(booking.reviewNotes ?? [])] + .filter((n) => n.type === 'CHANGES_REQUESTED') + .sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + )[0]?.note ?? null; if (contract.tradeDirection === 'EXPORT') { nextAction = this.workflowService.computeNextActionForBooking( booking, @@ -340,6 +406,8 @@ export class ContractClearanceService { roHold: Boolean(cycle?.roHoldReason), roHoldReason: cycle?.roHoldReason ?? null, vesselDepartureDate: cycle?.vesselDepartureDate ?? null, + vesselArrivalDate: cycle?.vesselArrivalDate ?? null, + doCollectedDate: cycle?.doCollectedDate ?? null, roAmendmentRequestedAt: cycle?.roAmendmentRequestedAt ? cycle.roAmendmentRequestedAt.toISOString() : null, @@ -349,7 +417,11 @@ export class ContractClearanceService { linkedBookingId: cycle?.bookingId ?? null, linkedBookingReference, linkedBookingStatus, + linkedBookingReviewNote, + linkedBookingScheduledDate, dutyAdvice, + dutyDispute, + transitAssignee, workflowFiles, t1, train, @@ -406,6 +478,32 @@ export class ContractClearanceService { }; } + /** + * The customer's duty objection, but only while it is still OPEN — i.e. the + * advice milestone sits back at PENDING because nobody has re-advised yet. + * Re-advising completes that milestone again, which closes the dispute here + * without any extra state to keep in sync; the notes stay as the audit trail + * and their count is the round number. + */ + private async buildDutyDispute( + contractId: string, + milestones: ClearanceMilestone[], + ): Promise { + const advised = milestones.find((m) => m.milestoneCode === 'DUTY_TAXES_ADVISED'); + if (!advised || advised.status === 'COMPLETED') return null; + const notes = await this.contractsRepository.findReviewNotes( + contractId, + 'DUTY_DISPUTE', + ); + const latest = notes[0]; + if (!latest) return null; + return { + note: latest.body, + raisedAt: latest.createdAt.toISOString(), + rounds: notes.length, + }; + } + /** * True when every REQUIRED customer-input field has an APPROVED review row in * the current cycle. The 100% gate before clearance can be finalized. @@ -631,6 +729,92 @@ export class ContractClearanceService { return this.applyReview(contractId, fileKey, status, staffId, 'OPERATIONS', note); } + /** + * GL corrects a clearance document in place instead of bouncing it back to + * the customer. The customer's upload is NOT lost — it is retired into the + * document's version history, stamped with who replaced it and why — and the + * new version starts unreviewed, so GL still has to approve it (or query it) + * before clearance can be finalized. + * + * Use this for the small fixes staff can make faster than the customer can + * (a wrong page order, a missing stamp scan); a query is still the right tool + * when only the customer can produce the correct document. + */ + async replaceDocument( + contractId: string, + fileKey: string, + file: Express.Multer.File, + staffId: string, + reason?: string, + ): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertClearanceReviewableStatus(contract); + if (!file) throw new BadRequestException('No replacement file uploaded'); + if (!reason?.trim()) { + throw new BadRequestException( + 'Say why the document is being replaced — it is kept on the file history.', + ); + } + + const cycle = await this.contractsRepository.currentCycle(contractId); + if (this.isPhasedCustoms(contract) && cycle?.preClearanceFinalizedAt) { + throw new BadRequestException( + 'Documents cannot be changed after pre-clearance is finalized.', + ); + } + + const existing = await this.filesService.findByCode( + contractId, + 'contracts', + fileKey, + ); + if (!existing) { + throw new NotFoundException( + `No document is stored under "${fileKey}" on this contract.`, + ); + } + + await this.filesService.upsertByCode( + { resourceId: contractId, resource: 'contracts', code: fileKey, file }, + { userId: staffId, reason: reason.trim() }, + ); + + // A fresh version is unreviewed by definition: clear any earlier verdict so + // the corrected file is signed off explicitly rather than inheriting a tick. + const { inputCode, outputCode } = contractClearanceCodes(contract); + const reviews = await this.contractsRepository.findDocumentReviews( + contractId, + cycle?.id ?? null, + ); + const settingCode = + reviews.find((r) => r.fileKey === fileKey)?.settingCode ?? + (fileKey.startsWith('custom_') ? 'custom' : (inputCode ?? outputCode ?? 'custom')); + await this.contractsRepository.setDocumentReviewStatus({ + contractId, + clearanceCycleId: cycle?.id ?? null, + settingCode, + fileKey, + status: 'PENDING', + staffId, + note: `Replaced by staff: ${reason.trim()}`, + }); + + await this.contractsRepository.createReviewNote( + contractId, + `Document "${fileKey}" replaced by staff: ${reason.trim()}`, + 'STAFF_NOTE', + staffId, + 'GL_ET', + ); + + return this.contractsService.findById(contractId); + } + + /** Every stored version of one clearance document, newest first. */ + async documentVersions(contractId: string, fileKey: string) { + return this.filesService.versionHistory(contractId, 'contracts', fileKey); + } + private async applyReview( contractId: string, fileKey: string, @@ -945,6 +1129,68 @@ export class ContractClearanceService { // ── Phased clearance actions (ONE_TIME customs, Phase 1) ─────────────────── /** Sync DOCUMENTS_APPROVED when reviews are done but the milestone row lags. */ + /** + * GL Ethiopia asks Djibouti to name the officer who will handle the shipment + * in transit. Nothing else moves until Djibouti answers — the declaration is + * gated on it — so this is the first thing ET does once the documents are + * approved. Re-requesting is allowed (a nudge) and simply restamps the ask. + */ + async requestTransitAssignee( + contractId: string, + note: string | undefined, + userId?: string, + ): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + const cycle = await this.contractsRepository.currentCycle(contractId); + if (!cycle) throw new BadRequestException('No clearance cycle found'); + + await this.contractsRepository.updateCycle(cycle.id, { + transitAssigneeRequestedAt: new Date(), + transitAssigneeRequestedByUserId: userId ?? null, + transitAssigneeRequestNote: note?.trim() || null, + }); + + const updated = await this.contractsService.findById(contractId); + this.notifier.transitAssigneeRequested(updated, note?.trim() ?? null); + return updated; + } + + /** + * GL Djibouti names the transit officer — free text, because the person is + * not a platform user. Answering unblocks the declaration for Ethiopia. A + * later call overwrites the name (reassignment) and re-notifies. + */ + async assignTransitAssignee( + contractId: string, + assignee: string, + userId?: string, + ): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (!assignee?.trim()) { + throw new BadRequestException('Name the officer who will handle the transit.'); + } + const cycle = await this.contractsRepository.currentCycle(contractId); + if (!cycle) throw new BadRequestException('No clearance cycle found'); + if (!cycle.transitAssigneeRequestedAt) { + throw new BadRequestException( + 'GL Ethiopia has not requested a transit assignee for this clearance yet.', + ); + } + + const previous = cycle.transitAssigneeName ?? null; + await this.contractsRepository.updateCycle(cycle.id, { + transitAssigneeName: assignee.trim(), + transitAssigneeAssignedAt: new Date(), + transitAssigneeAssignedByUserId: userId ?? null, + }); + + const updated = await this.contractsService.findById(contractId); + this.notifier.transitAssigneeAssigned(updated, assignee.trim(), previous); + return updated; + } + private async ensureDeclarationPrerequisites( contractId: string, contract: Contract, @@ -955,6 +1201,16 @@ export class ContractClearanceService { 'All required customer documents must be approved before uploading a declaration.', ); } + // The transit officer must be named by Djibouti first — the declaration is + // filed against whoever will physically handle the shipment there. + const cycle = await this.contractsRepository.currentCycle(contractId); + if (!cycle?.transitAssigneeName) { + throw new BadRequestException( + cycle?.transitAssigneeRequestedAt + ? 'GL Djibouti has not assigned the transit officer yet — the declaration cannot be filed until they do.' + : 'Request a transit assignee from GL Djibouti before filing the customs declaration.', + ); + } const milestones = await this.workflowService.listMilestones(contractId); const docsApproved = milestones.find((m) => m.milestoneCode === 'DOCUMENTS_APPROVED'); if (docsApproved?.status !== 'COMPLETED' && docsApproved?.status !== 'SKIPPED') { @@ -1061,6 +1317,67 @@ export class ContractClearanceService { return this.contractsService.findById(contractId); } + /** + * The customer disagrees with the advised duty & tax and asks GL Ethiopia to + * correct it. Nothing is paid; the advice milestone reopens so the Duty & tax + * step becomes actionable again on the GL clearance page, with the customer's + * message shown beside it. GL re-advises (same endpoint as the first time), + * which closes the dispute — the loop may run as many rounds as it takes. + */ + async disputeDuty( + contractId: string, + note: string, + userId?: string, + ): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (contract.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Duty applies only to import contracts.'); + } + if (!note?.trim()) { + throw new BadRequestException( + 'Say what is wrong with the advised amount so GL can correct it.', + ); + } + + const cycle = await this.contractsRepository.currentCycle(contractId); + if (!cycle?.dutyRequired) { + throw new BadRequestException('Duty/tax is not required for this clearance.'); + } + const milestones = await this.workflowService.listMilestones(contractId); + const byCode = new Map(milestones.map((m) => [m.milestoneCode, m])); + if (byCode.get('DUTY_TAXES_ADVISED')?.status !== 'COMPLETED') { + throw new BadRequestException( + 'There is no advised duty amount to dispute yet.', + ); + } + // Once the slip is in, the money is paid — a dispute then is a refund + // conversation, not a re-advice. + if (byCode.get('DUTY_TAX_PAID')?.status === 'COMPLETED') { + throw new BadRequestException( + 'The duty payment slip has already been submitted — contact GL Ethiopia directly.', + ); + } + + await this.contractsRepository.createReviewNote( + contractId, + note.trim(), + 'DUTY_DISPUTE', + userId, + 'CUSTOMER', + ); + // Back to GL: reopening the milestone is what re-arms the Duty & tax step + // (the stepper picks its active step from milestone completion). + await this.milestoneService.reopenForContract(contractId, 'DUTY_TAXES_ADVISED'); + await this.contractsRepository.updateCycle(cycle.id, { + currentPhase: ContractDocPhase.GlEtOutput, + }); + + const updated = await this.contractsService.findById(contractId); + this.notifier.dutyDisputed(updated, note.trim()); + return updated; + } + async uploadDutySlip( contractId: string, file: Express.Multer.File, @@ -1175,7 +1492,7 @@ export class ContractClearanceService { contractId: string, file: Express.Multer.File, userId?: string, - vesselDepartureDate?: string, + dates?: { vesselArrivalDate?: string; doCollectedDate?: string }, ): Promise { const contract = await this.contractsService.findById(contractId); this.assertPhasedCustoms(contract); @@ -1185,6 +1502,8 @@ export class ContractClearanceService { if (!file) throw new BadRequestException('No Delivery Order uploaded'); + const { vesselArrivalDate, doCollectedDate } = assertDoCollectionDates(dates); + // DO upload is deliberately un-gated: GL Djibouti may attach it at any point, // any file type. The DO_COLLECTED milestone (and booking readiness) still waits // for GL Ethiopia to finalize pre-clearance so the workflow order holds. @@ -1196,9 +1515,10 @@ export class ContractClearanceService { }); const cycle = await this.contractsRepository.currentCycle(contractId); - if (cycle && vesselDepartureDate?.trim()) { + if (cycle) { await this.contractsRepository.updateCycle(cycle.id, { - vesselDepartureDate: vesselDepartureDate.trim(), + vesselArrivalDate, + doCollectedDate, }); } if (cycle?.preClearanceFinalizedAt) { diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts index 2d26f51dc..512ce5288 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts @@ -1,3 +1,5 @@ +import { BadRequestException } from '@nestjs/common'; + import { Contract } from './entities/contract.entity'; import { INTERCITY_DOCUMENTS_SETTING_CODE } from '../bookings/clearance.util'; @@ -84,3 +86,47 @@ export function contractClearanceCodes(contract: Contract): { includesCustoms, }; } + +/** + * Djibouti GL cannot record a Delivery Order without saying WHEN the vessel + * arrived and WHEN the DO was collected — the file alone leaves the import + * timeline unauditable. Shared by the contract and per-booking DO uploads so + * one endpoint can never be laxer than the other. + * + * Returns the normalized `YYYY-MM-DD` pair; throws if either is missing, + * unparseable, or the DO predates the vessel's arrival. + */ +export function assertDoCollectionDates(dates?: { + vesselArrivalDate?: string; + doCollectedDate?: string; +}): { vesselArrivalDate: string; doCollectedDate: string } { + const vesselArrivalDate = normalizeDoDate( + dates?.vesselArrivalDate, + 'Vessel arrival date', + ); + const doCollectedDate = normalizeDoDate( + dates?.doCollectedDate, + 'DO collected date', + ); + + if (doCollectedDate < vesselArrivalDate) { + throw new BadRequestException( + 'DO collected date cannot be earlier than the vessel arrival date.', + ); + } + + return { vesselArrivalDate, doCollectedDate }; +} + +/** `YYYY-MM-DD` or throw — the column is a DATE, so time zones never enter. */ +function normalizeDoDate(value: string | undefined, label: string): string { + const trimmed = value?.trim(); + if (!trimmed) { + throw new BadRequestException(`${label} is required to upload a Delivery Order.`); + } + const date = trimmed.slice(0, 10); + if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || Number.isNaN(Date.parse(date))) { + throw new BadRequestException(`${label} is not a valid date.`); + } + return date; +} diff --git a/apps/edr-freight-api/src/modules/contracts/contract-document-diff.util.ts b/apps/edr-freight-api/src/modules/contracts/contract-document-diff.util.ts index 154777aea..2c2c4e5de 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-document-diff.util.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-document-diff.util.ts @@ -4,8 +4,9 @@ import type { } from './entities/contract.entity'; /** - * One recorded change between two document snapshots. Granularity is per - * article: a body edit is reported as "the body changed", not as a text diff. + * One recorded change between two document snapshots. A body edit carries the + * text on both sides so the audit trail shows WHAT was rewritten, not merely + * that something was — the UI diffs the two strings for display. */ export type ContractDocumentChange = | { kind: 'ARTICLE_ADDED'; articleId: string; title: string } @@ -16,7 +17,14 @@ export type ContractDocumentChange = title: string; fromTitle: string; } - | { kind: 'ARTICLE_BODY_CHANGED'; articleId: string; title: string } + | { + kind: 'ARTICLE_BODY_CHANGED'; + articleId: string; + title: string; + /** Body before / after the edit. Absent on revisions recorded earlier. */ + fromBody?: string; + toBody?: string; + } | { kind: 'ARTICLE_REORDERED'; articleId: string; @@ -25,7 +33,18 @@ export type ContractDocumentChange = toOrder: number; } | { kind: 'DOCUMENT_TITLE_CHANGED'; title: string; fromTitle: string | null } - | { kind: 'WHEREAS_CHANGED'; added: number; removed: number }; + | { kind: 'WHEREAS_CHANGED'; added: number; removed: number } + /** + * A contract field (not a document article) changed — the customer editing a + * DRAFT/CHANGES_REQUESTED contract, e.g. its route, cargo or service type. + */ + | { + kind: 'FIELD_CHANGED'; + field: string; + label: string; + from: string | null; + to: string | null; + }; type SnapshotLike = Pick< ContractDocumentSnapshot, @@ -109,6 +128,8 @@ export function diffSnapshots( kind: 'ARTICLE_BODY_CHANGED', articleId: article.id, title: article.title, + fromBody: previous.body, + toBody: article.body, }); } if (previous.order !== article.order) { @@ -134,6 +155,60 @@ export function diffSnapshots( return changes; } +/** Human label per audited contract field, in the order they read on the form. */ +export const CONTRACT_FIELD_LABELS: Record = { + contractKind: 'Contract kind', + tradeDirection: 'Trade direction', + freightType: 'Freight type', + serviceType: 'Service type', + paymentCurrency: 'Payment currency', + contractType: 'Contract type', + isHazardous: 'Hazardous', + hazardClass: 'Hazard class', + unNumber: 'UN number', + isReefer: 'Reefer', + equipmentReturn: 'Equipment return', + customsClearingAgent: 'Customs clearing agent', + firstMilePickupAddress: 'First-mile pickup address', + lastMileDeliveryAddress: 'Last-mile delivery address', + routes: 'Routes', + cargoScope: 'Cargo scope', +}; + +/** Render a field value for the audit trail — never "[object Object]". */ +function displayValue(value: unknown): string | null { + if (value === null || value === undefined || value === '') return null; + if (typeof value === 'boolean') return value ? 'Yes' : 'No'; + return String(value); +} + +/** + * Compare two flat maps of contract fields and report what changed. Only keys + * present in `after` are considered, so a partial update never reports the + * fields it did not touch. + */ +export function diffContractFields( + before: Record, + after: Record, +): ContractDocumentChange[] { + const changes: ContractDocumentChange[] = []; + + for (const [field, nextRaw] of Object.entries(after)) { + const next = displayValue(nextRaw); + const previous = displayValue(before[field]); + if (next === previous) continue; + changes.push({ + kind: 'FIELD_CHANGED', + field, + label: CONTRACT_FIELD_LABELS[field] ?? field, + from: previous, + to: next, + }); + } + + return changes; +} + /** Short human summary of a change set, e.g. "2 articles edited, 1 article added". */ export function summarizeChanges(changes: ContractDocumentChange[]): string { if (changes.length === 0) return 'No changes'; @@ -148,6 +223,7 @@ export function summarizeChanges(changes: ContractDocumentChange[]): string { const counts = new Map(); const parts: string[] = []; + const fields: string[] = []; for (const change of changes) { const verb = articleVerbs[change.kind]; @@ -157,9 +233,19 @@ export function summarizeChanges(changes: ContractDocumentChange[]): string { parts.push('document title changed'); } else if (change.kind === 'WHEREAS_CHANGED') { parts.push('recitals changed'); + } else if (change.kind === 'FIELD_CHANGED') { + fields.push(change.label.toLowerCase()); } } + if (fields.length > 0) { + parts.push( + fields.length <= 3 + ? `${fields.join(', ')} changed` + : `${fields.length} contract fields changed`, + ); + } + const articleParts = [...counts.entries()].map( ([verb, count]) => `${count} article${count === 1 ? '' : 's'} ${verb}`, ); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts index 2808ea6cf..e38f737c6 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts @@ -1,8 +1,12 @@ import { Injectable, Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; +import { DataSource, Repository } from 'typeorm'; -import { diffSnapshots, summarizeChanges } from './contract-document-diff.util'; +import { + ContractDocumentChange, + diffSnapshots, + summarizeChanges, +} from './contract-document-diff.util'; import { ContractDocumentRevision } from './entities/contract-document-revision.entity'; import type { ContractDocumentSnapshot } from './entities/contract.entity'; @@ -12,6 +16,39 @@ export interface RecordRevisionInput { after: ContractDocumentSnapshot | null; actorId?: string | null; actorRole?: string | null; + actorName?: string | null; + stepId?: string | null; +} + +/** + * `iam.users.name` is a localized object ({ en, am, … }), not a string — a + * plain `String(name)` there yields "[object Object]" in the audit trail. + */ +interface IamUserRow { + name?: Record | string | null; + username?: string | null; + email?: string | null; +} + +/** Best display name for a user row: English label → any locale → login → email. */ +function pickUserName(user: IamUserRow): string | null { + const { name } = user; + if (typeof name === 'string' && name.trim()) return name.trim(); + if (name && typeof name === 'object') { + const localized = + name.en ?? Object.values(name).find((v) => typeof v === 'string' && v.trim()); + if (localized?.trim()) return localized.trim(); + } + return user.username?.trim() || user.email?.trim() || null; +} + +/** Pre-computed changes (contract fields), rather than a document diff. */ +export interface RecordChangesInput { + contractId: string; + changes: ContractDocumentChange[]; + actorId?: string | null; + actorRole?: string | null; + actorName?: string | null; stepId?: string | null; } @@ -22,6 +59,7 @@ export class ContractDocumentHistoryService { constructor( @InjectRepository(ContractDocumentRevision) private readonly revisionRepo: Repository, + @InjectDataSource() private readonly dataSource: DataSource, ) {} /** @@ -30,18 +68,32 @@ export class ContractDocumentHistoryService { * and swallowed. A no-op edit records nothing. */ async record(input: RecordRevisionInput): Promise { + return this.recordChanges({ + ...input, + changes: diffSnapshots(input.before, input.after), + }); + } + + /** + * Append a revision from an already-computed change set — the contract-field + * path, where there is no document snapshot to diff. Same best-effort + * contract as {@link record}: a no-op change set records nothing, and a + * failure here never breaks the edit that triggered it. + */ + async recordChanges(input: RecordChangesInput): Promise { try { - const changes = diffSnapshots(input.before, input.after); - if (changes.length === 0) return; + if (input.changes.length === 0) return; await this.revisionRepo.save( this.revisionRepo.create({ contractId: input.contractId, actorId: input.actorId ?? null, actorRole: input.actorRole ?? null, + actorName: + input.actorName ?? (await this.resolveActorName(input.actorId)), stepId: input.stepId ?? null, - summary: summarizeChanges(changes), - changes, + summary: summarizeChanges(input.changes), + changes: input.changes, }), ); } catch (err) { @@ -51,11 +103,62 @@ export class ContractDocumentHistoryService { } } + /** + * Name for the acting user. `iam.users` is owned by the auth system and has + * no entity here, so it is read directly; a miss is not an error — the trail + * still carries the id, role and timestamp. + */ + private async resolveActorName( + actorId?: string | null, + ): Promise { + if (!actorId) return null; + const names = await this.resolveActorNames([actorId]); + return names.get(actorId) ?? null; + } + + /** Batched {@link resolveActorName} — one query for a whole revision list. */ + private async resolveActorNames( + actorIds: string[], + ): Promise> { + const resolved = new Map(); + const ids = [...new Set(actorIds.filter(Boolean))]; + if (ids.length === 0) return resolved; + + try { + const rows = (await this.dataSource.query( + `SELECT id, name, username, email FROM iam.users WHERE id = ANY($1::uuid[])`, + [ids], + )) as Array; + for (const row of rows) { + const name = pickUserName(row); + if (name) resolved.set(row.id, name); + } + } catch (err) { + this.logger.warn(`Could not resolve actor names: ${String(err)}`); + } + return resolved; + } + /** Revision history for a contract, newest first. */ - list(contractId: string): Promise { - return this.revisionRepo.find({ + async list(contractId: string): Promise { + const revisions = await this.revisionRepo.find({ where: { contractId }, order: { createdAt: 'DESC' }, }); + + // Rows written before actor_name existed still carry an actor_id — resolve + // those for display (one query for the whole list) rather than backfilling. + const missing = revisions + .filter((r) => !r.actorName && r.actorId) + .map((r) => r.actorId as string); + if (missing.length === 0) return revisions; + + const names = await this.resolveActorNames(missing); + for (const revision of revisions) { + if (!revision.actorName && revision.actorId) { + revision.actorName = names.get(revision.actorId) ?? null; + } + } + return revisions; } } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-duty-dispute.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-duty-dispute.spec.ts new file mode 100644 index 000000000..0ba682294 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-duty-dispute.spec.ts @@ -0,0 +1,164 @@ +import { BadRequestException } from '@nestjs/common'; + +import { ContractClearanceService } from './contract-clearance.service'; +import type { Contract } from './entities/contract.entity'; + +/** + * The duty advice → dispute → re-advice loop. GL Ethiopia advises an amount; + * the customer either pays it or sends it back with a reason. Sending it back + * reopens the advice milestone — that is what puts the Duty & tax step back in + * GL's hands — and the round can repeat until the amount is agreed. + */ +describe('ContractClearanceService — duty dispute', () => { + const contract = (over: Partial = {}): Contract => + ({ + id: 'ctr-1', + reference: 'CTR-2026-00042', + tradeDirection: 'IMPORT', + customsClearingEnabled: true, + contractKind: 'ONE_TIME', + ...over, + }) as Contract; + + const milestone = (code: string, status: string) => + ({ milestoneCode: code, status }) as never; + + let repo: { + currentCycle: jest.Mock; + createReviewNote: jest.Mock; + updateCycle: jest.Mock; + findReviewNotes: jest.Mock; + }; + let contractsService: { findById: jest.Mock }; + let workflowService: { listMilestones: jest.Mock }; + let milestoneService: { reopenForContract: jest.Mock }; + let notifier: { dutyDisputed: jest.Mock }; + let service: ContractClearanceService; + + const build = (milestones: unknown[]) => { + workflowService.listMilestones.mockResolvedValue(milestones); + }; + + beforeEach(() => { + repo = { + currentCycle: jest.fn().mockResolvedValue({ id: 'cyc-1', dutyRequired: true }), + createReviewNote: jest.fn().mockResolvedValue(undefined), + updateCycle: jest.fn().mockResolvedValue(undefined), + findReviewNotes: jest.fn().mockResolvedValue([]), + }; + contractsService = { findById: jest.fn().mockResolvedValue(contract()) }; + workflowService = { listMilestones: jest.fn().mockResolvedValue([]) }; + milestoneService = { reopenForContract: jest.fn().mockResolvedValue(undefined) }; + notifier = { dutyDisputed: jest.fn() }; + + service = new ContractClearanceService( + repo as never, + contractsService as never, + {} as never, // bookingsService + {} as never, // filesService + {} as never, // fileUploadSettingsService + workflowService as never, + milestoneService as never, + {} as never, // dropdownSettingsService + {} as never, // glOperationsService + notifier as never, + ); + build([ + milestone('DUTY_TAXES_ADVISED', 'COMPLETED'), + milestone('DUTY_TAX_PAID', 'PENDING'), + ]); + }); + + it('records the objection and hands the step back to GL', async () => { + await service.disputeDuty('ctr-1', ' Declared value is wrong ', 'user-1'); + + expect(repo.createReviewNote).toHaveBeenCalledWith( + 'ctr-1', + 'Declared value is wrong', + 'DUTY_DISPUTE', + 'user-1', + 'CUSTOMER', + ); + // Reopening the advice milestone is what re-arms the Duty & tax step. + expect(milestoneService.reopenForContract).toHaveBeenCalledWith( + 'ctr-1', + 'DUTY_TAXES_ADVISED', + ); + expect(repo.updateCycle).toHaveBeenCalledWith('cyc-1', { + currentPhase: 'GL_ET_OUTPUT', + }); + }); + + it('tells GL Ethiopia, not the customer', async () => { + await service.disputeDuty('ctr-1', 'Too high', 'user-1'); + expect(notifier.dutyDisputed).toHaveBeenCalledWith( + expect.objectContaining({ id: 'ctr-1' }), + 'Too high', + ); + }); + + it('requires a reason — GL cannot correct an unexplained objection', async () => { + await expect(service.disputeDuty('ctr-1', ' ')).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(milestoneService.reopenForContract).not.toHaveBeenCalled(); + }); + + it('refuses when nothing has been advised yet', async () => { + build([milestone('DUTY_TAXES_ADVISED', 'PENDING')]); + await expect(service.disputeDuty('ctr-1', 'Too high')).rejects.toThrow( + /no advised duty amount/i, + ); + }); + + it('refuses once the payment slip is in — that is a refund, not a re-advice', async () => { + build([ + milestone('DUTY_TAXES_ADVISED', 'COMPLETED'), + milestone('DUTY_TAX_PAID', 'COMPLETED'), + ]); + await expect(service.disputeDuty('ctr-1', 'Too high')).rejects.toThrow( + /already been submitted/i, + ); + }); + + it('refuses when duty was never required for this clearance', async () => { + repo.currentCycle.mockResolvedValue({ id: 'cyc-1', dutyRequired: false }); + await expect(service.disputeDuty('ctr-1', 'Too high')).rejects.toThrow( + /not required/i, + ); + }); + + describe('the view', () => { + const buildDispute = (milestones: unknown[]) => + ( + service as unknown as { + buildDutyDispute: (id: string, m: unknown[]) => Promise; + } + ).buildDutyDispute('ctr-1', milestones); + + it('shows the objection while GL still owes a corrected advice', async () => { + repo.findReviewNotes.mockResolvedValue([ + { body: 'Second look please', createdAt: new Date('2026-07-20T09:00:00Z') }, + { body: 'First objection', createdAt: new Date('2026-07-18T09:00:00Z') }, + ]); + + const dispute = await buildDispute([ + milestone('DUTY_TAXES_ADVISED', 'PENDING'), + ]); + + expect(dispute).toMatchObject({ note: 'Second look please', rounds: 2 }); + }); + + it('clears itself once GL re-advises', async () => { + repo.findReviewNotes.mockResolvedValue([ + { body: 'First objection', createdAt: new Date('2026-07-18T09:00:00Z') }, + ]); + + const dispute = await buildDispute([ + milestone('DUTY_TAXES_ADVISED', 'COMPLETED'), + ]); + + expect(dispute).toBeNull(); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.spec.ts new file mode 100644 index 000000000..0551cfc7a --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.spec.ts @@ -0,0 +1,63 @@ +import { ContractExpiryService } from './contract-expiry.service'; +import type { Contract } from './entities/contract.entity'; + +/** + * The reminder must warn each customer once, ten days out, and must never let a + * notification failure escape into the scheduler (that would also take out the + * expiry sweep sharing this service). + */ +describe('ContractExpiryService — expiry reminder', () => { + const contract = (over: Partial = {}): Contract => + ({ + id: 'c-1', + reference: 'CTR-2026-00042', + companyId: 'co-1', + contractValidUntil: new Date('2026-08-10T00:00:00.000Z'), + status: 'CONTRACT_ACTIVE', + ...over, + }) as Contract; + + let repo: { expireLapsedContracts: jest.Mock; findExpiringInDays: jest.Mock }; + let inbox: { notify: jest.Mock }; + let service: ContractExpiryService; + + beforeEach(() => { + repo = { + expireLapsedContracts: jest.fn().mockResolvedValue(0), + findExpiringInDays: jest.fn().mockResolvedValue([]), + }; + inbox = { notify: jest.fn().mockResolvedValue(undefined) }; + service = new ContractExpiryService(repo as never, inbox as never); + }); + + it('asks for the contracts lapsing ten days out', async () => { + await service.remindExpiringContracts(); + expect(repo.findExpiringInDays).toHaveBeenCalledWith(10); + }); + + it('notifies the owning company once, deep-linking the contract list', async () => { + repo.findExpiringInDays.mockResolvedValue([contract()]); + + await service.remindExpiringContracts(); + + expect(inbox.notify).toHaveBeenCalledTimes(1); + const sent = inbox.notify.mock.calls[0][0]; + expect(sent.recipients).toEqual({ companyId: 'co-1' }); + expect(sent.title).toContain('CTR-2026-00042'); + expect(sent.title).toContain('10 days'); + expect(sent.link).toBe('/contracts'); + expect(sent.data).toMatchObject({ contractId: 'c-1', action: 'CONTRACT_EXPIRING' }); + }); + + it('skips a contract with no owning company (nobody to notify)', async () => { + repo.findExpiringInDays.mockResolvedValue([contract({ companyId: null })]); + await service.remindExpiringContracts(); + expect(inbox.notify).not.toHaveBeenCalled(); + }); + + it('swallows a notification failure instead of throwing into the scheduler', async () => { + repo.findExpiringInDays.mockResolvedValue([contract()]); + inbox.notify.mockRejectedValue(new Error('inbox down')); + await expect(service.remindExpiringContracts()).resolves.toBeUndefined(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.ts new file mode 100644 index 000000000..1ef84c141 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.ts @@ -0,0 +1,96 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { NotificationAudience, NotificationType } from '@edr/types'; + +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { ContractsRepository } from './contracts.repository'; + +/** + * How many days before a contract lapses the customer is reminded. Mirrored by + * the portal contract list (EXPIRY_NOTICE_DAYS in contract-ui.tsx), which shows + * the same countdown on the row. + */ +const EXPIRY_NOTICE_DAYS = 10; + +/** Nightly sweep that flips contracts past contractValidUntil to EXPIRED. */ +@Injectable() +export class ContractExpiryService { + private readonly logger = new Logger(ContractExpiryService.name); + + constructor( + private readonly contractsRepository: ContractsRepository, + private readonly inbox: NotificationInboxService, + ) {} + + /** + * Warn every customer whose contract lapses in ~10 days, once. The repository + * window is a rolling 24h slice, so a contract is picked up by exactly one + * daily run — no reminded-flag column needed. + * + * ponytail: a missed run (API down over the slice) skips that contract's + * reminder; the portal list still shows its countdown for the whole window. + */ + @Cron(CronExpression.EVERY_DAY_AT_2AM, { name: 'contract-expiry-reminder' }) + async remindExpiringContracts(): Promise { + try { + const expiring = + await this.contractsRepository.findExpiringInDays(EXPIRY_NOTICE_DAYS); + let notified = 0; + for (const contract of expiring) { + if (!contract.companyId || !contract.contractValidUntil) continue; + const endsOn = contract.contractValidUntil.toLocaleDateString('en-GB'); + await this.inbox.notify({ + recipients: { companyId: contract.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.CONTRACT_STATUS, + title: `Contract ${contract.reference} expires in ${EXPIRY_NOTICE_DAYS} days`, + body: + `Your contract ${contract.reference} is valid until ${endsOn}. ` + + 'After that date it stops accepting new bookings — contact EDR if ' + + 'you need it renewed.', + link: '/contracts', + data: { contractId: contract.id, action: 'CONTRACT_EXPIRING' }, + }); + notified += 1; + } + this.logger.log( + `Contract expiry reminder: ${notified} customer(s) warned of a contract ` + + `lapsing in ${EXPIRY_NOTICE_DAYS} days`, + ); + } catch (err) { + // Never throws into the scheduler — a failed reminder must not stop the + // expiry sweep from running. + this.logger.error( + `Contract expiry reminder failed: ${(err as Error).message}`, + (err as Error).stack, + ); + } + } + + @Cron(CronExpression.EVERY_DAY_AT_1AM, { name: 'contract-expiry-sweep' }) + async expireLapsedContracts(): Promise { + try { + const affected = await this.contractsRepository.expireLapsedContracts(); + this.logger.log(`Contract expiry sweep: ${affected} contract(s) marked EXPIRED`); + } catch (err) { + this.logger.error( + `Contract expiry sweep failed: ${(err as Error).message}`, + (err as Error).stack, + ); + try { + await this.inbox.notify({ + recipients: { allBackoffice: true }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.GENERIC, + title: 'Contract expiry sweep failed', + body: `The nightly job that expires lapsed contracts failed: ${(err as Error).message}. Contracts past their validity date may still show as active until this is fixed.`, + data: { action: 'CONTRACT_EXPIRY_SWEEP_FAILED' }, + }); + } catch (notifyErr) { + this.logger.error( + `Contract expiry sweep failure alert also failed: ${(notifyErr as Error).message}`, + ); + } + } + } +} diff --git a/apps/edr-freight-api/src/modules/contracts/contract-field-diff.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-field-diff.spec.ts new file mode 100644 index 000000000..8726f6c64 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-field-diff.spec.ts @@ -0,0 +1,89 @@ +import { + diffContractFields, + summarizeChanges, +} from './contract-document-diff.util'; + +/** + * The contract-field audit runs on the customer's own edits, so it has to be + * exact: never report a field the edit did not touch, and never render a value + * as "[object Object]" or "true" in the trail a reviewer reads. + */ +describe('diffContractFields', () => { + it('reports only the fields that actually changed', () => { + const changes = diffContractFields( + { freightType: 'BULK', paymentCurrency: 'USD', isReefer: false }, + { freightType: 'CONTAINER', paymentCurrency: 'USD', isReefer: false }, + ); + + expect(changes).toEqual([ + { + kind: 'FIELD_CHANGED', + field: 'freightType', + label: 'Freight type', + from: 'BULK', + to: 'CONTAINER', + }, + ]); + }); + + it('renders booleans as Yes/No, not true/false', () => { + const [change] = diffContractFields({ isHazardous: false }, { isHazardous: true }); + + expect(change).toMatchObject({ label: 'Hazardous', from: 'No', to: 'Yes' }); + }); + + it('treats null, undefined and empty string as "not set"', () => { + expect(diffContractFields({ unNumber: null }, { unNumber: '' })).toEqual([]); + expect(diffContractFields({ unNumber: undefined }, { unNumber: null })).toEqual([]); + + const [set] = diffContractFields({ unNumber: null }, { unNumber: 'UN1234' }); + expect(set).toMatchObject({ from: null, to: 'UN1234' }); + }); + + it('ignores fields absent from the update', () => { + // A partial edit must not report the fields it never sent. + expect(diffContractFields({ freightType: 'BULK', isReefer: true }, {})).toEqual([]); + }); + + it('records a route swap that keeps the same lane count', () => { + const [change] = diffContractFields( + { routes: 'Nagad → Mojo' }, + { routes: 'Nagad → Adama' }, + ); + + expect(change).toMatchObject({ + label: 'Routes', + from: 'Nagad → Mojo', + to: 'Nagad → Adama', + }); + }); + + it('summarises field changes by name, and by count once there are many', () => { + const few = diffContractFields( + { freightType: 'BULK', paymentCurrency: 'USD' }, + { freightType: 'CONTAINER', paymentCurrency: 'ETB' }, + ); + expect(summarizeChanges(few)).toBe('freight type, payment currency changed'); + + const many = diffContractFields( + { a: '1', b: '1', c: '1', d: '1' }, + { a: '2', b: '2', c: '2', d: '2' }, + ); + expect(summarizeChanges(many)).toBe('4 contract fields changed'); + }); + + it('summarises document and field changes together', () => { + const summary = summarizeChanges([ + { kind: 'ARTICLE_BODY_CHANGED', articleId: 'a-1', title: 'Article 1' }, + { + kind: 'FIELD_CHANGED', + field: 'routes', + label: 'Routes', + from: 'A → B', + to: 'A → C', + }, + ]); + + expect(summary).toBe('1 article edited, routes changed'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts index fd81083fc..11681a28f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts @@ -192,6 +192,56 @@ export class ContractNotifierService { }); } + /** + * GL Ethiopia asked Djibouti to name the transit officer. Staff-only, and + * deep-linked to the Djibouti clearance page where the name is entered — the + * customs declaration is blocked until they answer. + */ + transitAssigneeRequested(c: Contract, note: string | null): void { + const msg = + `GL Ethiopia needs a transit assignee for contract ${c.reference} before ` + + `the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`; + this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${c.reference}`); + this.inAppStaff(c, `Transit assignee needed — ${c.reference}`, msg, { + type: NotificationType.CLEARANCE_REVIEW, + link: `/dashboard/gl-djibouti/clearance/${c.id}`, + }); + } + + /** Djibouti named (or changed) the transit officer — Ethiopia can proceed. */ + transitAssigneeAssigned( + c: Contract, + assignee: string, + previous: string | null, + ): void { + const msg = previous + ? `GL Djibouti changed the transit assignee for contract ${c.reference} from ` + + `"${previous}" to "${assignee}".` + : `GL Djibouti assigned ${assignee} to handle contract ${c.reference} in transit. ` + + `The customs declaration can now be filed.`; + this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${c.reference}`); + this.inAppStaff(c, `Transit assignee set — ${c.reference}`, msg, { + type: NotificationType.CLEARANCE_REVIEW, + link: `/dashboard/contracts/clearance/${c.id}`, + }); + } + + /** + * The customer disputed the advised duty & tax. This goes to STAFF, not the + * customer: GL Ethiopia is the one who has to re-advise, and the clearance + * page is where they do it. + */ + dutyDisputed(c: Contract, note: string): void { + const msg = + `The customer disputed the duty & tax advised on contract ${c.reference}: ` + + `"${note}". Review and re-advise the amount on the clearance page.`; + this.logger.log(`DUTY DISPUTED — ${c.reference}`); + this.inAppStaff(c, `Duty disputed on ${c.reference}`, msg, { + type: NotificationType.CLEARANCE_REVIEW, + link: `/dashboard/contracts/clearance/${c.id}`, + }); + } + /** A clearance document was queried — customer must re-upload it. */ clearanceDocumentQueried(c: Contract, fileKey: string, note: string): void { const msg = diff --git a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts index 149643441..a4e4b43b5 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts @@ -35,6 +35,8 @@ function toContractUnit(rateUnit: string): ContractUnitRateLineItem['unit'] { switch (rateUnit) { case 'PER_TON': return 'per_ton'; + case 'PER_ITEM': + return 'per_item'; case 'PER_KM': return 'per_km'; case 'PER_WAGON': @@ -115,9 +117,19 @@ export class ContractPricingService { }); } } else { - const bulkRate = - liveRates.find((r) => r.rateType === baseType && r.currency === 'USD') ?? null; const cargoScope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId); + // Freeze the rate for the contract's own commodity when one is configured + // — a per-item machinery rate and a per-ton wheat rate live side by side. + const bulkRates = liveRates.filter( + (r) => r.rateType === baseType && r.currency === 'USD', + ); + const bulkRate = + (cargoScope?.cargoTypeId + ? bulkRates.find((r) => r.cargoTypeId === cargoScope.cargoTypeId) + : undefined) ?? + bulkRates.find((r) => !r.cargoTypeId) ?? + bulkRates[0] ?? + null; if (bulkRate) { lineItems.push({ code: 'BULK_FREIGHT', diff --git a/apps/edr-freight-api/src/modules/contracts/contract-revision-actor.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-revision-actor.spec.ts new file mode 100644 index 000000000..3f579d32b --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-revision-actor.spec.ts @@ -0,0 +1,115 @@ +import { ContractDocumentHistoryService } from './contract-document-history.service'; + +/** + * `iam.users.name` is a localized jsonb object, not a string. Reading it + * naively puts "[object Object]" in the audit trail — or, worse, throws and + * leaves every revision anonymous. These specs pin the resolution rules. + */ +describe('ContractDocumentHistoryService actor names', () => { + const build = (rows: unknown[]) => { + const saved: Array> = []; + const service = Object.create( + ContractDocumentHistoryService.prototype, + ) as ContractDocumentHistoryService; + Object.assign(service, { + logger: { warn: jest.fn(), error: jest.fn() }, + dataSource: { query: jest.fn().mockResolvedValue(rows) }, + revisionRepo: { + create: (row: Record) => row, + save: jest.fn((row: Record) => { + saved.push(row); + return Promise.resolve(row); + }), + find: jest.fn().mockResolvedValue([]), + }, + }); + return { service, saved }; + }; + + const change = { + kind: 'FIELD_CHANGED' as const, + field: 'routes', + label: 'Routes', + from: 'A → B', + to: 'A → C', + }; + + it('prefers the English label from the localized name object', async () => { + const { service, saved } = build([ + { id: 'u-1', name: { am: 'ሱፐር አድሚን', en: 'Super Admin' }, username: 'superadmin' }, + ]); + + await service.recordChanges({ contractId: 'c-1', changes: [change], actorId: 'u-1' }); + + expect(saved[0].actorName).toBe('Super Admin'); + }); + + it('falls back to another locale, then username, then email', async () => { + const onlyAmharic = build([{ id: 'u-1', name: { am: 'ሱፐር' }, username: 'x' }]); + await onlyAmharic.service.recordChanges({ + contractId: 'c-1', + changes: [change], + actorId: 'u-1', + }); + expect(onlyAmharic.saved[0].actorName).toBe('ሱፐር'); + + const noName = build([{ id: 'u-1', name: null, username: 'operator', email: 'o@edr' }]); + await noName.service.recordChanges({ + contractId: 'c-1', + changes: [change], + actorId: 'u-1', + }); + expect(noName.saved[0].actorName).toBe('operator'); + + const emailOnly = build([{ id: 'u-1', name: {}, username: null, email: 'o@edr.local' }]); + await emailOnly.service.recordChanges({ + contractId: 'c-1', + changes: [change], + actorId: 'u-1', + }); + expect(emailOnly.saved[0].actorName).toBe('o@edr.local'); + }); + + it('never writes "[object Object]" as the actor name', async () => { + const { service, saved } = build([{ id: 'u-1', name: { en: 'Real Name' } }]); + + await service.recordChanges({ contractId: 'c-1', changes: [change], actorId: 'u-1' }); + + expect(String(saved[0].actorName)).not.toContain('object Object'); + }); + + it('records nothing when the change set is empty', async () => { + const { service, saved } = build([]); + + await service.recordChanges({ contractId: 'c-1', changes: [], actorId: 'u-1' }); + + expect(saved).toHaveLength(0); + }); + + it('still records the revision when the user lookup fails', async () => { + const { service, saved } = build([]); + Object.assign(service, { + dataSource: { query: jest.fn().mockRejectedValue(new Error('iam down')) }, + }); + + await service.recordChanges({ contractId: 'c-1', changes: [change], actorId: 'u-1' }); + + expect(saved).toHaveLength(1); + expect(saved[0].actorName).toBeNull(); + }); + + it('resolves names for legacy rows that predate the actor_name column', async () => { + const { service } = build([{ id: 'u-1', name: { en: 'Abenezer Haile' } }]); + Object.assign(service, { + revisionRepo: { + find: jest + .fn() + .mockResolvedValue([{ id: 'r-1', actorId: 'u-1', actorName: null }]), + }, + }); + + const [revision] = await service.list('c-1'); + + expect(revision.actorName).toBe('Abenezer Haile'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-signature-asset.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-signature-asset.spec.ts new file mode 100644 index 000000000..e372f0cb2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-signature-asset.spec.ts @@ -0,0 +1,81 @@ +import { Readable } from 'stream'; + +import { ContractTransitionService } from './contract-transition.service'; + +/** + * A stamp may be uploaded as JPEG/WebP while a drawn signature is always PNG. + * The type must survive the round-trip: data URL in → stored object extension + * → data URL out. Getting this wrong labels JPEG bytes as image/png in the + * contract PDF and leaves the seal to browser content-sniffing. + */ +describe('ContractTransitionService signature/stamp asset typing', () => { + const pngPixel = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg=='; + const jpegPixel = `data:image/jpeg;base64,${Buffer.from('fake-jpeg').toString('base64')}`; + + /** Minimal service instance — only filesService/minioService are exercised. */ + const build = () => { + const uploaded: Array<{ code: string; mimetype: string; name: string }> = []; + const filesService = { + upsertByCode: jest.fn(({ code, file }) => { + uploaded.push({ code, mimetype: file.mimetype, name: file.originalname }); + return Promise.resolve({ id: `file-${code}`, url: `https://minio/x/${file.originalname}` }); + }), + }; + const minioService = { + getObjectNameFromUrl: (url: string) => url.split('/').pop() ?? '', + getFileStream: () => Promise.resolve(Readable.from(Buffer.from('bytes'))), + }; + const service = Object.create( + ContractTransitionService.prototype, + ) as ContractTransitionService; + Object.assign(service, { filesService, minioService }); + return { service, uploaded }; + }; + + const contract = { id: 'c-1', reference: 'CTR-2026-00001' }; + + it('stores a drawn PNG signature as image/png', async () => { + const { service, uploaded } = build(); + await (service as never as { + uploadSignatureAsset: (c: unknown, code: string, b64: string) => Promise; + }).uploadSignatureAsset(contract, 'signature_customer', pngPixel); + + expect(uploaded[0].mimetype).toBe('image/png'); + expect(uploaded[0].name).toBe('signature-customer-CTR-2026-00001.png'); + }); + + it('keeps an uploaded JPEG stamp as image/jpeg, not image/png', async () => { + const { service, uploaded } = build(); + await (service as never as { + uploadSignatureAsset: (c: unknown, code: string, b64: string) => Promise; + }).uploadSignatureAsset(contract, 'stamp_customer', jpegPixel); + + expect(uploaded[0].mimetype).toBe('image/jpeg'); + expect(uploaded[0].name).toBe('stamp-customer-CTR-2026-00001.jpg'); + }); + + it('inlines a stored .jpg back as a data:image/jpeg URI', async () => { + const { service } = build(); + const inline = (service as never as { + inlineImageUrl: (url?: string | null) => Promise; + }).inlineImageUrl.bind(service); + + await expect(inline('https://minio/x/stamp-customer-CTR.jpg')).resolves.toMatch( + /^data:image\/jpeg;base64,/, + ); + await expect(inline('https://minio/x/signature-customer-CTR.png')).resolves.toMatch( + /^data:image\/png;base64,/, + ); + }); + + it('passes through empty and already-inlined values untouched', async () => { + const { service } = build(); + const inline = (service as never as { + inlineImageUrl: (url?: string | null) => Promise; + }).inlineImageUrl.bind(service); + + await expect(inline(null)).resolves.toBeNull(); + await expect(inline(pngPixel)).resolves.toBe(pngPixel); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-stamp-resign.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-stamp-resign.spec.ts new file mode 100644 index 000000000..2c8bc8fa7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-stamp-resign.spec.ts @@ -0,0 +1,131 @@ +import { BadRequestException, ConflictException } from '@nestjs/common'; + +import { ContractTransitionService } from './contract-transition.service'; + +/** + * Signing is one-shot. The single exception: a contract signed before company + * stamps were required must be re-signable so the customer can attach one — + * otherwise counterSign's both-stamps gate strands it forever. These specs pin + * that exception open and pin everything else shut. + */ +describe('customer re-sign to attach a missing stamp', () => { + const contractReady = { id: 'c-1', reference: 'CTR-1', status: 'CONTRACT_READY' }; + const signedNoStamp = { id: 'c-1', reference: 'CTR-1', status: 'SIGNED_CUSTOMER' }; + + const build = (contract: unknown, existingSignature: unknown) => { + const applied: unknown[] = []; + const service = Object.create( + ContractTransitionService.prototype, + ) as ContractTransitionService; + Object.assign(service, { + contractsService: { + findById: jest.fn().mockResolvedValue(contract), + assertCustomerCanAccessContract: jest.fn().mockResolvedValue(undefined), + }, + contractsRepository: { + findSignature: jest.fn().mockResolvedValue(existingSignature), + update: jest.fn().mockResolvedValue(undefined), + }, + otpService: { + verifyOtpForAction: jest.fn().mockResolvedValue(undefined), + sendOtp: jest.fn().mockResolvedValue(undefined), + }, + notifier: { customerSignedToStaff: jest.fn() }, + resolveSignerContacts: jest.fn().mockResolvedValue({ phone: '+251900000000' }), + applySignature: jest.fn((...args: unknown[]) => { + applied.push(args); + return Promise.resolve(); + }), + regenerateContractPdf: jest.fn().mockResolvedValue(undefined), + }); + return { service, applied }; + }; + + const dto = { + role: 'CUSTOMER' as const, + signerDisplayName: 'C. Customer', + signatureImageBase64: 'data:image/png;base64,AAAA', + stampImageBase64: 'data:image/png;base64,BBBB', + otp: '123456', + }; + + it('lets a customer sign again when their signature has no stamp', async () => { + const { service, applied } = build(signedNoStamp, { + id: 's-1', + role: 'CUSTOMER', + stampFileId: null, + }); + + await expect(service.sign('c-1', dto, { signerUserId: 'u-1' })).resolves.toBeDefined(); + expect(applied).toHaveLength(1); + }); + + it('still refuses a second signature once a stamp is on file', async () => { + const { service } = build(signedNoStamp, { + id: 's-1', + role: 'CUSTOMER', + stampFileId: 'file-1', + }); + + // Stamped already → not the re-sign case, so the status guard rejects + // SIGNED_CUSTOMER before the already-signed check is reached. + await expect(service.sign('c-1', dto, { signerUserId: 'u-1' })).rejects.toBeInstanceOf( + ConflictException, + ); + }); + + it('refuses a second signature on a still-ready contract', async () => { + const { service } = build(contractReady, { + id: 's-1', + role: 'CUSTOMER', + stampFileId: 'file-1', + }); + + await expect(service.sign('c-1', dto, { signerUserId: 'u-1' })).rejects.toThrow( + /already signed/i, + ); + }); + + it('signs normally when nothing is on file yet', async () => { + const { service, applied } = build(contractReady, null); + + await expect(service.sign('c-1', dto, { signerUserId: 'u-1' })).resolves.toBeDefined(); + expect(applied).toHaveLength(1); + }); + + it('sends a signing OTP for the stamp re-sign', async () => { + const { service } = build(signedNoStamp, { + id: 's-1', + role: 'CUSTOMER', + stampFileId: null, + }); + + await expect( + service.sendSigningOtp('c-1', { signerUserId: 'u-1' }), + ).resolves.toEqual(expect.objectContaining({ sentTo: expect.any(String) })); + }); + + it('refuses a signing OTP once the contract is signed and stamped', async () => { + const { service } = build(signedNoStamp, { + id: 's-1', + role: 'CUSTOMER', + stampFileId: 'file-1', + }); + + await expect( + service.sendSigningOtp('c-1', { signerUserId: 'u-1' }), + ).rejects.toBeInstanceOf(ConflictException); + }); + + it('requires the OTP on the re-sign path too', async () => { + const { service } = build(signedNoStamp, { + id: 's-1', + role: 'CUSTOMER', + stampFileId: null, + }); + + await expect( + service.sign('c-1', { ...dto, otp: undefined }, { signerUserId: 'u-1' }), + ).rejects.toBeInstanceOf(BadRequestException); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index 54158c383..b579acdfb 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -22,6 +22,7 @@ import { assertCanApproveContractStep, assertFreightPermission, canEditContractStep, + HAZARDOUS_APPROVAL_ROLES, } from '../../common/freight-permission.util'; import { FREIGHT_PERMS, @@ -243,6 +244,7 @@ export class ContractTransitionService { validityDays: number, documentSnapshot?: ContractDocumentSnapshotInput | null, user?: TCurrentUser | null, + window?: { validFrom?: string | null; validUntil?: string | null }, ): Promise { const contract = await this.contractsService.findById(contractId); // The route guard passes on either arm; the contract's freight type decides @@ -259,11 +261,20 @@ export class ContractTransitionService { ); } - await this.assertValidityDaysConfigured(validityDays); + // Staff picked an explicit window in the accept dialog — honour it verbatim + // (any start, any end). Only the legacy days-only payload is still held to + // the admin-configured period list. + const picked = window?.validFrom && window?.validUntil; + if (!picked) await this.assertValidityDaysConfigured(validityDays); - const validFrom = new Date(); - const validUntil = new Date(validFrom); - validUntil.setDate(validUntil.getDate() + validityDays); + const validFrom = picked ? new Date(window!.validFrom!) : new Date(); + const validUntil = picked ? new Date(window!.validUntil!) : new Date(validFrom); + if (!picked) validUntil.setDate(validUntil.getDate() + validityDays); + if (validUntil.getTime() <= validFrom.getTime()) { + throw new BadRequestException( + 'The contract validity end date must be after the start date.', + ); + } await this.instantiateApprovalSteps(contract); @@ -273,6 +284,20 @@ export class ContractTransitionService { // shared six templates are never written here. const snapshot = await this.resolveDocumentSnapshot(contract, documentSnapshot); + // Audit whatever staff changed in the accept dialog. The baseline is the + // template this contract would otherwise have frozen as-is, so an untouched + // accept diffs to nothing and records no revision. + if (documentSnapshot) { + const baseline = await this.resolveDocumentSnapshot(contract); + await this.documentHistory.record({ + contractId, + before: baseline, + after: snapshot, + actorId, + actorRole: 'Reviewing staff', + }); + } + await this.contractsRepository.update(contractId, { status: 'PENDING_APPROVAL', approvedByStaffId: actorId, @@ -530,12 +555,26 @@ export class ContractTransitionService { ); } - for (const rule of chain) { - await this.contractsRepository.createApprovalStep({ - contractId: contract.id, - stepOrder: rule.stepOrder, + // Dangerous goods clear two dedicated hazardous desks BEFORE the commercial + // chain — if either refuses, the contract never reaches the approvers who + // would price and sign it. Steps are renumbered sequentially so the prefix + // and the configured chain form one ordered list. + const roles: Array<{ requiredRole: string; blocksRole: string | null }> = [ + ...(contract.isHazardous ? [...HAZARDOUS_APPROVAL_ROLES] : []).map( + (requiredRole) => ({ requiredRole, blocksRole: null }), + ), + ...chain.map((rule) => ({ requiredRole: rule.requiredRole, blocksRole: rule.blocksRole ?? null, + })), + ]; + + for (const [index, role] of roles.entries()) { + await this.contractsRepository.createApprovalStep({ + contractId: contract.id, + stepOrder: index + 1, + requiredRole: role.requiredRole, + blocksRole: role.blocksRole, status: 'PENDING', }); } @@ -928,23 +967,41 @@ export class ContractTransitionService { }); } - /** Replace MinIO signature URLs with inline data URIs so they render in the PDF. */ + /** + * Replace MinIO signature/stamp URLs with inline data URIs so they render in + * the PDF — Chromium cannot fetch the private bucket. + */ private async inlineSignatureImages( - signatures: Array<{ signatureImageUrl?: string | null }>, + signatures: Array<{ + signatureImageUrl?: string | null; + stampImageUrl?: string | null; + }>, ): Promise { for (const sig of signatures) { - if (!sig.signatureImageUrl) continue; - try { - if (sig.signatureImageUrl.startsWith('data:')) continue; - const objectName = this.minioService.getObjectNameFromUrl( - sig.signatureImageUrl, - ); - const stream = await this.minioService.getFileStream(objectName); - const buffer = await this.streamToBuffer(stream); - sig.signatureImageUrl = `data:image/png;base64,${buffer.toString('base64')}`; - } catch { - /* keep original url */ - } + sig.signatureImageUrl = await this.inlineImageUrl(sig.signatureImageUrl); + sig.stampImageUrl = await this.inlineImageUrl(sig.stampImageUrl); + } + } + + /** MinIO URL → data URI. Returns the input unchanged if absent or on failure. */ + private async inlineImageUrl( + url?: string | null, + ): Promise { + if (!url || url.startsWith('data:')) return url; + try { + const objectName = this.minioService.getObjectNameFromUrl(url); + const stream = await this.minioService.getFileStream(objectName); + const buffer = await this.streamToBuffer(stream); + const extension = objectName.split('.').pop()?.toLowerCase(); + const mime = + extension === 'jpg' || extension === 'jpeg' + ? 'image/jpeg' + : extension === 'webp' + ? 'image/webp' + : 'image/png'; + return `data:${mime};base64,${buffer.toString('base64')}`; + } catch { + return url; } } @@ -959,6 +1016,46 @@ export class ContractTransitionService { }); } + /** + * base64 (data URL or raw) → image FileRecord stored on the contract under + * `code`. Drawn signatures are always PNG; an uploaded stamp may be JPEG or + * WebP, so the type is read off the data-URL prefix rather than assumed — + * the stored extension is what {@link inlineImageUrl} reads it back as. + */ + private async uploadSignatureAsset( + contract: Contract, + code: string, + imageBase64: string, + ): Promise { + const mimetype = + /^data:(image\/[a-z+]+);base64,/i.exec(imageBase64)?.[1]?.toLowerCase() ?? + 'image/png'; + const extension = mimetype === 'image/jpeg' ? 'jpg' : mimetype.split('/')[1]; + const raw = imageBase64.includes(',') + ? imageBase64.split(',')[1]! + : imageBase64; + const buffer = Buffer.from(raw, 'base64'); + const file: Express.Multer.File = { + fieldname: code, + originalname: `${code.replace(/_/g, '-')}-${contract.reference}.${extension}`, + encoding: '7bit', + mimetype, + size: buffer.length, + buffer, + stream: Readable.from(buffer), + destination: '', + filename: '', + path: '', + }; + + return this.filesService.upsertByCode({ + resourceId: contract.id, + resource: 'contracts', + code, + file, + }); + } + /** Apply a digital signature row (mirrors booking-contract.service). */ private async applySignature( contract: Contract, @@ -985,29 +1082,28 @@ export class ContractTransitionService { ); } - const raw = imageBase64.includes(',') - ? imageBase64.split(',')[1]! - : imageBase64; - const buffer = Buffer.from(raw, 'base64'); - const sigFile: Express.Multer.File = { - fieldname: `signature_${role.toLowerCase()}`, - originalname: `signature-${role.toLowerCase()}-${contract.reference}.png`, - encoding: '7bit', - mimetype: 'image/png', - size: buffer.length, - buffer, - stream: Readable.from(buffer), - destination: '', - filename: '', - path: '', - }; + // The company stamp is a separate image from the drawn signature. Both + // parties to the contract (client + EDR) must seal it; DIRECTOR/CEO rows + // are internal approval signatures, not party seals, so they stay exempt. + const stampRequired = role === 'CUSTOMER' || role === 'STAFF'; + if (stampRequired && !dto.stampImageBase64) { + throw new BadRequestException( + 'A company stamp is required to sign this contract.', + ); + } - const fileRecord = await this.filesService.upsertByCode({ - resourceId: contract.id, - resource: 'contracts', - code: `signature_${role.toLowerCase()}`, - file: sigFile, - }); + const fileRecord = await this.uploadSignatureAsset( + contract, + `signature_${role.toLowerCase()}`, + imageBase64, + ); + const stampRecord = dto.stampImageBase64 + ? await this.uploadSignatureAsset( + contract, + `stamp_${role.toLowerCase()}`, + dto.stampImageBase64, + ) + : null; await this.contractsRepository.saveSignature({ contractId: contract.id, @@ -1015,6 +1111,7 @@ export class ContractTransitionService { signerDisplayName, signedAt: new Date(), signatureFileId: fileRecord.id, + stampFileId: stampRecord?.id ?? null, consentText: dto.consentText ?? null, }); @@ -1053,7 +1150,17 @@ export class ContractTransitionService { options.signerUserId, contract, ); - assertContractStatus(contract, ['CONTRACT_READY']); + // SIGNED_CUSTOMER is allowed only for the re-sign-to-add-a-stamp case that + // {@link sign} permits — otherwise the code would be useless on arrival. + const existing = await this.contractsRepository.findSignature( + contractId, + 'CUSTOMER', + ); + const addingMissingStamp = Boolean(existing) && !existing?.stampFileId; + assertContractStatus( + contract, + addingMissingStamp ? ['CONTRACT_READY', 'SIGNED_CUSTOMER'] : ['CONTRACT_READY'], + ); const signerContacts = await this.resolveSignerContacts(options.signerUserId); await this.otpService.sendOtp(signerContacts); @@ -1077,9 +1184,16 @@ export class ContractTransitionService { options.signerUserId, contract, ); - assertContractStatus(contract, ['CONTRACT_READY']); const existing = await this.contractsRepository.findSignature(contractId, 'CUSTOMER'); - if (existing) { + // Signing is one-shot, with one exception: a contract signed before the + // company stamp was required has to be sealed before EDR can counter-sign + // it, so the customer may sign again purely to attach the missing stamp. + const addingMissingStamp = Boolean(existing) && !existing?.stampFileId; + assertContractStatus( + contract, + addingMissingStamp ? ['CONTRACT_READY', 'SIGNED_CUSTOMER'] : ['CONTRACT_READY'], + ); + if (existing && !addingMissingStamp) { throw new BadRequestException('Customer has already signed this contract'); } // Sudo-mode gate: a fresh, single-use OTP must be verified before the @@ -1127,6 +1241,19 @@ export class ContractTransitionService { const contract = await this.contractsService.findById(contractId); assertContractStatus(contract, ['SIGNED_CUSTOMER']); + // Both parties' stamps must be on file before the contract executes. The + // EDR stamp is enforced by applySignature below; the customer's is checked + // here so a contract signed before stamps existed can't slip through. + const customerSignature = await this.contractsRepository.findSignature( + contractId, + 'CUSTOMER', + ); + if (!customerSignature?.stampFileId) { + throw new BadRequestException( + 'The customer stamp is missing on this contract — it cannot be counter-signed until the customer signs again with their company stamp.', + ); + } + await this.applySignature(contract, dto, options); const now = new Date(); diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index a015cd469..74574ed28 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -303,8 +303,10 @@ export class ContractsController { @Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContractDto, @UploadedFiles() files: Express.Multer.File[], + // Recorded on the edit's audit revision — who changed the contract. + @CurrentUser() user?: TCurrentUser, ) { - return this.contractsService.update(id, dto, files ?? []); + return this.contractsService.update(id, dto, files ?? [], user?.id); } @Delete(':id') @@ -358,6 +360,7 @@ export class ContractsController { dto.validityDays, dto.documentSnapshot, user, + { validFrom: dto.validFrom, validUntil: dto.validUntil }, ); } @@ -738,6 +741,92 @@ export class ContractsController { return this.clearanceService.finalizePreClearance(id); } + @Post(':id/clearance/transit-assignee/request') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @ApiOperation({ + summary: + 'GL ET asks GL Djibouti to name the transit officer — required before the customs declaration', + }) + requestTransitAssignee( + @Param('id', ParseUUIDPipe) id: string, + @Body('note') note: string | undefined, + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.requestTransitAssignee( + id, + note, + resolveAuthUserId(user), + ); + } + + @Post(':id/clearance/transit-assignee/assign') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @ApiOperation({ + summary: + 'GL Djibouti names the transit officer (free text) — unblocks the customs declaration; calling again reassigns', + }) + assignTransitAssignee( + @Param('id', ParseUUIDPipe) id: string, + @Body('assignee') assignee: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.assignTransitAssignee( + id, + assignee, + resolveAuthUserId(user), + ); + } + + @Get(':id/clearance/documents/:fileKey/versions') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceReview) + @ApiOperation({ + summary: + 'Version history of one clearance document — the customer original plus every staff replacement', + }) + documentVersions( + @Param('id', ParseUUIDPipe) id: string, + @Param('fileKey') fileKey: string, + ) { + return this.clearanceService.documentVersions(id, fileKey); + } + + @Post(':id/clearance/documents/:fileKey/replace') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceReview) + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ + summary: + 'GL replaces a clearance document in place (reason required) — the previous version is kept in the file history and the new one needs approving', + }) + replaceClearanceDocument( + @Param('id', ParseUUIDPipe) id: string, + @Param('fileKey') fileKey: string, + @UploadedFile() file: Express.Multer.File, + @Body('reason') reason: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.replaceDocument( + id, + fileKey, + file, + resolveAuthUserId(user), + reason, + ); + } + + @Post(':id/clearance/duty/dispute') + @ApiOperation({ + summary: + 'Customer disputes the advised duty/tax with a reason — reopens the step so GL Ethiopia can re-advise (repeatable)', + }) + disputeContractDuty( + @Param('id', ParseUUIDPipe) id: string, + @Body('note') note: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.disputeDuty(id, note, resolveAuthUserId(user)); + } + @Post(':id/clearance/duty-slip') @UseInterceptors(FileInterceptor('file')) @ApiConsumes('multipart/form-data') @@ -766,19 +855,21 @@ export class ContractsController { @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) @UseInterceptors(FileInterceptor('file')) @ApiConsumes('multipart/form-data') - @ApiOperation({ summary: 'GL DJ uploads Delivery Order (import)' }) + @ApiOperation({ + summary: + 'GL DJ uploads Delivery Order (import) with vessel arrival + DO collected dates', + }) uploadDeliveryOrder( @Param('id', ParseUUIDPipe) id: string, @UploadedFile() file: Express.Multer.File, - @Body('vesselDepartureDate') vesselDepartureDate: string | undefined, + @Body('vesselArrivalDate') vesselArrivalDate: string | undefined, + @Body('doCollectedDate') doCollectedDate: string | undefined, @CurrentUser() user: AuthUserPayload, ) { - return this.clearanceService.uploadDeliveryOrder( - id, - file, - resolveAuthUserId(user), - vesselDepartureDate, - ); + return this.clearanceService.uploadDeliveryOrder(id, file, resolveAuthUserId(user), { + vesselArrivalDate, + doCollectedDate, + }); } @Post(':id/clearance/release-order') diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts index bb12648a4..679786c13 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -21,6 +21,7 @@ import { ContractTemplatesModule } from '../contract-templates/contract-template import { ContractsController } from './contracts.controller'; import { ContractsService } from './contracts.service'; import { ContractsRepository } from './contracts.repository'; +import { ContractExpiryService } from './contract-expiry.service'; import { ContractPricingService } from './contract-pricing.service'; import { ContractNotifierService } from './contract-notifier.service'; import { ContractTransitionService } from './contract-transition.service'; @@ -105,6 +106,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum providers: [ ContractsService, ContractsRepository, + ContractExpiryService, ContractPricingService, ContractNotifierService, ContractTransitionService, diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index 67a3bd101..b9c22d1af 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -14,6 +14,7 @@ import { import { ContractRateSnapshot } from './entities/contract-rate-snapshot.entity'; import { ContractReviewNote, ContractReviewNoteType } from './entities/contract-review-note.entity'; import { ContractSignature, ContractSignerRole } from './entities/contract-signature.entity'; +import { TERMINAL_CONTRACT_STATUSES } from './utils/contract-expiry.util'; export interface ContractListFilterOptions { statuses?: string[]; @@ -66,6 +67,81 @@ export class ContractsRepository extends BaseRepository { return Number(row?.max ?? 0); } + /** + * Non-terminal contracts for the same company + service type, with routes + * loaded — candidates for the duplicate-contract check on create(). Terminal + * filtering happens in JS via isEffectivelyExpired (also covers the + * date-passed-but-not-yet-cron-flipped case). + */ + async findDuplicateCandidates( + companyId: string, + serviceTypeId: string, + ): Promise { + return this.repository + .createQueryBuilder('contract') + .leftJoinAndSelect('contract.routes', 'routes') + .where('contract.deleted_at IS NULL') + .andWhere('contract.company_id = :companyId', { companyId }) + .andWhere('contract.service_type_id = :serviceTypeId', { serviceTypeId }) + .andWhere('contract.status NOT IN (:...terminal)', { + terminal: TERMINAL_CONTRACT_STATUSES, + }) + // A ONE_TIME contract allows a single booking, so once that booking + // exists the contract is spent and can never carry another shipment. + // Without this it kept blocking new requests on the same service type + + // route until its validity lapsed — locking a customer out of a lane for + // the rest of the term after one completed shipment. + .andWhere( + `(contract.contract_kind <> 'ONE_TIME' OR NOT EXISTS ( + SELECT 1 FROM freight.bookings b + WHERE b.contract_id = contract.id AND b.deleted_at IS NULL + ))`, + ) + .getMany(); + } + + /** + * Nightly expiry sweep: flips lapsed contracts to EXPIRED. Returns the + * number of rows updated (for cron logging). + */ + async expireLapsedContracts(): Promise { + const result = await this.repository + .createQueryBuilder() + .update(Contract) + .set({ status: 'EXPIRED' }) + .where('deleted_at IS NULL') + .andWhere('status NOT IN (:...terminal)', { terminal: TERMINAL_CONTRACT_STATUSES }) + .andWhere('contract_valid_until IS NOT NULL AND contract_valid_until < :now', { + now: new Date(), + }) + .execute(); + return result.affected ?? 0; + } + + /** + * Live contracts whose validity ends between `days` and `days + 1` days from + * now — the slice the daily expiry-reminder cron warns about. The window is + * rolling and exactly 24h wide, so consecutive daily runs tile it without + * gaps or overlaps: each contract is picked up by exactly one run and the + * customer is notified once, with no "already reminded" flag to store. + */ + async findExpiringInDays(days: number): Promise { + const now = Date.now(); + return this.repository + .createQueryBuilder('contract') + .where('contract.deleted_at IS NULL') + .andWhere('contract.status NOT IN (:...terminal)', { + terminal: TERMINAL_CONTRACT_STATUSES, + }) + .andWhere('contract.contract_valid_until >= :from', { + from: new Date(now + days * 86_400_000), + }) + .andWhere('contract.contract_valid_until < :to', { + to: new Date(now + (days + 1) * 86_400_000), + }) + .getMany(); + } + /** Find a contract by ID with all child collections, service type, company and files. */ async findByIdWithRelations(id: string): Promise { if (!id) return null; @@ -88,7 +164,9 @@ export class ContractsRepository extends BaseRepository { 'contract.files', FileRecord, 'file', - "file.resource_id = contract.id AND file.resource = 'contracts'", + // Superseded versions are soft-deleted, not dropped — keep them out of + // the live file list (a manual join condition is not filtered for us). + "file.resource_id = contract.id AND file.resource = 'contracts' AND file.deleted_at IS NULL", ) .getOne(); @@ -434,7 +512,7 @@ export class ContractsRepository extends BaseRepository { findSignatures(contractId: string): Promise { return this.dataSource.getRepository(ContractSignature).find({ where: { contractId }, - relations: ['signatureFile'], + relations: ['signatureFile', 'stampFile'], order: { signedAt: 'ASC' }, }); } @@ -445,7 +523,7 @@ export class ContractsRepository extends BaseRepository { ): Promise { return this.dataSource.getRepository(ContractSignature).findOne({ where: { contractId, role }, - relations: ['signatureFile'], + relations: ['signatureFile', 'stampFile'], }); } @@ -482,6 +560,17 @@ export class ContractsRepository extends BaseRepository { ); } + /** Review notes of one type, newest first — the duty advice/dispute rounds. */ + async findReviewNotes( + contractId: string, + noteType: ContractReviewNoteType, + ): Promise { + return this.dataSource.getRepository(ContractReviewNote).find({ + where: { contractId, noteType }, + order: { createdAt: 'DESC' }, + }); + } + async findLatestReviewNote( contractId: string, noteType?: ContractReviewNoteType, @@ -649,12 +738,20 @@ export class ContractsRepository extends BaseRepository { ContractClearanceCycle, | 'dutyRequired' | 'vesselDepartureDate' + | 'vesselArrivalDate' + | 'doCollectedDate' | 'roAmendmentRequestedAt' | 'roHoldReason' | 'currentPhase' | 'status' | 'preClearanceFinalizedAt' | 'completedAt' + | 'transitAssigneeRequestedAt' + | 'transitAssigneeRequestedByUserId' + | 'transitAssigneeRequestNote' + | 'transitAssigneeName' + | 'transitAssigneeAssignedAt' + | 'transitAssigneeAssignedByUserId' > >, ): Promise { diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index 65f0637f0..43aab750f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, + ConflictException, ForbiddenException, Injectable, NotFoundException, @@ -24,6 +25,9 @@ import { ContractListSummaryDto } from './dto/contract-list-summary.dto'; import { Contract, CONTRACT_STATUSES, CONTRACT_CUSTOMER_EDITABLE_STATUSES } from './entities/contract.entity'; import { ContractRoute } from './entities/contract-route.entity'; import { ContractCargoScope } from './entities/contract-cargo-scope.entity'; +import { isEffectivelyExpired } from './utils/contract-expiry.util'; +import { diffContractFields } from './contract-document-diff.util'; +import { ContractDocumentHistoryService } from './contract-document-history.service'; import { FileRecord } from '../files/entities/file.entity'; /** Paginated contract list: flat `total` (backoffice) + `meta` block (portal). */ @@ -40,6 +44,35 @@ export interface PaginatedContracts { }; } +/** Route list as a readable lane string, e.g. "Nagad → Mojo, Mojo → Adama". */ +function describeRoutes(routes?: ContractRoute[]): string | null { + if (!routes?.length) return null; + return [...routes] + .sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)) + .map( + (r) => + `${r.originYard?.label ?? r.originYardId} → ${r.destinationYard?.label ?? r.destinationYardId}`, + ) + .join(', '); +} + +/** Cargo scope as a readable string, e.g. "20ft ×2, 40ft ×1" or "Wheat ×500". */ +function describeCargoScope(scope?: ContractCargoScope[]): string | null { + if (!scope?.length) return null; + return scope + .map((row) => { + const label = + row.containerSize ?? + row.cargoType?.cargoTypeName ?? + row.cargoFreeText ?? + row.cargoTypeId ?? + 'cargo'; + return row.quantityCap != null ? `${label} ×${row.quantityCap}` : String(label); + }) + .sort() + .join(', '); +} + const NEEDS_ACTION_STATUSES = [ 'SUBMITTED', 'PENDING_APPROVAL', @@ -55,6 +88,7 @@ export class ContractsService { private readonly companiesService: CompaniesService, private readonly filesService: FilesService, private readonly minioService: MinioService, + private readonly documentHistory: ContractDocumentHistoryService, ) {} /** Generate a unique contract reference number (CTR-YYYY-NNNNN). */ @@ -155,6 +189,42 @@ export class ContractsService { } } + /** + * Same customer + same service type + an overlapping route already has a + * non-expired contract → block. A route "overlaps" if any origin/destination + * pair matches — good enough today since ONE_TIME and GENERAL contracts both + * carry a single route in practice, and still correct if that changes. + */ + private async assertNoDuplicateContract( + companyId: string, + serviceTypeId: string, + routes: CreateContractDto['routes'], + ): Promise { + const candidates = await this.contractsRepository.findDuplicateCandidates( + companyId, + serviceTypeId, + ); + const duplicate = candidates.find( + (c) => + !isEffectivelyExpired(c) && + (c.routes ?? []).some((existingRoute) => + routes.some( + (r) => + r.originYardId === existingRoute.originYardId && + r.destinationYardId === existingRoute.destinationYardId, + ), + ), + ); + if (duplicate) { + const until = duplicate.contractValidUntil + ? duplicate.contractValidUntil.toISOString().slice(0, 10) + : 'its approval completes'; + throw new ConflictException( + `An active contract already exists for this service type and route (${duplicate.reference}, valid until ${until}). A new request can't be submitted until it expires or is rejected/cancelled.`, + ); + } + } + /** Create a new contract (DRAFT) with its routes and cargo-scope rows. */ async create( dto: CreateContractDto, @@ -186,6 +256,9 @@ export class ContractsService { this.assertCargoScopeShape(dto.freightType, dto.cargoScope); this.assertRouteShape(dto.contractKind, dto.routes); await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes); + if (companyId) { + await this.assertNoDuplicateContract(companyId, dto.serviceTypeId, dto.routes); + } // Stamp the operational profile for portal scoping. A forwarder contract // pins its profile explicitly (trade direction can't tell it apart from a @@ -291,7 +364,11 @@ export class ContractsService { tradeDirection: dto.tradeDirection, freightType: dto.freightType, serviceTypeId: dto.serviceTypeId, - paymentCurrency: dto.paymentCurrency, + // A contract is always QUOTED in USD — the billing currency is chosen per + // booking (or on the shipment request when GL books for the customer), so + // any client-supplied currency here is ignored. Contracts created before + // this rule keep whatever they stored; update() never rewrites it. + paymentCurrency: 'USD', customsClearingEnabled: includesCustoms, customsClearingAgent: includesCustoms ? null : (dto.customsClearingAgent ?? null), equipmentReturn: dto.equipmentReturn ?? null, @@ -302,6 +379,10 @@ export class ContractsService { lastMileDeliveryLat: dto.lastMileDeliveryLat ?? null, lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null, isHazardous: dto.isHazardous ?? false, + // Hazard class / UN number only exist on a hazardous contract — a stale + // pair from an earlier draft must never survive the flag being turned off. + hazardClass: dto.isHazardous ? (dto.hazardClass ?? null) : null, + unNumber: dto.isHazardous ? (dto.unNumber ?? null) : null, isReefer: dto.isReefer ?? false, contractType: dto.contractType ?? null, status: 'DRAFT', @@ -445,6 +526,7 @@ export class ContractsService { id: string, dto: UpdateContractDto, files: Express.Multer.File[], + actorId?: string, ): Promise<{ contract: Contract; warnings: string[] }> { const existing = await this.findById(id); if (!CONTRACT_CUSTOMER_EDITABLE_STATUSES.includes(existing.status as never)) { @@ -471,9 +553,18 @@ export class ContractsService { tradeDirection: dto.tradeDirection ?? existing.tradeDirection, freightType, serviceTypeId: dto.serviceTypeId ?? existing.serviceTypeId, - paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency, + // Never rewritten: grandfathered contracts keep the currency (and frozen + // snapshots) they were signed with. + paymentCurrency: existing.paymentCurrency, isHazardous: dto.isHazardous ?? existing.isHazardous, isReefer: dto.isReefer ?? existing.isReefer, + // Same rule as create: clearing the flag clears the declaration with it. + hazardClass: (dto.isHazardous ?? existing.isHazardous) + ? (dto.hazardClass ?? existing.hazardClass ?? null) + : null, + unNumber: (dto.isHazardous ?? existing.isHazardous) + ? (dto.unNumber ?? existing.unNumber ?? null) + : null, equipmentReturn: dto.equipmentReturn ?? existing.equipmentReturn, firstMilePickupAddress: dto.firstMilePickupAddress ?? existing.firstMilePickupAddress, firstMilePickupLat: dto.firstMilePickupLat ?? existing.firstMilePickupLat, @@ -524,7 +615,52 @@ export class ContractsService { existing.companyProfileId ?? null, ); - return { contract: await this.findById(id), warnings }; + const updated = await this.findById(id); + // Audit what this edit actually changed. Runs after the writes so the + // "after" side is read back from the contract rather than from the DTO. + await this.recordFieldRevision(existing, updated, actorId); + + return { contract: updated, warnings }; + } + + /** Fields worth auditing on a customer edit, read off a loaded contract. */ + private auditableFields(contract: Contract): Record { + return { + contractKind: contract.contractKind, + tradeDirection: contract.tradeDirection, + freightType: contract.freightType, + serviceType: contract.serviceType?.serviceName ?? contract.serviceTypeId, + paymentCurrency: contract.paymentCurrency, + contractType: contract.contractType, + isHazardous: contract.isHazardous, + hazardClass: contract.hazardClass, + unNumber: contract.unNumber, + isReefer: contract.isReefer, + equipmentReturn: contract.equipmentReturn, + customsClearingAgent: contract.customsClearingAgent, + firstMilePickupAddress: contract.firstMilePickupAddress, + lastMileDeliveryAddress: contract.lastMileDeliveryAddress, + routes: describeRoutes(contract.routes), + cargoScope: describeCargoScope(contract.cargoScope), + }; + } + + /** Append a revision describing a customer's edit to the contract itself. */ + private async recordFieldRevision( + before: Contract, + after: Contract, + actorId?: string, + ): Promise { + const changes = diffContractFields( + this.auditableFields(before), + this.auditableFields(after), + ); + await this.documentHistory.recordChanges({ + contractId: after.id, + changes, + actorId: actorId ?? null, + actorRole: 'Customer', + }); } /** Parse comma-separated or repeated status query values. */ diff --git a/apps/edr-freight-api/src/modules/contracts/do-collection-dates.spec.ts b/apps/edr-freight-api/src/modules/contracts/do-collection-dates.spec.ts new file mode 100644 index 000000000..37ed6c267 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/do-collection-dates.spec.ts @@ -0,0 +1,46 @@ +import { BadRequestException } from '@nestjs/common'; + +import { assertDoCollectionDates } from './contract-clearance.util'; + +describe('assertDoCollectionDates', () => { + it('requires both dates', () => { + expect(() => assertDoCollectionDates(undefined)).toThrow(BadRequestException); + expect(() => + assertDoCollectionDates({ vesselArrivalDate: '2026-07-01' }), + ).toThrow(/DO collected date is required/); + expect(() => + assertDoCollectionDates({ doCollectedDate: '2026-07-01' }), + ).toThrow(/Vessel arrival date is required/); + // Whitespace is not a date. + expect(() => + assertDoCollectionDates({ vesselArrivalDate: ' ', doCollectedDate: ' ' }), + ).toThrow(BadRequestException); + }); + + it('rejects a DO collected before the vessel arrived', () => { + expect(() => + assertDoCollectionDates({ + vesselArrivalDate: '2026-07-10', + doCollectedDate: '2026-07-09', + }), + ).toThrow(/cannot be earlier than the vessel arrival date/); + }); + + it('normalizes an ISO datetime down to its date part', () => { + expect( + assertDoCollectionDates({ + vesselArrivalDate: '2026-07-10T21:00:00.000Z', + doCollectedDate: '2026-07-10T05:00:00.000Z', + }), + ).toEqual({ vesselArrivalDate: '2026-07-10', doCollectedDate: '2026-07-10' }); + }); + + it('rejects a malformed date', () => { + expect(() => + assertDoCollectionDates({ + vesselArrivalDate: '10/07/2026', + doCollectedDate: '2026-07-10', + }), + ).toThrow(/not a valid date/); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/dto/accept-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/accept-contract.dto.ts index d3eaa73a3..8b4e12793 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/accept-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/accept-contract.dto.ts @@ -1,6 +1,13 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsInt, IsOptional, Max, Min, ValidateNested } from 'class-validator'; +import { + IsDateString, + IsInt, + IsOptional, + Max, + Min, + ValidateNested, +} from 'class-validator'; import { UpdateContractDocumentDto } from './contract-document.dto'; @@ -18,6 +25,21 @@ export class AcceptContractDto { @Max(3650) validityDays!: number; + /** + * Explicit validity window picked by staff in the accept dialog. When both are + * present they win over `validityDays` (which is then only the derived span) + * and the configured-period check is skipped — staff may enter any range. + */ + @ApiPropertyOptional({ description: 'Validity start (ISO date)' }) + @IsOptional() + @IsDateString() + validFrom?: string; + + @ApiPropertyOptional({ description: 'Validity end (ISO date)' }) + @IsOptional() + @IsDateString() + validUntil?: string; + /** * Optional per-contract document override edited by staff in the accept * dialog. When present its articles are frozen onto THIS contract; when diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-request.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-request.dto.ts index 11d50494a..9f596bbef 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-request.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-request.dto.ts @@ -2,6 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform, Type } from 'class-transformer'; import { IsArray, + IsIn, IsInt, IsNumber, IsOptional, @@ -91,6 +92,15 @@ export class CreateBookingRequestDto { @Type(() => RequestBulkLineDto) bulk?: RequestBulkLineDto; + @ApiPropertyOptional({ + enum: ['ETB', 'USD'], + description: + 'Billing currency for the shipment GL will book. Intercity is always ETB.', + }) + @IsOptional() + @IsIn(['ETB', 'USD']) + paymentCurrency?: string; + @ApiPropertyOptional() @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts index 93a0e9e76..2ab616c00 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts @@ -15,6 +15,8 @@ import { ValidateNested, } from 'class-validator'; +import { PAYMENT_CURRENCIES } from './create-contract.dto'; + /** Per-shipment equipment return — "NA" stays contract-level only. */ const SHIPMENT_EQUIPMENT_RETURNS = ['WITH_RETURN', 'WITHOUT_RETURN'] as const; @@ -150,6 +152,20 @@ export class CreateBookingUnderContractDto { @IsUUID() contractRouteId?: string; + /** + * The contract quotes in USD; the customer picks the billing currency here. + * Omitted → the contract's own currency (USD for contracts created under the + * current rule, the grandfathered currency for older ones). Intercity is + * forced to ETB by the service regardless of what is sent. + */ + @ApiPropertyOptional({ + enum: PAYMENT_CURRENCIES, + description: 'Billing currency for this shipment. Intercity is always ETB.', + }) + @IsOptional() + @IsIn([...PAYMENT_CURRENCIES]) + paymentCurrency?: string; + @ApiPropertyOptional({ description: 'Binding shipment day. Omitted for intercity (DOMESTIC) bookings — staff assign a passing train later.', diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts index fb4f40654..88ab8beb7 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts @@ -17,6 +17,8 @@ import { ValidateNested, } from 'class-validator'; +import { HAZARD_CLASS_VALUES } from '@edr/types'; + import { CONTRACT_KINDS } from '../entities/contract.entity'; const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const; @@ -156,9 +158,20 @@ export class CreateContractDto { @IsUUID() serviceTypeId!: string; - @ApiProperty({ enum: PAYMENT_CURRENCIES }) + /** + * Deprecated at the contract level. A contract now always quotes in USD; the + * customer picks the billing currency per booking (or on the shipment request + * when GL books on their behalf). Accepted but ignored on create so older + * clients don't break — the service forces USD. + */ + @ApiPropertyOptional({ + enum: PAYMENT_CURRENCIES, + deprecated: true, + description: 'Ignored — contracts always quote in USD. Choose currency at booking.', + }) + @IsOptional() @IsIn([...PAYMENT_CURRENCIES]) - paymentCurrency!: string; + paymentCurrency?: string; @ApiPropertyOptional({ description: 'Whether EDR/GL handles customs clearance' }) @IsOptional() @@ -228,6 +241,28 @@ export class CreateContractDto { @Transform(({ value }) => value === 'true' || value === true) isHazardous?: boolean; + @ApiPropertyOptional({ + enum: HAZARD_CLASS_VALUES, + description: 'UN/ADR dangerous-goods class. Required when isHazardous.', + }) + @ValidateIf((o: CreateContractDto) => o.isHazardous === true) + @IsIn(HAZARD_CLASS_VALUES, { + message: `hazardClass must be one of: ${HAZARD_CLASS_VALUES.join(', ')}`, + }) + hazardClass?: string; + + @ApiPropertyOptional({ + description: 'UN number of the dangerous good. Required when isHazardous.', + }) + @ValidateIf((o: CreateContractDto) => o.isHazardous === true) + @IsString() + @MinLength(1) + @MaxLength(16) + @Transform(({ value }) => + typeof value === 'string' ? value.trim().toUpperCase() : value, + ) + unNumber?: string; + @ApiPropertyOptional({ default: false, description: 'Sets contracts.is_reefer' }) @IsOptional() @IsBoolean() diff --git a/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts index 5ddffad6c..91759d75e 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts @@ -17,6 +17,17 @@ export class SignContractDto { @MinLength(20) signatureImageBase64?: string; + @ApiPropertyOptional({ + description: + 'PNG company stamp/seal image as base64 (with or without data URL prefix). ' + + 'Required for the CUSTOMER and STAFF roles — both parties must seal the ' + + 'contract before it is fully executed.', + }) + @IsOptional() + @IsString() + @MinLength(20) + stampImageBase64?: string; + @ApiProperty() @IsString() @MinLength(1) diff --git a/apps/edr-freight-api/src/modules/contracts/entities/booking-request.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/booking-request.entity.ts index 6582376d3..b530bb5e9 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/booking-request.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/booking-request.entity.ts @@ -43,6 +43,14 @@ export class BookingRequest extends BaseEntity { @Column({ name: 'requested_lines', type: 'jsonb', default: () => "'{}'::jsonb" }) requestedLines!: Freight.RequestedShipmentLines; + /** + * Billing currency the customer chose for this shipment. The contract quotes + * in USD; on a customs contract GL creates the booking, so this is where the + * customer states which currency to be invoiced in. + */ + @Column({ name: 'payment_currency', type: 'varchar', length: 5, nullable: true }) + paymentCurrency?: string | null; + @Column({ name: 'notes', type: 'text', nullable: true }) notes?: string | null; diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract-clearance-cycle.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract-clearance-cycle.entity.ts index 3c101f58e..8f5aa7e81 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract-clearance-cycle.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract-clearance-cycle.entity.ts @@ -43,6 +43,41 @@ export class ContractClearanceCycle extends BaseEntity { @Column({ name: 'vessel_departure_date', type: 'date', nullable: true }) vesselDepartureDate?: string | null; + /** Import DO: when the vessel arrived in Djibouti. Required on DO upload. */ + @Column({ name: 'vessel_arrival_date', type: 'date', nullable: true }) + vesselArrivalDate?: string | null; + + /** Import DO: when GL Djibouti collected the DO. Required on DO upload. */ + @Column({ name: 'do_collected_date', type: 'date', nullable: true }) + doCollectedDate?: string | null; + + /** + * Transit-assignee handshake that runs BEFORE the customs declaration: GL + * Ethiopia asks Djibouti for the officer who will handle the shipment in + * transit, and Djibouti answers with a name. The declaration step stays shut + * until `transitAssigneeName` is set; Djibouti may overwrite it later + * (reassignment) and the newer name simply wins. + */ + @Column({ name: 'transit_assignee_requested_at', type: 'timestamptz', nullable: true }) + transitAssigneeRequestedAt?: Date | null; + + @Column({ name: 'transit_assignee_requested_by_user_id', type: 'uuid', nullable: true }) + transitAssigneeRequestedByUserId?: string | null; + + /** What GL Ethiopia asked for — shown on the Djibouti queue. */ + @Column({ name: 'transit_assignee_request_note', type: 'text', nullable: true }) + transitAssigneeRequestNote?: string | null; + + /** The officer Djibouti named — free text, no user directory to bind to. */ + @Column({ name: 'transit_assignee_name', type: 'text', nullable: true }) + transitAssigneeName?: string | null; + + @Column({ name: 'transit_assignee_assigned_at', type: 'timestamptz', nullable: true }) + transitAssigneeAssignedAt?: Date | null; + + @Column({ name: 'transit_assignee_assigned_by_user_id', type: 'uuid', nullable: true }) + transitAssigneeAssignedByUserId?: string | null; + @Column({ name: 'ro_amendment_requested_at', type: 'timestamptz', nullable: true }) roAmendmentRequestedAt?: Date | null; diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract-document-revision.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract-document-revision.entity.ts index bc7e12e3e..a832cb50c 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract-document-revision.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract-document-revision.entity.ts @@ -21,6 +21,13 @@ export class ContractDocumentRevision extends BaseEntity { @Column({ name: 'actor_id', type: 'uuid', nullable: true }) actorId?: string | null; + /** + * Who made the edit, captured at the time. Denormalised so the trail still + * names them after a rename or a deactivated account. + */ + @Column({ name: 'actor_name', type: 'varchar', length: 200, nullable: true }) + actorName?: string | null; + /** The approval step's required role at the time of the edit. */ @Column({ name: 'actor_role', type: 'varchar', length: 64, nullable: true }) actorRole?: string | null; diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract-review-note.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract-review-note.entity.ts index 5b744338e..4d2a46365 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract-review-note.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract-review-note.entity.ts @@ -8,6 +8,11 @@ export const CONTRACT_REVIEW_NOTE_TYPES = [ 'STAFF_NOTE', 'CUSTOMER_NOTE', 'AMENDMENT', + /** + * The customer disputed the advised duty & tax and asked GL Ethiopia to + * correct it. One row per round — the advice/dispute loop can repeat. + */ + 'DUTY_DISPUTE', ] as const; export type ContractReviewNoteType = (typeof CONTRACT_REVIEW_NOTE_TYPES)[number]; diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract-signature.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract-signature.entity.ts index bed79d8b1..a17601bf8 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract-signature.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract-signature.entity.ts @@ -29,6 +29,14 @@ export class ContractSignature extends BaseEntity { @JoinColumn({ name: 'signature_file_id' }) signatureFile?: FileRecord | null; + /** Company stamp/seal image, uploaded alongside the drawn signature. */ + @Column({ name: 'stamp_file_id', type: 'uuid', nullable: true }) + stampFileId?: string | null; + + @ManyToOne(() => FileRecord, { nullable: true }) + @JoinColumn({ name: 'stamp_file_id' }) + stampFile?: FileRecord | null; + @Column({ name: 'consent_text', type: 'text', nullable: true }) consentText?: string | null; diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts index b8635199d..3fe48ea27 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts @@ -188,6 +188,14 @@ export class Contract extends BaseEntity { @Column({ name: 'is_hazardous', type: 'boolean', default: false }) isHazardous!: boolean; + /** UN/ADR dangerous-goods class (CLASS_1..CLASS_9); null unless hazardous. */ + @Column({ name: 'hazard_class', type: 'varchar', length: 16, nullable: true }) + hazardClass?: string | null; + + /** UN number of the dangerous good; null unless hazardous. */ + @Column({ name: 'un_number', type: 'varchar', length: 16, nullable: true }) + unNumber?: string | null; + @Column({ name: 'is_reefer', type: 'boolean', default: false }) isReefer!: boolean; diff --git a/apps/edr-freight-api/src/modules/contracts/shipment-currency.spec.ts b/apps/edr-freight-api/src/modules/contracts/shipment-currency.spec.ts new file mode 100644 index 000000000..7bd109430 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/shipment-currency.spec.ts @@ -0,0 +1,85 @@ +import { ContractBookingService } from './contract-booking.service'; +import { BookingPricingService } from '../bookings/booking-pricing.service'; +import type { Contract } from './entities/contract.entity'; +import type { ContractRateSnapshot } from './entities/contract-rate-snapshot.entity'; + +const contract = (over: Partial): Contract => + ({ tradeDirection: 'IMPORT', paymentCurrency: 'USD', ...over }) as Contract; + +/** The private resolver, reached without standing up the whole Nest graph. */ +const resolveCurrency = (c: Contract, requested?: string | null): string => + ( + ContractBookingService.prototype as unknown as { + resolveShipmentCurrency: (c: Contract, r?: string | null) => string; + } + ).resolveShipmentCurrency(c, requested); + +const snapshot = (currency: string, unitPrice: number): ContractRateSnapshot => + ({ rateCode: 'CONTAINER_20FT', currency, unitPrice }) as ContractRateSnapshot; + +const frozenByCode = ( + snap: ContractRateSnapshot | null, + bookingCurrency: string, + usdToEtb: number, +): ContractRateSnapshot | null => + ( + BookingPricingService.prototype as unknown as { + frozenRateByCode: ( + m: Map | null, + code: string, + bookingCurrency: string, + usdToEtb: number, + ) => ContractRateSnapshot | null; + } + ).frozenRateByCode( + snap ? new Map([['CONTAINER_20FT', snap]]) : null, + 'CONTAINER_20FT', + bookingCurrency, + usdToEtb, + ); + +describe('per-shipment billing currency', () => { + it('takes the customer choice over the contract', () => { + expect(resolveCurrency(contract({}), 'ETB')).toBe('ETB'); + expect(resolveCurrency(contract({}), 'USD')).toBe('USD'); + }); + + it('falls back to the contract currency when none is chosen', () => { + // Grandfathered ETB contract with no explicit choice. + expect(resolveCurrency(contract({ paymentCurrency: 'ETB' }))).toBe('ETB'); + expect(resolveCurrency(contract({}), ' ')).toBe('USD'); + }); + + it('forces ETB on intercity whatever was requested', () => { + const domestic = contract({ tradeDirection: 'DOMESTIC' }); + expect(resolveCurrency(domestic, 'USD')).toBe('ETB'); + expect(resolveCurrency(domestic)).toBe('ETB'); + }); +}); + +describe('frozen contract rate in the booking currency', () => { + it('converts a USD snapshot for an ETB booking instead of dropping it', () => { + // The old behaviour returned null here, which silently re-priced the + // booking at live rates and lost the agreed contract price. + expect(frozenByCode(snapshot('USD', 400), 'ETB', 150)?.unitPrice).toBe(60_000); + }); + + it('converts a grandfathered ETB snapshot back for a USD booking', () => { + expect(frozenByCode(snapshot('ETB', 60_000), 'USD', 150)?.unitPrice).toBe(400); + }); + + it('passes a matching-currency snapshot through untouched', () => { + const snap = snapshot('USD', 400); + expect(frozenByCode(snap, 'USD', 1)).toBe(snap); + }); + + it('refuses to price off an unusable exchange rate', () => { + // Converting with 0 would zero the whole line. + expect(frozenByCode(snapshot('USD', 400), 'ETB', 0)).toBeNull(); + expect(frozenByCode(snapshot('USD', 400), 'ETB', Number.NaN)).toBeNull(); + }); + + it('returns null when there is no snapshot', () => { + expect(frozenByCode(null, 'ETB', 150)).toBeNull(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/transit-assignee.spec.ts b/apps/edr-freight-api/src/modules/contracts/transit-assignee.spec.ts new file mode 100644 index 000000000..da4b2f34b --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/transit-assignee.spec.ts @@ -0,0 +1,175 @@ +import { BadRequestException } from '@nestjs/common'; + +import { ContractClearanceService } from './contract-clearance.service'; +import type { Contract } from './entities/contract.entity'; + +/** + * Pre-declaration transit-assignee handshake. GL Ethiopia asks Djibouti who will + * handle the shipment in transit; Djibouti answers with a name. The customs + * declaration stays shut until that name exists, and Djibouti may send a + * different one later. + */ +describe('ContractClearanceService — transit assignee', () => { + const contract = (over: Partial = {}): Contract => + ({ + id: 'ctr-1', + reference: 'CTR-2026-00042', + tradeDirection: 'IMPORT', + customsClearingEnabled: true, + contractKind: 'ONE_TIME', + ...over, + }) as Contract; + + let repo: { currentCycle: jest.Mock; updateCycle: jest.Mock }; + let contractsService: { findById: jest.Mock }; + let notifier: { + transitAssigneeRequested: jest.Mock; + transitAssigneeAssigned: jest.Mock; + }; + let service: ContractClearanceService; + + const cycle = (over: Record = {}) => ({ + id: 'cyc-1', + transitAssigneeRequestedAt: null, + transitAssigneeName: null, + ...over, + }); + + beforeEach(() => { + repo = { + currentCycle: jest.fn().mockResolvedValue(cycle()), + updateCycle: jest.fn().mockResolvedValue(undefined), + }; + contractsService = { findById: jest.fn().mockResolvedValue(contract()) }; + notifier = { + transitAssigneeRequested: jest.fn(), + transitAssigneeAssigned: jest.fn(), + }; + service = new ContractClearanceService( + repo as never, + contractsService as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + notifier as never, + ); + }); + + describe('request (GL Ethiopia)', () => { + it('stamps the ask and pings Djibouti', async () => { + await service.requestTransitAssignee('ctr-1', ' Reefer, needs a cold-chain officer ', 'et-1'); + + const patch = repo.updateCycle.mock.calls[0][1]; + expect(patch.transitAssigneeRequestedAt).toBeInstanceOf(Date); + expect(patch.transitAssigneeRequestedByUserId).toBe('et-1'); + expect(patch.transitAssigneeRequestNote).toBe( + 'Reefer, needs a cold-chain officer', + ); + expect(notifier.transitAssigneeRequested).toHaveBeenCalled(); + }); + }); + + describe('assign (GL Djibouti)', () => { + it('records the officer and tells Ethiopia they can proceed', async () => { + repo.currentCycle.mockResolvedValue( + cycle({ transitAssigneeRequestedAt: new Date() }), + ); + + await service.assignTransitAssignee('ctr-1', ' Ahmed Bourhan ', 'dj-1'); + + const patch = repo.updateCycle.mock.calls[0][1]; + expect(patch.transitAssigneeName).toBe('Ahmed Bourhan'); + expect(patch.transitAssigneeAssignedByUserId).toBe('dj-1'); + expect(notifier.transitAssigneeAssigned).toHaveBeenCalledWith( + expect.objectContaining({ id: 'ctr-1' }), + 'Ahmed Bourhan', + null, + ); + }); + + it('reassigns, carrying the previous name into the notice', async () => { + repo.currentCycle.mockResolvedValue( + cycle({ + transitAssigneeRequestedAt: new Date(), + transitAssigneeName: 'Ahmed Bourhan', + }), + ); + + await service.assignTransitAssignee('ctr-1', 'Fatouma Ali', 'dj-1'); + + expect(notifier.transitAssigneeAssigned).toHaveBeenCalledWith( + expect.anything(), + 'Fatouma Ali', + 'Ahmed Bourhan', + ); + }); + + it('refuses an empty name', async () => { + repo.currentCycle.mockResolvedValue( + cycle({ transitAssigneeRequestedAt: new Date() }), + ); + await expect( + service.assignTransitAssignee('ctr-1', ' ', 'dj-1'), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('refuses before Ethiopia has asked', async () => { + await expect( + service.assignTransitAssignee('ctr-1', 'Ahmed Bourhan', 'dj-1'), + ).rejects.toThrow(/not requested/i); + }); + }); + + describe('declaration gate', () => { + const ensure = (c: Contract) => + ( + service as unknown as { + ensureDeclarationPrerequisites: (id: string, c: Contract) => Promise; + } + ).ensureDeclarationPrerequisites('ctr-1', c); + + beforeEach(() => { + // Documents are approved; only the assignee decides the outcome here. + ( + service as unknown as { isClearanceFullyApproved: unknown } + ).isClearanceFullyApproved = jest.fn().mockResolvedValue(true); + }); + + it('tells GL to raise the request when none exists', async () => { + await expect(ensure(contract())).rejects.toThrow( + /Request a transit assignee/i, + ); + }); + + it('tells GL to wait when Djibouti has not answered', async () => { + repo.currentCycle.mockResolvedValue( + cycle({ transitAssigneeRequestedAt: new Date() }), + ); + await expect(ensure(contract())).rejects.toThrow(/has not assigned/i); + }); + + it('lets the declaration through once the officer is named', async () => { + repo.currentCycle.mockResolvedValue( + cycle({ + transitAssigneeRequestedAt: new Date(), + transitAssigneeName: 'Ahmed Bourhan', + }), + ); + ( + service as unknown as { workflowService: unknown } + ).workflowService = { + listMilestones: jest + .fn() + .mockResolvedValue([ + { milestoneCode: 'DOCUMENTS_APPROVED', status: 'COMPLETED' }, + ]), + }; + + await expect(ensure(contract())).resolves.toBeUndefined(); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/utils/contract-expiry.util.ts b/apps/edr-freight-api/src/modules/contracts/utils/contract-expiry.util.ts new file mode 100644 index 000000000..b9e491d29 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/utils/contract-expiry.util.ts @@ -0,0 +1,25 @@ +import type { Contract } from '../entities/contract.entity'; + +/** Statuses that already mean "done/void" — a contract in one of these never blocks a duplicate. */ +export const TERMINAL_CONTRACT_STATUSES = [ + 'REJECTED', + 'CANCELLED', + 'CONTRACT_CLOSED', + 'ARCHIVED', + 'EXPIRED', +] as const; + +/** + * True once a contract is done, either explicitly (terminal status) or by date + * (past contractValidUntil). Checked by date too because the nightly expiry + * cron only flips the status once a day — this keeps same-day checks correct + * even a few hours before the cron runs. + */ +export function isEffectivelyExpired( + contract: Pick, +): boolean { + if ((TERMINAL_CONTRACT_STATUSES as readonly string[]).includes(contract.status)) { + return true; + } + return Boolean(contract.contractValidUntil && contract.contractValidUntil < new Date()); +} diff --git a/apps/edr-freight-api/src/modules/files/entities/file.entity.ts b/apps/edr-freight-api/src/modules/files/entities/file.entity.ts index 1fbd1459f..7624800d8 100644 --- a/apps/edr-freight-api/src/modules/files/entities/file.entity.ts +++ b/apps/edr-freight-api/src/modules/files/entities/file.entity.ts @@ -53,4 +53,16 @@ export class FileRecord extends BaseEntity { @Column({ name: "reviewed_at", type: "timestamptz", nullable: true }) reviewedAt!: Date | null; + + /** + * Who replaced this version, when a newer file took its place. Superseded + * versions are soft-deleted rather than dropped, so the original a customer + * uploaded survives a staff correction and the two can be compared. + */ + @Column({ name: "replaced_by_user_id", type: "uuid", nullable: true }) + replacedByUserId!: string | null; + + /** Why the file was replaced — shown on the document's version history. */ + @Column({ name: "replace_reason", type: "text", nullable: true }) + replaceReason!: string | null; } diff --git a/apps/edr-freight-api/src/modules/files/files.repository.ts b/apps/edr-freight-api/src/modules/files/files.repository.ts index 583e221ed..9a2552c7a 100644 --- a/apps/edr-freight-api/src/modules/files/files.repository.ts +++ b/apps/edr-freight-api/src/modules/files/files.repository.ts @@ -41,12 +41,47 @@ export class FilesRepository extends BaseRepository { return this.repository.findOne({ where: { resourceId, resource, code } }); } + /** + * Retire the live version(s) of a document code. SOFT delete on purpose: the + * bytes and the row stay so the original upload can still be read back from + * the version history after staff replace it. Every normal read already + * filters soft-deleted rows, so callers see only the current version. + * + * `replacedBy` / `reason` are stamped on the retired row when a newer file is + * taking its place (as opposed to a plain removal). + */ async deleteByCode( resourceId: string, resource: string, code: string, + replacedBy?: { userId?: string | null; reason?: string | null }, ): Promise { - await this.repository.delete({ resourceId, resource, code }); + if (replacedBy) { + await this.repository.update( + { resourceId, resource, code }, + { + replacedByUserId: replacedBy.userId ?? null, + replaceReason: replacedBy.reason ?? null, + }, + ); + } + await this.repository.softDelete({ resourceId, resource, code }); + } + + /** + * Every version of one document code, newest first — superseded versions + * included. The only read that deliberately looks past the soft-delete filter. + */ + findVersionHistory( + resourceId: string, + resource: string, + code: string, + ): Promise { + return this.repository.find({ + where: { resourceId, resource, code }, + withDeleted: true, + order: { createdAt: "DESC" }, + }); } /** diff --git a/apps/edr-freight-api/src/modules/files/files.service.spec.ts b/apps/edr-freight-api/src/modules/files/files.service.spec.ts new file mode 100644 index 000000000..fc6fb82d3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/files/files.service.spec.ts @@ -0,0 +1,112 @@ +import { FilesService } from './files.service'; + +/** + * Replacing a stored document must never destroy the previous one: the customer + * uploaded it, and a staff correction has to stay auditable against it. The old + * row is soft-deleted (so every normal read still returns exactly the current + * version) and stamped with who replaced it and why. + */ +describe('FilesService — document versions', () => { + const file = { + originalname: 'bill-of-lading.pdf', + size: 1234, + mimetype: 'application/pdf', + buffer: Buffer.from('x'), + } as Express.Multer.File; + + let filesRepository: { + deleteByCode: jest.Mock; + create: jest.Mock; + findVersionHistory: jest.Mock; + }; + let service: FilesService; + + beforeEach(() => { + filesRepository = { + deleteByCode: jest.fn().mockResolvedValue(undefined), + create: jest.fn(async (row) => ({ id: 'file-new', ...row })), + findVersionHistory: jest.fn().mockResolvedValue([]), + }; + service = new FilesService( + filesRepository as never, + { + uploadFile: jest.fn().mockResolvedValue('https://minio/bucket/new.pdf'), + getObjectNameFromUrl: (u: string) => u, + getSignedUrl: jest.fn(), + } as never, + ); + }); + + it('stamps the retired version with who replaced it and why', async () => { + await service.upsertByCode( + { resourceId: 'ctr-1', resource: 'contracts', code: 'bill_of_lading', file }, + { userId: 'gl-user-1', reason: 'Customer sent page 2 only' }, + ); + + expect(filesRepository.deleteByCode).toHaveBeenCalledWith( + 'ctr-1', + 'contracts', + 'bill_of_lading', + { userId: 'gl-user-1', reason: 'Customer sent page 2 only' }, + ); + }); + + it('still replaces silently when no replacer is given (system overwrites)', async () => { + await service.upsertByCode({ + resourceId: 'ctr-1', + resource: 'contracts', + code: 'contract_pdf', + file, + }); + + expect(filesRepository.deleteByCode).toHaveBeenCalledWith( + 'ctr-1', + 'contracts', + 'contract_pdf', + undefined, + ); + }); + + it('marks the live row current and the soft-deleted ones superseded', async () => { + filesRepository.findVersionHistory.mockResolvedValue([ + { + id: 'v2', + name: 'corrected.pdf', + url: 'u2', + size: 2, + mimeType: 'application/pdf', + createdAt: new Date('2026-07-20T10:00:00Z'), + deletedAt: null, + replacedByUserId: null, + replaceReason: null, + }, + { + id: 'v1', + name: 'original.pdf', + url: 'u1', + size: 1, + mimeType: 'application/pdf', + createdAt: new Date('2026-07-18T10:00:00Z'), + deletedAt: new Date('2026-07-20T10:00:00Z'), + replacedByUserId: 'gl-user-1', + replaceReason: 'Wrong page order', + }, + ]); + + const versions = await service.versionHistory( + 'ctr-1', + 'contracts', + 'bill_of_lading', + ); + + expect(versions[0]).toMatchObject({ id: 'v2', isCurrent: true, replacedAt: null }); + expect(versions[1]).toMatchObject({ + id: 'v1', + isCurrent: false, + replacedByUserId: 'gl-user-1', + replaceReason: 'Wrong page order', + }); + // The customer's original is still readable — that is the whole point. + expect(versions[1].url).toBe('u1'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/files/files.service.ts b/apps/edr-freight-api/src/modules/files/files.service.ts index e959fa556..ea11e5fe8 100644 --- a/apps/edr-freight-api/src/modules/files/files.service.ts +++ b/apps/edr-freight-api/src/modules/files/files.service.ts @@ -104,13 +104,66 @@ export class FilesService { }); } - /** Replace existing file row for the same resource + code (e.g. contract PDF). */ - async upsertByCode(input: CreateFileInput): Promise { + /** + * Replace the file stored under a resource + code (e.g. contract PDF). The + * previous version is retired, not destroyed — pass `replacedBy` to record who + * swapped it and why, which is what the version history shows. + */ + async upsertByCode( + input: CreateFileInput, + replacedBy?: { userId?: string | null; reason?: string | null }, + ): Promise { const { resourceId, resource, code } = input; - await this.filesRepository.deleteByCode(resourceId, resource, code); + await this.filesRepository.deleteByCode( + resourceId, + resource, + code, + replacedBy, + ); return this.upload(input); } + /** + * Every stored version of one document, newest first. `isCurrent` marks the + * live row; the rest are superseded uploads kept for audit. + */ + async versionHistory( + resourceId: string, + resource: string, + code: string, + ): Promise< + Array<{ + id: string; + name: string; + url: string; + size: number; + mimeType: string; + uploadedAt: string; + isCurrent: boolean; + replacedAt: string | null; + replacedByUserId: string | null; + replaceReason: string | null; + }> + > { + const rows = await this.filesRepository.findVersionHistory( + resourceId, + resource, + code, + ); + return rows.map((row) => ({ + id: row.id, + name: row.name, + url: row.url, + size: row.size, + mimeType: row.mimeType, + uploadedAt: row.createdAt.toISOString(), + isCurrent: row.deletedAt == null, + replacedAt: row.deletedAt ? row.deletedAt.toISOString() : null, + replacedByUserId: row.replacedByUserId, + replaceReason: row.replaceReason, + })); + } + async deleteByCode( resourceId: string, resource: string, 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/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..e8fa57cdc 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,7 @@ 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 { SetDistancesDto } from './dto/set-distances.dto'; import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto'; import { LastMileStatus } from './entities/last-mile.entity'; @@ -131,6 +132,18 @@ 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/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..d7c45bb3c 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 @@ -869,6 +869,46 @@ 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 setDistances( id: string, distances: Array<{ vehicleId: string; distanceKm: number }>, diff --git a/apps/edr-freight-api/src/modules/locomotives/dto/create-locomotive.dto.ts b/apps/edr-freight-api/src/modules/locomotives/dto/create-locomotive.dto.ts index d46622ba4..0cde28fb7 100644 --- a/apps/edr-freight-api/src/modules/locomotives/dto/create-locomotive.dto.ts +++ b/apps/edr-freight-api/src/modules/locomotives/dto/create-locomotive.dto.ts @@ -65,25 +65,4 @@ export class CreateLocomotiveDto { @IsNumber() @Min(0) overageToleranceMeters?: number; - - @ApiPropertyOptional({ example: 4200 }) - @IsOptional() - @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value))) - @IsNumber() - @Min(0) - powerKw?: number; - - @ApiPropertyOptional({ example: 300 }) - @IsOptional() - @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value))) - @IsNumber() - @Min(0) - tractionForceKn?: number; - - @ApiPropertyOptional({ example: 120 }) - @IsOptional() - @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value))) - @IsNumber() - @Min(0) - maxSpeedKmh?: number; } diff --git a/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts b/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts index d7a875c24..d73795375 100644 --- a/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts +++ b/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts @@ -76,15 +76,6 @@ export class Locomotive extends BaseEntity { @JoinColumn({ name: 'current_yard_id' }) currentYard?: Yard | null; - @Column({ name: 'power_kw', type: 'numeric', precision: 10, scale: 3, nullable: true }) - powerKw?: number | null; - - @Column({ name: 'traction_force_kn', type: 'numeric', precision: 10, scale: 3, nullable: true }) - tractionForceKn?: number | null; - - @Column({ name: 'max_speed_kmh', type: 'numeric', precision: 10, scale: 3, nullable: true }) - maxSpeedKmh?: number | null; - @OneToMany(() => TrainSet, (trainSet) => trainSet.locomotive) trainSets?: TrainSet[]; } diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts index 46e0ae415..46396893e 100644 --- a/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts @@ -113,9 +113,6 @@ export class LocomotivesService { maxTrainLengthMeters: dto.maxTrainLengthMeters, overageToleranceTons: dto.overageToleranceTons ?? null, overageToleranceMeters: dto.overageToleranceMeters ?? null, - powerKw: dto.powerKw ?? null, - tractionForceKn: dto.tractionForceKn ?? null, - maxSpeedKmh: dto.maxSpeedKmh ?? null, }); } @@ -176,11 +173,6 @@ export class LocomotivesService { ? locomotive.currentYardId : (dto.currentYardId ?? null), name: dto.name === undefined ? locomotive.name : dto.name?.trim() || null, - powerKw: dto.powerKw === undefined ? locomotive.powerKw : dto.powerKw ?? null, - tractionForceKn: - dto.tractionForceKn === undefined ? locomotive.tractionForceKn : dto.tractionForceKn ?? null, - maxSpeedKmh: - dto.maxSpeedKmh === undefined ? locomotive.maxSpeedKmh : dto.maxSpeedKmh ?? null, }); if (!updated) { diff --git a/apps/edr-freight-api/src/modules/routes/routes.duplicate.spec.ts b/apps/edr-freight-api/src/modules/routes/routes.duplicate.spec.ts new file mode 100644 index 000000000..43194ce08 --- /dev/null +++ b/apps/edr-freight-api/src/modules/routes/routes.duplicate.spec.ts @@ -0,0 +1,80 @@ +import { ConflictException } from '@nestjs/common'; +import type { DataSource } from 'typeorm'; + +import { RoutesService } from './routes.service'; +import type { RoutesRepository } from './routes.repository'; + +type StopSeq = Array<{ yardId: string; sequenceNo: number }>; + +/** DataSource stub whose Route repository returns the given existing routes. */ +const serviceWith = ( + existing: Array<{ id: string; milestones: StopSeq }>, +): RoutesService => { + const dataSource = { + getRepository: () => ({ find: async () => existing }), + } as unknown as DataSource; + return new RoutesService(dataSource, {} as RoutesRepository); +}; + +const assertNotDuplicate = ( + service: RoutesService, + yardIds: string[], + excludeRouteId?: string, +): Promise => + ( + service as unknown as { + assertNotDuplicate: ( + m: Array<{ yardId: string }>, + id?: string, + ) => Promise; + } + ).assertNotDuplicate( + yardIds.map((yardId) => ({ yardId })), + excludeRouteId, + ); + +describe('RoutesService duplicate guard', () => { + const addisAdamaDire: StopSeq = [ + { yardId: 'addis', sequenceNo: 1 }, + { yardId: 'adama', sequenceNo: 2 }, + { yardId: 'dire', sequenceNo: 3 }, + ]; + + it('rejects an identical stop sequence', async () => { + const service = serviceWith([{ id: 'r1', milestones: addisAdamaDire }]); + + await expect( + assertNotDuplicate(service, ['addis', 'adama', 'dire']), + ).rejects.toBeInstanceOf(ConflictException); + }); + + it('allows the same endpoints with a different corridor', async () => { + // Same origin + destination, but skipping Adama is a genuinely other route. + const service = serviceWith([{ id: 'r1', milestones: addisAdamaDire }]); + + await expect( + assertNotDuplicate(service, ['addis', 'dire']), + ).resolves.toBeUndefined(); + }); + + it('does not flag the route being edited against itself', async () => { + const service = serviceWith([{ id: 'r1', milestones: addisAdamaDire }]); + + await expect( + assertNotDuplicate(service, ['addis', 'adama', 'dire'], 'r1'), + ).resolves.toBeUndefined(); + }); + + it('compares stops by sequence, not storage order', async () => { + const shuffled: StopSeq = [ + { yardId: 'dire', sequenceNo: 3 }, + { yardId: 'addis', sequenceNo: 1 }, + { yardId: 'adama', sequenceNo: 2 }, + ]; + const service = serviceWith([{ id: 'r1', milestones: shuffled }]); + + await expect( + assertNotDuplicate(service, ['addis', 'adama', 'dire']), + ).rejects.toBeInstanceOf(ConflictException); + }); +}); diff --git a/apps/edr-freight-api/src/modules/routes/routes.service.ts b/apps/edr-freight-api/src/modules/routes/routes.service.ts index 96e6c7fd1..855f8ec02 100644 --- a/apps/edr-freight-api/src/modules/routes/routes.service.ts +++ b/apps/edr-freight-api/src/modules/routes/routes.service.ts @@ -5,7 +5,7 @@ import { NotFoundException, } from '@nestjs/common'; import { TrainScheduleStatus } from '@edr/types'; -import { DataSource, In } from 'typeorm'; +import { DataSource, In, Not } from 'typeorm'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { Yard } from '../rule-engine/entities/yard.entity'; @@ -15,7 +15,7 @@ import { CreateRouteDto } from './dto/create-route.dto'; import { FilterRoutesDto } from './dto/filter-routes.dto'; import { UpdateRouteDto } from './dto/update-route.dto'; import { RouteMilestone } from './entities/route-milestone.entity'; -import { formatRouteLabel, Route } from './entities/route.entity'; +import { formatRouteLabel, Route, type RouteStatus } from './entities/route.entity'; import { RoutesRepository } from './routes.repository'; /** Order-insensitive key: distances are symmetric. */ @@ -88,6 +88,7 @@ export class RoutesService { async create(dto: CreateRouteDto): Promise { const validated = await this.validateMilestones(dto.milestones); + await this.assertNotDuplicate(validated.milestones); const route = await this.dataSource.transaction(async (manager) => { const savedRoute = await manager.getRepository(Route).save( @@ -123,6 +124,11 @@ export class RoutesService { ? await this.validateMilestones(dto.milestones) : null; + // An edit can collide with another route just as easily as a create can. + if (milestoneInput) { + await this.assertNotDuplicate(milestoneInput.milestones, id); + } + // Milestones or endpoints are about to be rewritten — reject if any // non-terminal schedule still references this route, otherwise its stop list // and distances would silently shift under a live plan. Status-only / @@ -187,6 +193,51 @@ export class RoutesService { return this.findById(id); } + /** + * A route IS its ordered stop list — "Addis → Adama → Dire Dawa" and + * "Addis → Dire Dawa" share endpoints but are different corridors. So the + * duplicate test compares the full yard sequence, not just origin/destination. + * + * Decommissioned routes (STOP_WORKING) are ignored: replacing a retired + * corridor with a fresh one is exactly what an admin does after deactivating, + * and there is no reactivate action to fall back on. + */ + private async assertNotDuplicate( + milestones: Array<{ yardId: string }>, + excludeRouteId?: string, + ): Promise { + const signature = milestones.map((m) => m.yardId).join('>'); + + const candidates = await this.dataSource.getRepository(Route).find({ + where: { + originYardId: milestones[0].yardId, + destinationYardId: milestones[milestones.length - 1].yardId, + status: Not('STOP_WORKING'), + }, + relations: { + originYard: true, + destinationYard: true, + milestones: { yard: true }, + }, + }); + + const duplicate = candidates.find((route) => { + if (route.id === excludeRouteId) return false; + const stops = [...(route.milestones ?? [])] + .sort((a, b) => a.sequenceNo - b.sequenceNo) + .map((m) => m.yardId) + .join('>'); + return stops === signature; + }); + + if (duplicate) { + throw new ConflictException( + `This route already exists: ${formatRouteLabel(duplicate)}. ` + + 'Edit the existing route instead of creating a duplicate.', + ); + } + } + private async validateMilestones(milestones: Array<{ yardId: string }>) { if (milestones.length < 2) { throw new BadRequestException('A route requires at least two yards'); diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.spec.ts new file mode 100644 index 000000000..e44dcdb5f --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.spec.ts @@ -0,0 +1,70 @@ +import { allowedRateUnits, isBulkQuantityUnit } from "./rate-unit.util"; + +/** + * A bulk rate's weighting unit follows how its commodity is counted: wheat is + * weighed (per ton), machinery is counted (per item). Per-wagon is offered + * either way. + */ +describe("allowedRateUnits — bulk unit of measure", () => { + it("offers per-ton for a weighed commodity", () => { + expect( + allowedRateUnits({ + appliesTo: "BULK", + trigger: "ALWAYS", + cargoUnitOfMeasure: "PER_TON", + }), + ).toEqual(["PER_TON", "PER_WAGON"]); + }); + + it("offers per-item for a counted commodity", () => { + expect( + allowedRateUnits({ + appliesTo: "BULK", + trigger: "ALWAYS", + cargoUnitOfMeasure: "PER_ITEM", + }), + ).toEqual(["PER_ITEM", "PER_WAGON"]); + }); + + it("falls back to per-ton when the rate is not scoped to a commodity", () => { + expect(allowedRateUnits({ appliesTo: "BULK", trigger: "ALWAYS" })).toEqual([ + "PER_TON", + "PER_WAGON", + ]); + }); + + it("swaps the per-ton slot for counted commodities on every bulk-capable shape", () => { + expect( + allowedRateUnits({ + appliesTo: "OTHER", + trigger: "CUSTOMS_CLEARANCE", + cargoKind: "BULK", + cargoUnitOfMeasure: "PER_ITEM", + }), + ).toEqual(["PER_ITEM", "PER_WAGON"]); + expect( + allowedRateUnits({ + appliesTo: "INTERCITY", + trigger: "ALWAYS", + cargoUnitOfMeasure: "PER_ITEM", + }), + ).toEqual(["PER_CONTAINER", "PER_ITEM", "PER_WAGON", "PER_KM"]); + }); + + it("never offers per-item for overweight, which is always per excess ton", () => { + expect( + allowedRateUnits({ + appliesTo: "OTHER", + trigger: "OVERWEIGHT", + cargoUnitOfMeasure: "PER_TON", + }), + ).toEqual(["PER_TON"]); + }); + + it("treats per-ton and per-item as the same booking quantity", () => { + expect(isBulkQuantityUnit("PER_TON")).toBe(true); + expect(isBulkQuantityUnit("PER_ITEM")).toBe(true); + expect(isBulkQuantityUnit("PER_WAGON")).toBe(false); + expect(isBulkQuantityUnit("FLAT")).toBe(false); + }); +}); diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts index 3519a0c06..1de36bcfd 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts @@ -1,5 +1,17 @@ import type { RateAppliesTo, RateTrigger, RateUnit } from './rate.entity'; +/** How the bulk commodity a rate is scoped to is counted (cargo_types.unit_of_measure). */ +export type CargoUom = 'PER_TON' | 'PER_ITEM' | null | undefined; + +/** + * Units billed against a booking's bulk quantity. That quantity is recorded in + * the commodity's own unit — tonnes for a PER_TON commodity, item count for a + * PER_ITEM one — so both units scale off the same field and only differ in what + * they are called. + */ +export const isBulkQuantityUnit = (unit: string): boolean => + unit === 'PER_TON' || unit === 'PER_ITEM'; + /** * Which rate units make sense for a given rate shape. The weighting basis is * driven by the *type* of thing being billed — a container leg bills per @@ -8,6 +20,10 @@ import type { RateAppliesTo, RateTrigger, RateUnit } from './rate.entity'; * ton. This keeps the rate table dynamic yet non-conflicting: the admin can * only pick a unit the pricing engine knows how to apply. * + * A rate scoped to a break-bulk commodity (unit_of_measure = PER_ITEM) offers + * PER_ITEM wherever a weighed commodity offers PER_TON — machinery is priced + * per unit shipped, wheat per tonne. Per-wagon is offered either way. + * * Returned lists are ordered with the most natural/default unit first. */ export function allowedRateUnits(input: { @@ -15,6 +31,19 @@ export function allowedRateUnits(input: { trigger: RateTrigger; /** CUSTOMS_CLEARANCE only: which cargo kind the fee covers. */ cargoKind?: 'CONTAINER' | 'BULK' | null; + /** Unit of measure of the bulk commodity the rate is scoped to, when any. */ + cargoUnitOfMeasure?: CargoUom; +}): RateUnit[] { + const units = unitsForShape(input); + return input.cargoUnitOfMeasure === 'PER_ITEM' + ? units.map((u) => (u === 'PER_TON' ? 'PER_ITEM' : u)) + : units; +} + +function unitsForShape(input: { + appliesTo: RateAppliesTo; + trigger: RateTrigger; + cargoKind?: 'CONTAINER' | 'BULK' | null; }): RateUnit[] { const { appliesTo, trigger } = input; @@ -81,6 +110,7 @@ export function isRateUnitAllowed(input: { appliesTo: RateAppliesTo; trigger: RateTrigger; cargoKind?: 'CONTAINER' | 'BULK' | null; + cargoUnitOfMeasure?: CargoUom; unit: RateUnit; }): boolean { return allowedRateUnits(input).includes(input.unit); diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts index cd2a6e14b..d62d5647f 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts @@ -34,6 +34,9 @@ export type RateStatus = typeof RATE_STATUSES[number]; export const RATE_UNITS = [ 'PER_WAGON', 'PER_TON', + // Break-bulk commodities are counted, not weighed (cargo_types.unit_of_measure + // = PER_ITEM) — their rates bill per item off the same booking quantity field. + 'PER_ITEM', 'PER_CONTAINER', 'PER_KM', 'PER_INVOICE', diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts index 76203edef..b3ee54d20 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts @@ -14,7 +14,8 @@ export interface IRatesRepository { findLiveRatesDetailed(): Promise; findByPattern(pattern: { rateType: string; - rateUnit: string; + /** Omitted for singly-resolved rates — see the repository implementation. */ + rateUnit?: string; containerTypeId?: string | null; cargoTypeId?: string | null; tradeDirection?: string | null; diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts index bdec72e46..abd10cd98 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts @@ -18,10 +18,17 @@ export class RatesRepository implements IRatesRepository { return this.repo.findOne({ where: { id } }); } + /** + * Every LIVE rate, newest first. The ordering is load-bearing: pricing picks + * the first match for a pattern, so without it Postgres heap order decided + * which of two overlapping rates a booking was billed at. Newest-first also + * means the most recent configuration wins where legacy overlaps still exist. + */ findLiveRates(): Promise { return this.repo .createQueryBuilder('rate') .where('rate.status = :status', { status: 'LIVE' }) + .orderBy('rate.created_at', 'DESC') .getMany(); } @@ -47,9 +54,21 @@ export class RatesRepository implements IRatesRepository { * insert so the admin gets a friendly error instead of a raw constraint fault. * NULL scope columns are matched with IS NULL, mirroring the COALESCE index. */ + /** + * The live/draft rate already covering a pricing pattern, if any. + * + * `rateUnit` is optional on purpose. Where pricing resolves ONE rate for a + * lane (base freight, customs, lashing, empty return) the unit is not part of + * the identity — a per-container and a per-wagon row for the same lane are + * two answers to one question and the engine picks whichever came back first, + * so the caller omits it and the second row is rejected. Additive surcharges + * (hazard, reefer, demurrage…) are the opposite: the engine bills every + * matching rate by its own unit, so one per freight shape is the design and + * the caller passes the unit to keep them apart. + */ findByPattern(pattern: { rateType: string; - rateUnit: string; + rateUnit?: string; containerTypeId?: string | null; cargoTypeId?: string | null; tradeDirection?: string | null; @@ -59,9 +78,12 @@ export class RatesRepository implements IRatesRepository { const qb = this.repo .createQueryBuilder('rate') .where('rate.rate_type = :rateType', { rateType: pattern.rateType }) - .andWhere('rate.rate_unit = :rateUnit', { rateUnit: pattern.rateUnit }) .andWhere('rate.status <> :superseded', { superseded: 'SUPERSEDED' }); + if (pattern.rateUnit) { + qb.andWhere('rate.rate_unit = :rateUnit', { rateUnit: pattern.rateUnit }); + } + if (pattern.containerTypeId) { qb.andWhere('rate.container_type_id = :containerTypeId', { containerTypeId: pattern.containerTypeId }); } else { diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index 3ad97bb53..ac2e854e4 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -2,6 +2,7 @@ import { Inject, Injectable, BadRequestException } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.entity'; import { Rate, RateTrigger } from './entities/rate.entity'; +import { isBulkQuantityUnit } from './entities/rate-unit.util'; import { ICargoTypesRepository, CARGO_TYPES_REPOSITORY, @@ -377,6 +378,9 @@ export class RuleEngineService { let calculatedAmount: number; switch (rate.rateUnit) { + // PER_ITEM is PER_TON for a counted (break-bulk) commodity — the bulk + // quantity is recorded in the commodity's own unit either way. + case 'PER_ITEM': case 'PER_TON': // OVERWEIGHT bills the excess tons; every other PER_TON surcharge // (e.g. bulk reefer) bills the full bulk tonnage. @@ -608,7 +612,7 @@ export class RuleEngineService { if (!rate) return modifiers; const billedQty = - rate.rateUnit === 'PER_TON' + isBulkQuantityUnit(rate.rateUnit) ? Math.max(0, Number(input.bulkTons ?? 0)) : rate.rateUnit === 'PER_WAGON' ? Math.max(0, Number(input.bulkWagons ?? 0)) diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.duplicate-pattern.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.duplicate-pattern.spec.ts new file mode 100644 index 000000000..23d96da89 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.duplicate-pattern.spec.ts @@ -0,0 +1,138 @@ +import { ConflictException } from '@nestjs/common'; + +import { RatesService } from './rates.service'; +import type { Rate } from '../entities/rate.entity'; + +/** + * One rate per lane + scope, whatever the unit. + * + * Pricing resolves a single rate for a (type, container type, leg) and then + * applies whatever unit it carries — it has no way to choose between a + * per-container and a per-wagon row for the same 20ft lane, and used to bill + * whichever the database happened to return first. So the unit is NOT part of a + * rate's identity: changing how a lane is billed means editing its rate. + */ +describe('RatesService — one rate per pattern', () => { + const DJ = '11111111-1111-4000-8000-000000000001'; + const ET = '11111111-1111-4000-8000-000000000002'; + const CT20 = '11111111-1111-4000-8000-000000000003'; + + const existing = (over: Partial = {}): Rate => + ({ + id: 'rate-existing', + rateType: 'CONTAINER_IMPORT', + rateUnit: 'PER_WAGON', + rateValue: 1690, + containerTypeId: CT20, + cargoTypeId: null, + tradeDirection: 'IMPORT', + originYardId: DJ, + destinationYardId: ET, + status: 'LIVE', + ...over, + }) as Rate; + + const dto = { + appliesTo: 'CONTAINER', + trigger: 'ALWAYS', + tradeDirection: 'IMPORT', + containerTypeId: CT20, + originYardId: DJ, + destinationYardId: ET, + rateValue: 845, + rateUnit: 'PER_CONTAINER', + }; + + let repository: { findByPattern: jest.Mock; create: jest.Mock }; + let service: RatesService; + + beforeEach(() => { + repository = { + findByPattern: jest.fn().mockResolvedValue(null), + create: jest.fn(async (r) => ({ id: 'rate-new', ...r })), + }; + service = new RatesService( + repository as never, + { + findById: jest.fn(async (id: string) => ({ + id, + country: id === DJ ? 'Djibouti' : 'Ethiopia', + label: id === DJ ? 'Doraleh' : 'Gelan', + })), + } as never, + { findById: jest.fn().mockResolvedValue(null) } as never, + ); + }); + + it('refuses a second rate on the same lane that only differs by unit', async () => { + repository.findByPattern.mockResolvedValue(existing()); + + await expect(service.create(dto as never, 'staff-1')).rejects.toBeInstanceOf( + ConflictException, + ); + expect(repository.create).not.toHaveBeenCalled(); + }); + + it('looks the pattern up without the unit, so either order collides', async () => { + await service.create(dto as never, 'staff-1'); + + const pattern = repository.findByPattern.mock.calls[0][0]; + expect(pattern).not.toHaveProperty('rateUnit'); + expect(pattern).toMatchObject({ + rateType: 'CONTAINER_IMPORT', + containerTypeId: CT20, + originYardId: DJ, + destinationYardId: ET, + }); + }); + + it('still allows the same unit on a different lane', async () => { + await service.create(dto as never, 'staff-1'); + expect(repository.create).toHaveBeenCalledWith( + expect.objectContaining({ + rateUnit: 'PER_CONTAINER', + rateValue: 845, + status: 'DRAFT', + }), + ); + }); + + /** + * Additive surcharges are billed per matching rate, each by its own unit, so + * hazard is legitimately per-container for boxes AND per-ton for bulk. The + * unit stays part of their identity or the second one could never be created. + */ + it('keeps the unit in the key for an additive surcharge', async () => { + await service.create( + { + appliesTo: 'OTHER', + trigger: 'HAZARDOUS', + rateValue: 300, + rateUnit: 'PER_CONTAINER', + } as never, + 'staff-1', + ); + + expect(repository.findByPattern.mock.calls[0][0]).toMatchObject({ + rateType: 'HAZARD_SURCHARGE', + rateUnit: 'PER_CONTAINER', + }); + }); + + it('treats lashing as singly resolved — one unit per direction', async () => { + await service.create( + { + appliesTo: 'OTHER', + trigger: 'LASHING', + tradeDirection: 'IMPORT', + rateValue: 40, + rateUnit: 'PER_TON', + } as never, + 'staff-1', + ); + + expect(repository.findByPattern.mock.calls[0][0]).not.toHaveProperty( + 'rateUnit', + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index 700d6366c..d8ceb97ab 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -12,7 +12,11 @@ import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto'; import { UpdateRateDto } from '../dto/update-rate.dto'; import { Rate } from '../entities/rate.entity'; import { deriveRateType } from '../entities/rate-type.util'; -import { allowedRateUnits, isRateUnitAllowed } from '../entities/rate-unit.util'; +import { CargoUom, allowedRateUnits, isRateUnitAllowed } from '../entities/rate-unit.util'; +import { + CARGO_TYPES_REPOSITORY, + ICargoTypesRepository, +} from '../interfaces/cargo-types.repository.interface'; import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface'; import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface'; @@ -32,6 +36,8 @@ export class RatesService { private readonly repository: IRatesRepository, @Inject(YARDS_REPOSITORY) private readonly yardsRepository: IYardsRepository, + @Inject(CARGO_TYPES_REPOSITORY) + private readonly cargoTypesRepository: ICargoTypesRepository, ) {} /** List rates — standard paginated envelope with server-side search. */ @@ -63,25 +69,37 @@ export class RatesService { * Normalise + validate the weighting unit for a rate shape. Overweight is * always billed per excess ton, so its unit is forced to PER_TON regardless * of what the client sent. Every other shape must pick a unit the pricing - * engine can actually apply (see `allowedRateUnits`). + * engine can actually apply (see `allowedRateUnits`) — for a rate scoped to a + * bulk commodity that means the commodity's own unit of measure: a PER_ITEM + * commodity bills per item where a weighed one bills per ton. */ - private resolveRateUnit( + private async resolveRateUnit( appliesTo: Rate['appliesTo'], trigger: Rate['trigger'], requestedUnit: Rate['rateUnit'] | undefined, cargoKind?: 'CONTAINER' | 'BULK' | null, - ): Rate['rateUnit'] { + cargoTypeId?: string | null, + ): Promise { // Overweight is per-ton, full stop — the admin form hides the unit field // for it and omits rateUnit from the payload entirely. if (trigger === 'OVERWEIGHT') return 'PER_TON'; - const allowed = allowedRateUnits({ appliesTo, trigger, cargoKind }); + const cargoUnitOfMeasure = await this.cargoUnitOfMeasure(cargoTypeId); + const allowed = allowedRateUnits({ appliesTo, trigger, cargoKind, cargoUnitOfMeasure }); if (!requestedUnit) { throw new BadRequestException( `Pick a rate unit for this rate. Allowed: ${allowed.join(', ')}.`, ); } - if (!isRateUnitAllowed({ appliesTo, trigger, cargoKind, unit: requestedUnit })) { + if ( + !isRateUnitAllowed({ + appliesTo, + trigger, + cargoKind, + cargoUnitOfMeasure, + unit: requestedUnit, + }) + ) { throw new BadRequestException( `Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed.join(', ')}.`, ); @@ -89,6 +107,13 @@ export class RatesService { return requestedUnit; } + /** Unit of measure of the bulk commodity a rate is scoped to; null when unscoped. */ + private async cargoUnitOfMeasure(cargoTypeId?: string | null): Promise { + if (!cargoTypeId) return null; + const cargo = await this.cargoTypesRepository.findById(cargoTypeId); + return cargo?.unitOfMeasure ?? null; + } + /** Base rail freight is priced per leg; surcharges and truck legs are not. */ private isBaseFreight(appliesTo: Rate['appliesTo'], trigger: Rate['trigger']): boolean { return trigger === 'ALWAYS' && BASE_FREIGHT_CATEGORIES.includes(appliesTo); @@ -107,6 +132,24 @@ export class RatesService { ); } + /** + * True when pricing resolves exactly ONE rate for this shape (base freight, + * customs clearance, lashing, empty-container return — all `find()`-based + * lookups). For those the unit is not part of the rate's identity: two rows + * for the same lane differing only by unit are a duplicate the engine cannot + * choose between. + * + * The additive surcharges are the opposite — the engine bills EVERY matching + * rate by its own unit, which is how hazard can be per-container for boxes + * and per-ton for bulk at the same time — so their unit stays part of the key. + */ + private resolvesSingleRate( + appliesTo: Rate['appliesTo'], + trigger: Rate['trigger'], + ): boolean { + return this.isRouteScoped(appliesTo, trigger) || trigger === 'LASHING'; + } + /** * Which country each end of the leg must sit in, given what the rate is for. * The railway only sells three shapes: import lands at the Djibouti ports and @@ -301,10 +344,17 @@ export class RatesService { * Reject a second rate with the same identity pattern (rateType + scope). With * effective-date windows gone, two LIVE/DRAFT rates for the same pattern would * make pricing ambiguous — so we allow exactly one per pattern. + * + * The UNIT is not part of that identity. Pricing resolves one rate per lane + + * scope and then applies whatever unit it carries; a per-container and a + * per-wagon row for the same 20ft lane are two answers to one question, and + * the engine silently picked one of them. Changing how a lane is billed means + * editing its rate, not adding a second. */ private async assertNoDuplicatePattern(pattern: { rateType: string; - rateUnit: string; + /** Passed only for additive surcharges — see {@link resolvesSingleRate}. */ + rateUnit?: string; containerTypeId: string | null; cargoTypeId: string | null; tradeDirection: string | null; @@ -380,16 +430,17 @@ export class RatesService { tradeDirection, isBulk: this.resolvesToBulk(appliesTo, intercityKind), }); - const rateUnit = this.resolveRateUnit( + const rateUnit = await this.resolveRateUnit( appliesTo, trigger, dto.rateUnit as Rate['rateUnit'] | undefined, cargoKind, + cargoTypeId, ); await this.assertNoDuplicatePattern({ rateType, - rateUnit, + ...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }), containerTypeId, cargoTypeId, tradeDirection, @@ -562,12 +613,19 @@ export class RatesService { // Re-validate the unit against the (possibly changed) shape; overweight is // forced to PER_TON. const requestedUnit = (dto.rateUnit as Rate['rateUnit']) ?? existing.rateUnit; - updates.rateUnit = this.resolveRateUnit(appliesTo, trigger, requestedUnit, cargoKind); + const rateUnit = await this.resolveRateUnit( + appliesTo, + trigger, + requestedUnit, + cargoKind, + updates.cargoTypeId, + ); + updates.rateUnit = rateUnit; // Guard the pattern uniqueness for the new identity, ignoring this row. await this.assertNoDuplicatePattern({ rateType, - rateUnit: updates.rateUnit, + ...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }), containerTypeId: updates.containerTypeId, cargoTypeId: updates.cargoTypeId, tradeDirection: updates.tradeDirection, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 591812880..e010b4db9 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -3000,6 +3000,22 @@ export class BookingBatchService implements OnModuleInit { } } + /** + * How many bookings on this route-day would be expired if document review + * ended right now — i.e. requests staff have neither accepted nor rejected. + * Same query the doc-review-end sweep runs, so the number staff see is + * exactly what is at risk. + */ + async countUnacceptedForRouteDay(group: RouteDayGroup): Promise { + const corridorYards = await this.corridorYardsForRouteDay(group); + if (corridorYards.length === 0) return 0; + const unaccepted = await this.bookingsRepository.findUnacceptedForRouteDay( + corridorYards, + group.day, + ); + return unaccepted.length; + } + /** * Free capacity for a government booking by displacing the lowest-priority commercial * bookings (reserved first, then allocated — including PAID). Displaced → EXPIRED + notified. diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts index abd07c129..79b279393 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts @@ -22,6 +22,7 @@ describe('BookingWindowService — window state machine', () => { expireLeftoverDayPool: jest.Mock; expireLeftoverExportDay: jest.Mock; fillFromWaitingList: jest.Mock; + countUnacceptedForRouteDay: jest.Mock; }; let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock }; let trainSchedulingService: { finalizeSchedule: jest.Mock; getWindowConfig: jest.Mock }; @@ -79,6 +80,7 @@ describe('BookingWindowService — window state machine', () => { expireLeftoverExportDay: jest.fn().mockResolvedValue(undefined), // No waiting booking fits by default, so conclude proceeds to reopen/DONE. fillFromWaitingList: jest.fn().mockResolvedValue(0), + countUnacceptedForRouteDay: jest.fn().mockResolvedValue(0), }; trainSchedulesRepository = { findById: jest.fn().mockResolvedValue(null), @@ -267,4 +269,87 @@ describe('BookingWindowService — window state machine', () => { expect(s.windowPhase).toBe('OPEN'); expect(batch.setWindow).not.toHaveBeenCalled(); }); + + // ---- header alarm --------------------------------------------------------- + + describe('getDocReviewAlert', () => { + const reviewing = (over: Partial): TrainSchedule => + baseSchedule({ + windowPhase: 'DOC_REVIEW', + docReviewEndsAt: new Date('2026-07-01T01:30:00.000Z'), + ...over, + }); + + it('returns null when nothing is under document review', async () => { + trainSchedulesRepository.findAll.mockResolvedValue([ + baseSchedule({ windowPhase: 'OPEN' }), + ]); + expect(await service.getDocReviewAlert()).toBeNull(); + }); + + it('returns null when every request on the route-day is decided', async () => { + trainSchedulesRepository.findAll.mockResolvedValue([reviewing({})]); + batch.countUnacceptedForRouteDay.mockResolvedValue(0); + expect(await service.getDocReviewAlert()).toBeNull(); + }); + + it('reports the deadline, its own pending count and the phase length', async () => { + trainSchedulesRepository.findAll.mockResolvedValue([reviewing({})]); + batch.countUnacceptedForRouteDay.mockResolvedValue(3); + + const alert = await service.getDocReviewAlert(); + + expect(alert).toMatchObject({ + scheduleId, + originYardId: 'yard-o', + destinationYardId: 'yard-d', + tradeDirection: 'IMPORT', + pendingCount: 3, + docReviewMinutes: 30, + docReviewEndsAt: '2026-07-01T01:30:00.000Z', + }); + }); + + it('skips the nearest deadline when it has nothing pending', async () => { + trainSchedulesRepository.findAll.mockResolvedValue([ + reviewing({ + id: 'sched-later', + destinationStationId: 'yard-far', + docReviewEndsAt: new Date('2026-07-01T02:00:00.000Z'), + }), + reviewing({ id: 'sched-soon' }), + ]); + // Nearest (sched-soon, yard-d) is clear; the later route-day still isn't. + batch.countUnacceptedForRouteDay.mockImplementation( + async (g: { destinationYardId: string }) => + g.destinationYardId === 'yard-far' ? 2 : 0, + ); + + const alert = await service.getDocReviewAlert(); + + expect(alert?.scheduleId).toBe('sched-later'); + expect(alert?.pendingCount).toBe(2); + }); + + it('counts a route-day once when sibling trains share the review phase', async () => { + trainSchedulesRepository.findAll.mockResolvedValue([ + reviewing({ id: 'sched-a' }), + reviewing({ id: 'sched-b' }), + ]); + batch.countUnacceptedForRouteDay.mockResolvedValue(4); + + const alert = await service.getDocReviewAlert(); + + expect(alert?.pendingCount).toBe(4); + expect(batch.countUnacceptedForRouteDay).toHaveBeenCalledTimes(1); + }); + + it('ignores a phase staff already completed early', async () => { + trainSchedulesRepository.findAll.mockResolvedValue([ + reviewing({ docReviewCompletedAt: new Date('2026-07-01T01:10:00.000Z') }), + ]); + batch.countUnacceptedForRouteDay.mockResolvedValue(5); + expect(await service.getDocReviewAlert()).toBeNull(); + }); + }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index 3b9bb25d3..5ae751164 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -30,6 +30,28 @@ import { } from './batch-window.util'; import { type BookingWindowConfig } from './booking-window.config'; +/** + * The most urgent document-review deadline that still has un-accepted booking + * requests behind it. Backoffice counts down to it and warns staff, because + * everything still pending when the phase ends is expired automatically. + */ +export interface DocReviewAlert { + /** A schedule of the route-day group under review (deep-link target). */ + scheduleId: string; + originYardId: string; + destinationYardId: string; + /** EAT booking day of the group, YYYY-MM-DD. */ + day: string; + /** IMPORT (the usual) or DOMESTIC — both run a review phase; export does not. */ + tradeDirection: string; + /** ISO deadline the review phase ends at. */ + docReviewEndsAt: string; + /** Full length of the review phase — the client warns past its halfway mark. */ + docReviewMinutes: number; + /** Requests neither accepted nor rejected — they expire at the deadline. */ + pendingCount: number; +} + /** * Drives the one-booking-day window cycle for IMPORT schedules and the FCFS * booking window for EXPORT schedules. All state lives in DB timestamps on the @@ -130,6 +152,65 @@ export class BookingWindowService implements OnModuleInit { } } + /** + * The route-day currently in document review whose deadline is nearest and + * which still has un-accepted requests. Null when nothing is under review or + * every request has been decided — the backoffice header shows nothing then. + * + * One card, one deadline, one count: route-days are checked in deadline order + * and the first with pending work wins, so the number always belongs to the + * clock beside it. + */ + async getDocReviewAlert(): Promise { + const reviewing = ( + await this.trainSchedulesRepository.findAll({ + where: [ + { status: TrainScheduleStatusEnum.Draft }, + { status: TrainScheduleStatusEnum.Scheduled }, + ], + }) + ) + .filter( + (s) => + s.windowPhase === 'DOC_REVIEW' && + s.docReviewCompletedAt == null && + s.docReviewEndsAt != null && + s.scheduledDepartureDate != null, + ) + .sort((a, b) => a.docReviewEndsAt!.getTime() - b.docReviewEndsAt!.getTime()); + if (reviewing.length === 0) return null; + + const liveCfg = await this.trainSchedulingService.getWindowConfig(); + const seen = new Set(); + for (const schedule of reviewing) { + const group = { + originYardId: schedule.originStationId, + destinationYardId: schedule.destinationStationId, + day: eatDay(schedule.scheduledDepartureDate), + }; + // Sibling trains share one review phase for the route-day pool — count it once. + const key = `${group.originYardId}|${group.destinationYardId}|${group.day}`; + if (seen.has(key)) continue; + seen.add(key); + + const pendingCount = + await this.bookingBatchService.countUnacceptedForRouteDay(group); + if (pendingCount === 0) continue; + + return { + scheduleId: schedule.id, + ...group, + // Carried so the backoffice list opens on the same direction the + // at-risk requests belong to (import corridor, or a domestic day). + tradeDirection: schedule.direction ?? 'IMPORT', + docReviewEndsAt: schedule.docReviewEndsAt!.toISOString(), + docReviewMinutes: effectiveWindowConfig(schedule, liveCfg).docReviewMinutes, + pendingCount, + }; + } + return null; + } + /** Staff finished document review early — start the batch/payment phase now. */ async completeDocReview(scheduleId: string): Promise { const schedule = await this.trainSchedulesRepository.findById(scheduleId); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/consist-order.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/consist-order.util.spec.ts new file mode 100644 index 000000000..44fff49e8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/consist-order.util.spec.ts @@ -0,0 +1,94 @@ +import { orderConsistWagons } from './consist-order.util'; + +// Built train: A-B-C-D coupled in that order. Slots are created by the wagon +// PLAN, so their sequenceNo says nothing about where the wagon actually sits. +const TRAIN = ['A', 'B', 'C', 'D']; + +const slot = (sequenceNo: number, physicalWagonId: string | null) => ({ + sequenceNo, + physicalWagonId, +}); + +describe('orderConsistWagons', () => { + it('draws slots in the train coupling order, not slot order', () => { + // Plan order says D then B; the train says B sits ahead of D. + const drawn = orderConsistWagons([slot(1, 'D'), slot(2, 'B')], { + physicalWagonIdsInOrder: TRAIN, + }); + + expect(drawn.map((w) => w.physicalWagonId)).toEqual(['B', 'D']); + expect(drawn.map((w) => w.position)).toEqual([1, 2]); + }); + + it('interleaves empty consist wagons in their real place', () => { + // Loaded slots on A and C; B and D ride along empty. The empties used to be + // appended after every loaded slot, so the drawing was never the train. + const drawn = orderConsistWagons( + [slot(1, 'A'), slot(2, 'C'), slot(98, 'B'), slot(99, 'D')], + { physicalWagonIdsInOrder: TRAIN }, + ); + + expect(drawn.map((w) => w.physicalWagonId)).toEqual(['A', 'B', 'C', 'D']); + }); + + it('keeps every wagon in place when a load moves between wagons', () => { + // Load sat on A (slot 1); staff drag it onto empty D. The move repins the + // slot, so the SAME slot now reads as wagon D and A falls back to empty. + const before = orderConsistWagons([slot(1, 'A'), slot(98, 'D')], { + physicalWagonIdsInOrder: TRAIN, + }); + const after = orderConsistWagons([slot(1, 'D'), slot(98, 'A')], { + physicalWagonIdsInOrder: TRAIN, + }); + + // A is drawn first and D last, before and after — the train did not shuffle. + expect(before.map((w) => w.physicalWagonId)).toEqual(['A', 'D']); + expect(after.map((w) => w.physicalWagonId)).toEqual(['A', 'D']); + }); + + it('follows a train-builder reorder without touching any slot row', () => { + const slots = [slot(1, 'A'), slot(2, 'B')]; + + // Builder swaps the coupling order; the slots are untouched. + const drawn = orderConsistWagons(slots, { + physicalWagonIdsInOrder: ['B', 'A', 'C', 'D'], + }); + + expect(drawn.map((w) => w.physicalWagonId)).toEqual(['B', 'A']); + }); + + it('draws back-to-front when the caller reverses the train', () => { + const drawn = orderConsistWagons([slot(1, 'A'), slot(2, 'C')], { + physicalWagonIdsInOrder: [...TRAIN].reverse(), + reverseWagonOrder: true, + }); + + expect(drawn.map((w) => w.physicalWagonId)).toEqual(['C', 'A']); + }); + + it('parks unpinned slots last, in slot order', () => { + const drawn = orderConsistWagons([slot(9, null), slot(4, null), slot(1, 'C')], { + physicalWagonIdsInOrder: TRAIN, + }); + + expect(drawn.map((w) => [w.physicalWagonId, w.sequenceNo])).toEqual([ + ['C', 1], + [null, 4], + [null, 9], + ]); + }); + + it('falls back to slot order when there is no built train', () => { + // Frozen schedules and loose-wagon schedules pass no physical order. + const drawn = orderConsistWagons([slot(2, 'X'), slot(1, 'Y')], { + physicalWagonIdsInOrder: [], + }); + expect(drawn.map((w) => w.sequenceNo)).toEqual([1, 2]); + + const reversed = orderConsistWagons([slot(1, 'X'), slot(2, 'Y')], { + physicalWagonIdsInOrder: [], + reverseWagonOrder: true, + }); + expect(reversed.map((w) => w.sequenceNo)).toEqual([2, 1]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/consist-order.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/consist-order.util.ts new file mode 100644 index 000000000..496b91952 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/consist-order.util.ts @@ -0,0 +1,54 @@ +/** + * Draw order for a schedule's consist. + * + * A slot's stored `sequenceNo` is its place in the wagon PLAN, not its place in + * the train. The train's real coupling order lives on the physical wagons + * (`wagons.sequence_number`), which the caller passes in already ordered — ASC + * normally, DESC for a `reverseWagonOrder` schedule. + * + * Ordering by the physical wagon is what keeps the drawing honest: + * - moving a load between wagons repaints WHICH wagon is loaded and never + * shuffles the train, because each slot is drawn wherever its wagon sits; + * - a train-builder reorder lands on the next read, allocations included, + * since the order is derived on every read instead of copied at pin time. + * + * Slots with no physical wagon (not pinned yet, or a schedule that isn't tied + * to a built train) have no place in the consist — they keep slot order, last. + */ +export interface ConsistOrderable { + sequenceNo: number; + physicalWagonId?: string | null; +} + +export interface ConsistOrderOptions { + /** + * Every wagon coupled to the built train, in real coupling order (already + * reversed by the caller for a `reverseWagonOrder` schedule). Empty for a + * frozen schedule or one with no built train — the consist then keeps slot + * order. + */ + physicalWagonIdsInOrder: string[]; + reverseWagonOrder?: boolean; +} + +export const orderConsistWagons = ( + wagons: T[], + { physicalWagonIdsInOrder, reverseWagonOrder }: ConsistOrderOptions, +): (T & { position: number })[] => { + const physicalOrder = new Map(physicalWagonIdsInOrder.map((id, index) => [id, index])); + const bySlotSequence = (a: T, b: T) => + reverseWagonOrder ? b.sequenceNo - a.sequenceNo : a.sequenceNo - b.sequenceNo; + + const ordered = physicalOrder.size + ? [...wagons].sort((a, b) => { + const ai = a.physicalWagonId ? physicalOrder.get(a.physicalWagonId) : undefined; + const bi = b.physicalWagonId ? physicalOrder.get(b.physicalWagonId) : undefined; + if (ai == null && bi == null) return bySlotSequence(a, b); + if (ai == null) return 1; + if (bi == null) return -1; + return ai - bi; + }) + : [...wagons].sort(bySlotSequence); + + return ordered.map((wagon, index) => ({ ...wagon, position: index + 1 })); +}; 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.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index a05286cb3..b7e9a750f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -8,6 +8,7 @@ import { } from "@nestjs/common"; import { CurrentUser } from "@edr/api-common"; import { + BookingDocReviewAlert, TrainSchedulingCancel, TrainSchedulingCreate, TrainSchedulingReschedule, @@ -747,6 +748,18 @@ export class TrainSchedulingController { return this.trainSchedulingService.getContainerTrainScheduleById(id); } + @Get("doc-review-alert") + // Dedicated permission, not scheduling or bookings:view — the alarm is meant + // for the position types that actually decide operation requests. + @BookingDocReviewAlert() + @ApiOperation({ + summary: + "Nearest document-review deadline that still has un-accepted booking requests behind it (null when there is none) — drives the backoffice header countdown", + }) + async getDocReviewAlert() { + return this.bookingWindowService.getDocReviewAlert(); + } + @Post("schedules/:id/doc-review-complete") @TrainSchedulingUpdate() @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index 6757a6e21..0d724a040 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -1133,7 +1133,12 @@ describe('TrainSchedulingService', () => { let slotB: Record; let allocsByWagon: Record>>; let allocRepo: { find: jest.Mock; update: jest.Mock }; - let slotRepo: { update: jest.Mock }; + let slotRepo: { + update: jest.Mock; + create: jest.Mock; + save: jest.Mock; + createQueryBuilder: jest.Mock; + }; let wagonRepo: { findOne: jest.Mock }; const makeSchedule = (over: Record = {}) => ({ @@ -1147,6 +1152,7 @@ describe('TrainSchedulingService', () => { beforeEach(() => { slotA = { id: 'wA', + trainSetId: 'ts-1', sequenceNo: 1, capacityTons: 61, lengthMeters: 14, @@ -1158,6 +1164,7 @@ describe('TrainSchedulingService', () => { }; slotB = { id: 'wB', + trainSetId: 'ts-1', sequenceNo: 2, capacityTons: 61, lengthMeters: 14, @@ -1186,7 +1193,18 @@ describe('TrainSchedulingService', () => { ), update: jest.fn().mockResolvedValue(undefined), }; - slotRepo = { update: jest.fn().mockResolvedValue(undefined) }; + slotRepo = { + update: jest.fn().mockResolvedValue(undefined), + create: jest.fn((row: Record) => row), + save: jest.fn((row: Record) => + Promise.resolve({ id: 'slot-new', ...row }), + ), + createQueryBuilder: jest.fn(() => ({ + select: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue({ maxSequenceNo: 2 }), + })), + }; wagonRepo = { findOne: jest.fn().mockResolvedValue(null) }; dataSource.getRepository.mockImplementation((entity: unknown) => { if (entity === WagonBookingAllocation) return allocRepo; @@ -1245,7 +1263,7 @@ describe('TrainSchedulingService', () => { }); }); - it('repins the slot onto an empty consist-only wagon (the 404 case)', async () => { + it('moves the load onto an empty consist-only wagon without renaming wagons', async () => { wagonRepo.findOne.mockResolvedValue({ id: 'phys-9', wagonTypeId: 'wt-1', @@ -1258,14 +1276,57 @@ describe('TrainSchedulingService', () => { expect(wagonRepo.findOne).toHaveBeenCalledWith( expect.objectContaining({ where: { id: 'phys-9', trainId: 'train-1' } }), ); - // Repin: wagon identity moves onto the slot; allocations stay put. + // A slot is created ON the target wagon, carrying the source's load + // fields. sequence_no appends past the existing max so it clears the + // (train_set_id, sequence_no) unique index. + expect(slotRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + trainSetId: 'ts-1', + physicalWagonId: 'phys-9', + wagonTypeId: 'wt-1', + sequenceNo: 3, + capacityTons: 70, + lengthMeters: 14, + assignedWeightTons: 40, + status: 'RESERVED', + boardYardId: 'yard-1', + alightYardId: null, + }), + ); + // The whole load crosses onto that new slot… + expect(allocRepo.update).toHaveBeenCalledWith('alloc-a1', { trainSetWagonId: 'slot-new' }); + expect(allocRepo.update).toHaveBeenCalledWith('alloc-a2', { trainSetWagonId: 'slot-new' }); + // …and the source wagon stays itself, just empty. expect(slotRepo.update).toHaveBeenCalledWith('wA', { - physicalWagonId: 'phys-9', - wagonTypeId: 'wt-1', - capacityTons: 70, - lengthMeters: 14, + assignedWeightTons: 0, + status: 'PLANNED', + boardYardId: null, + alightYardId: null, }); - expect(allocRepo.update).not.toHaveBeenCalled(); + // The bug this replaced: the source slot must NOT be repinned to another + // physical wagon — that reorders the train instead of moving the load. + expect(slotRepo.update).not.toHaveBeenCalledWith( + 'wA', + expect.objectContaining({ physicalWagonId: expect.anything() }), + ); + }); + + it('reuses the existing slot when the target wagon is addressed by wagon id', async () => { + // wB is already pinned to physical wagon phys-B. Addressing that wagon + // directly must land in wB, not mint a second slot on the same wagon. + (slotB as Record).physicalWagonId = 'phys-B'; + wagonRepo.findOne.mockResolvedValue({ + id: 'phys-B', + wagonTypeId: 'wt-1', + wagonNumber: 'WGN-B', + wagonType: containerType, + }); + + await service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'phys-B' }); + + expect(slotRepo.save).not.toHaveBeenCalled(); + expect(allocRepo.update).toHaveBeenCalledWith('alloc-a1', { trainSetWagonId: 'wB' }); + expect(allocRepo.update).toHaveBeenCalledWith('alloc-b1', { trainSetWagonId: 'wA' }); }); it('rejects a bulk load onto a wagon whose type only supports containers', async () => { 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 614afe275..15a542d31 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 @@ -147,6 +147,7 @@ import { DEFAULT_CONTAINER_WAGON_LENGTH_METERS, DEFAULT_CONTAINER_WAGON_TARE_TONS, } from './booking-batch.constants'; +import { orderConsistWagons } from './consist-order.util'; import { computeExportWindowTimes, computeImportWindowTimes, @@ -6659,6 +6660,8 @@ export class TrainSchedulingService { : slot.physicalWagonId ?? null; if (physicalId) coveredPhysicalIds.add(physicalId); } + // Fallback only — real empty rows below carry the wagon's OWN physical + // sequenceNumber, not an invented tail position (see emptyConsistWagons). const maxSlotSequenceNo = Math.max( 0, ...(schedule.trainSet?.wagons ?? []).map((w) => w.sequenceNo), @@ -6669,7 +6672,11 @@ export class TrainSchedulingService { // Physical wagon id — there is no TrainSetWagon slot behind this // row, so remove/edit affordances must stay disabled (consistOnly). id: wagon.id, - sequenceNo: maxSlotSequenceNo + index + 1, + // The wagon's REAL coupling position, so an empty wagon in the middle + // of the train draws in the middle — not appended after every loaded + // slot. Falls back to a tail position only if the wagon somehow has + // no sequence number of its own. + sequenceNo: wagon.sequenceNumber ?? maxSlotSequenceNo + index + 1, capacityTons: roundTons(Number(wagon.wagonType?.capacityTons ?? 0)), lengthMeters: roundTons(Number(wagon.wagonType?.lengthMeters ?? 0)), assignedWeightTons: 0, @@ -6690,6 +6697,18 @@ export class TrainSchedulingService { consistOnly: true, })); + // The consist is DRAWN in the built train's real coupling order (rawConsistWagons + // is already ASC/DESC per reverseWagonOrder), not in slot order — see + // consist-order.util. `position` is the drawn place, 1..n; `sequenceNo` stays + // the slot's own stored value. + const drawConsist = ( + list: T[], + ) => + orderConsistWagons(list, { + physicalWagonIdsInOrder: rawConsistWagons.map((wagon) => wagon.id), + reverseWagonOrder: schedule.reverseWagonOrder, + }); + return { id: schedule.id, reference: schedule.reference ?? null, @@ -6779,8 +6798,8 @@ export class TrainSchedulingService { maxPullWeightTons: roundTons(Number(loco.maxPullWeightTons)), maxTrainLengthMeters: roundTons(Number(loco.maxTrainLengthMeters)), })), - wagons: [...(schedule.trainSet.wagons ?? [])] - .sort((a, b) => a.sequenceNo - b.sequenceNo) + wagons: drawConsist( + (schedule.trainSet.wagons ?? []) .map((wagon) => { // Frozen schedules read the wagon number + allocations from the // snapshot slot; the immutable slot geometry (capacity/type) still @@ -6788,9 +6807,20 @@ export class TrainSchedulingService { const frozenSlot = isWagonAllocationFrozen ? snapshotSlotByTrainSetWagonId.get(wagon.id) : undefined; + // Draw the slot at its physical wagon's REAL coupling position, + // not the planning-time slot index — the two diverge once a + // load has been dragged onto a different wagon (moveWagonLoad + // repoints physicalWagonId but a slot keeps its own sequenceNo), + // or once wagon types were interleaved at pinning time. Frozen + // and not-yet-pinned slots have no live physical wagon to trust, + // so they keep their own slot sequence. + const sequenceNo = + frozenSlot || !wagon.physicalWagon + ? wagon.sequenceNo + : (wagon.physicalWagon.sequenceNumber ?? wagon.sequenceNo); return { id: wagon.id, - sequenceNo: wagon.sequenceNo, + sequenceNo, capacityTons: roundTons(Number(wagon.capacityTons)), lengthMeters: roundTons(Number(wagon.lengthMeters)), assignedWeightTons: roundTons(Number(wagon.assignedWeightTons)), @@ -6860,6 +6890,7 @@ export class TrainSchedulingService { }; }) .concat(emptyConsistWagons), + ), } : null, bookings: @@ -7418,8 +7449,8 @@ export class TrainSchedulingService { // Target: a slot of this train set, or an empty consist-only wagon of the // built train (physical wagon with no slot row yet). - const targetSlot = slots.find((w) => w.id === dto.targetWagonId) ?? null; - const consistWagon = targetSlot + const slotById = slots.find((w) => w.id === dto.targetWagonId) ?? null; + const wagonForTarget = slotById ? null : schedule.trainSet?.trainId ? await this.dataSource.getRepository(Wagon).findOne({ @@ -7427,18 +7458,34 @@ export class TrainSchedulingService { relations: { wagonType: true }, }) : null; - if (!targetSlot && !consistWagon) { + if (!slotById && !wagonForTarget) { throw new NotFoundException('Target wagon is not part of this schedule'); } + // A physical wagon holds at most one slot. When the caller addressed the + // wagon directly but a slot is already pinned to it, move into that slot + // rather than minting a second one on the same wagon. + const targetSlot = + slotById ?? + (wagonForTarget + ? (slots.find((w) => w.physicalWagonId === wagonForTarget.id) ?? null) + : null); + const consistWagon = targetSlot ? null : wagonForTarget; const targetAllocs = targetSlot ? await loadAllocations(targetSlot.id) : []; + if (targetSlot && targetSlot.id === source.id) { + return this.getTrainScheduleById(scheduleId); + } const loadTypesOf = (allocs: WagonBookingAllocation[]) => [ ...new Set(allocs.map((a) => (a.loadType ?? 'CONTAINER').toUpperCase())), ]; const cargoOf = (allocs: WagonBookingAllocation[]) => allocs.reduce((sum, a) => sum + Number(a.allocatedWeightTons || 0), 0); - const wagonLabel = (slot: { sequenceNo: number } | null, wagon: Wagon | null) => - slot ? `#${slot.sequenceNo}` : (wagon?.wagonNumber ?? 'the target wagon'); + // Name wagons by their physical number — the consist is drawn in the train's + // coupling order, so a slot's sequenceNo is not the position staff can see. + const slotLabel = (slot: TrainSetWagon) => + slot.physicalWagon?.wagonNumber ?? `#${slot.sequenceNo}`; + const wagonLabel = (slot: TrainSetWagon | null, wagon: Wagon | null) => + slot ? slotLabel(slot) : (wagon?.wagonNumber ?? 'the target wagon'); const checkReceives = ( allocs: WagonBookingAllocation[], label: string, @@ -7480,7 +7527,7 @@ export class TrainSchedulingService { if (targetAllocs.length) { checkReceives( targetAllocs, - `#${source.sequenceNo}`, + slotLabel(source), source.wagonType, Number(source.capacityTons), ); @@ -7490,19 +7537,6 @@ export class TrainSchedulingService { const slotRepo = manager.getRepository(TrainSetWagon); const allocs = manager.getRepository(WagonBookingAllocation); - // Empty consist wagon: repin the loaded slot onto that physical wagon. - // Allocations and load fields stay put; only the wagon identity changes. - if (consistWagon) { - await slotRepo.update(source.id, { - physicalWagonId: consistWagon.id, - wagonTypeId: consistWagon.wagonTypeId, - capacityTons: roundTons(Number(consistWagon.wagonType?.capacityTons ?? source.capacityTons)), - lengthMeters: roundTons(Number(consistWagon.wagonType?.lengthMeters ?? source.lengthMeters)), - }); - return; - } - - const target = targetSlot as TrainSetWagon; // Load-coupled slot fields travel with the load; wagon identity stays. const loadFieldsOf = (slot: TrainSetWagon) => ({ assignedWeightTons: slot.assignedWeightTons, @@ -7517,6 +7551,47 @@ export class TrainSchedulingService { alightYardId: null, }; const sourceLoadFields = loadFieldsOf(source); + + // Empty consist wagon with no slot row yet: give it one, then move the + // load into it. Repinning the SOURCE slot onto that wagon would have been + // fewer writes, but it renames the wagons instead of moving the load — + // the loaded slot becomes wagon B and B's identity pops out as an empty + // wagon where A used to be. Staff read that as the train re-ordering + // itself. A wagon must never change place because a container moved. + if (consistWagon) { + const { maxSequenceNo } = (await slotRepo + .createQueryBuilder('slot') + .select('COALESCE(MAX(slot.sequence_no), 0)', 'maxSequenceNo') + .where('slot.train_set_id = :trainSetId', { trainSetId: source.trainSetId }) + .getRawOne<{ maxSequenceNo: string | number }>()) ?? { maxSequenceNo: 0 }; + + const created = await slotRepo.save( + slotRepo.create({ + trainSetId: source.trainSetId, + wagonTypeId: consistWagon.wagonTypeId, + physicalWagonId: consistWagon.id, + // Plan-order key only — the consist is drawn in the train's coupling + // order (wagons.sequence_number), so appending here moves nothing. + // It just has to clear the (train_set_id, sequence_no) unique index. + sequenceNo: Number(maxSequenceNo) + 1, + capacityTons: roundTons( + Number(consistWagon.wagonType?.capacityTons ?? source.capacityTons), + ), + lengthMeters: roundTons( + Number(consistWagon.wagonType?.lengthMeters ?? source.lengthMeters), + ), + ...sourceLoadFields, + }), + ); + + for (const alloc of sourceAllocs) { + await allocs.update(alloc.id, { trainSetWagonId: created.id }); + } + await slotRepo.update(source.id, emptyLoadFields); + return; + } + + const target = targetSlot as TrainSetWagon; const targetLoadFields = targetAllocs.length ? loadFieldsOf(target) : emptyLoadFields; for (const alloc of sourceAllocs) { diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts index 662b83534..31f108740 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts @@ -548,6 +548,26 @@ export class TrainBuilderService { return rows.length > 0; } + /** Batched form of {@link isWagonPinnedToLiveSchedule} for a whole consist. */ + private async isAnyWagonPinnedToLiveSchedule( + manager: EntityManager, + wagonIds: string[], + ): Promise { + if (!wagonIds.length) return false; + const rows: { exists: boolean }[] = await manager.query( + `SELECT TRUE AS exists + FROM freight.train_set_wagons tsw + JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id + WHERE tsw.physical_wagon_id = ANY($1::uuid[]) + AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED') + AND ts.deleted_at IS NULL + AND tsw.deleted_at IS NULL + LIMIT 1`, + [wagonIds], + ); + return rows.length > 0; + } + /** Persist a drag-reorder: `wagonIds` is the full consist in its new order. */ async reorderWagons(id: string, dto: ReorderTrainWagonsDto) { await this.dataSource.transaction(async (manager) => { @@ -560,6 +580,17 @@ export class TrainBuilderService { if (current.size !== incoming.size || [...current].some((wid) => !incoming.has(wid))) { throw new BadRequestException('Reorder must include every wagon of the train exactly once'); } + // A live schedule (DRAFT/SCHEDULED/DISPATCHED) reads each wagon's slot at + // its OWN frozen sequenceNo, never the wagon's live sequenceNumber — so + // renumbering here would silently desync that schedule's drawn consist + // from the built train's real order (loaded slots keep the old order, + // empty ones show the new one). Same guard as remove/maintenance. + if (await this.isAnyWagonPinnedToLiveSchedule(manager, [...current])) { + throw new ConflictException( + 'This train has wagons pinned to an active schedule and cannot be reordered — ' + + "it would desync the schedule's consist view from the built train's real order.", + ); + } for (let i = 0; i < dto.wagonIds.length; i++) { await manager.getRepository(Wagon).update(dto.wagonIds[i], { sequenceNumber: i + 1 }); } diff --git a/apps/edr-freight-api/src/modules/wagons/dto/close-short-transfer-request.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/close-short-transfer-request.dto.ts new file mode 100644 index 000000000..a277cfe72 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/dto/close-short-transfer-request.dto.ts @@ -0,0 +1,17 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, MaxLength } from 'class-validator'; + +/** + * OCC ends a transfer request with fewer wagons than asked for. The note is + * carried into the requester's notification — it is what tells them WHY the + * yard could not give the rest. + */ +export class CloseShortTransferRequestDto { + @ApiPropertyOptional({ + description: 'Why the source yard cannot supply the remainder', + }) + @IsOptional() + @IsString() + @MaxLength(2000) + note?: string; +} diff --git a/apps/edr-freight-api/src/modules/wagons/dto/list-transfer-requests-query.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/list-transfer-requests-query.dto.ts new file mode 100644 index 000000000..733b774bb --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/dto/list-transfer-requests-query.dto.ts @@ -0,0 +1,44 @@ +import { WagonTransferRequestStatus } from '@edr/types'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsIn, IsOptional, IsUUID } from 'class-validator'; + +import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto'; + +const SORT_FIELDS = ['createdAt', 'quantity', 'status'] as const; + +/** + * Transfer-desk list query. `status` accepts a comma-separated list so the + * "Open" tab can ask for PENDING + PARTIALLY_FULFILLED in one call. + */ +export class ListTransferRequestsQueryDto extends PaginationQueryDto { + @ApiPropertyOptional({ + description: 'One status or a comma-separated list', + enum: WagonTransferRequestStatus, + }) + @IsOptional() + @Transform(({ value }) => + typeof value === 'string' && value.trim() ? value.trim() : undefined, + ) + status?: string; + + @ApiPropertyOptional({ description: 'Source yard' }) + @IsOptional() + @IsUUID() + fromYardId?: string; + + @ApiPropertyOptional({ description: 'Destination yard' }) + @IsOptional() + @IsUUID() + toYardId?: string; + + @ApiPropertyOptional({ description: 'Wagon type' }) + @IsOptional() + @IsUUID() + wagonTypeId?: string; + + @ApiPropertyOptional({ enum: SORT_FIELDS, default: 'createdAt' }) + @IsOptional() + @IsIn([...SORT_FIELDS]) + sortBy?: (typeof SORT_FIELDS)[number]; +} diff --git a/apps/edr-freight-api/src/modules/wagons/dto/reorder-wagons.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/reorder-wagons.dto.ts deleted file mode 100644 index 0395adb8f..000000000 --- a/apps/edr-freight-api/src/modules/wagons/dto/reorder-wagons.dto.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { IsArray, IsUUID } from 'class-validator'; - -export class ReorderWagonsDto { - @IsArray() - @IsUUID(4, { each: true }) - wagonIds!: string[]; -} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts b/apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts index c81b6c365..c39b12b9d 100644 --- a/apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts +++ b/apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts @@ -40,6 +40,14 @@ export class WagonTransferRequest extends BaseEntity { @Column({ name: 'quantity', type: 'int' }) quantity!: number; + /** + * How many have actually moved so far. OCC sends what the yard can spare, + * whenever it can — the request stays open until this reaches `quantity` or + * OCC closes it short. + */ + @Column({ name: 'fulfilled_quantity', type: 'int', default: 0 }) + fulfilledQuantity!: number; + @Column({ name: 'status', type: 'varchar', @@ -54,9 +62,17 @@ export class WagonTransferRequest extends BaseEntity { @Column({ name: 'fulfilled_by_user_id', type: 'uuid', nullable: true }) fulfilledByUserId?: string | null; + /** When the LAST transfer against this request ran (not necessarily the full count). */ @Column({ name: 'fulfilled_at', type: 'timestamptz', nullable: true }) fulfilledAt?: Date | null; + /** Set when OCC ended the request with fewer wagons than asked for. */ + @Column({ name: 'closed_short_at', type: 'timestamptz', nullable: true }) + closedShortAt?: Date | null; + + @Column({ name: 'closed_short_by_user_id', type: 'uuid', nullable: true }) + closedShortByUserId?: string | null; + @Column({ name: 'note', type: 'text', nullable: true }) note?: string | null; diff --git a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts index b00e4a64f..9e69c3bf6 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts @@ -1,4 +1,3 @@ -import { WagonTransferRequestStatus } from '@edr/types'; import { Body, Controller, @@ -13,26 +12,36 @@ import { CurrentUser } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { - FleetManage, - FleetView, + WagonTransferCancel, + WagonTransferCloseShort, WagonTransferFulfill, WagonTransferHistoryAll, WagonTransferRequest, + WagonTransferView, } from '../../common/booking-guards'; -import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { BulkFulfillTransferRequestsDto } from './dto/bulk-fulfill-transfer-requests.dto'; +import { CloseShortTransferRequestDto } from './dto/close-short-transfer-request.dto'; import { CreateTransferRequestDto } from './dto/create-transfer-request.dto'; import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto'; +import { ListTransferRequestsQueryDto } from './dto/list-transfer-requests-query.dto'; import { WagonTransferRequestsService } from './wagon-transfer-requests.service'; +/** Query-string number, or undefined when absent/garbage (service defaults it). */ +const toInt = (value?: string): number | undefined => { + const n = Number.parseInt(String(value ?? ''), 10); + return Number.isFinite(n) && n > 0 ? n : undefined; +}; + /** - * Two-person wagon-transfer queue. Requester (transfer_request perm) files a - * count-only request; OCC (transfer_fulfill perm) picks the wagons and executes - * the move. Separate top-level path so it never collides with `wagons/:id`. + * The wagon-transfer desk. A requester (transfer_request) files a count-only + * request; OCC (transfer_fulfill) moves wagons against it in as many + * instalments as the source yard allows, and closes it short + * (transfer_close_short) when the yard has no more to give. Separate top-level + * path so it never collides with `wagons/:id`. */ @ApiTags('wagon-transfer-requests') @Controller('wagon-transfer-requests') -@FleetView(FREIGHT_PERMS.wagons.view) +@WagonTransferView() export class WagonTransferRequestsController { constructor(private readonly service: WagonTransferRequestsService) {} @@ -47,10 +56,12 @@ export class WagonTransferRequestsController { } @Get() - @ApiQuery({ name: 'status', required: false, enum: WagonTransferRequestStatus }) - @ApiOperation({ summary: 'List transfer requests (OCC queue: status=PENDING)' }) - list(@Query('status') status?: WagonTransferRequestStatus) { - return this.service.listRequests(status); + @ApiOperation({ + summary: + 'Transfer desk list — paginated, filterable by status (comma-separated), yards and wagon type', + }) + list(@Query() query: ListTransferRequestsQueryDto) { + return this.service.listRequests(query); } // NOTE: static routes (`history`, `bulk-fulfill`) MUST stay above `@Get(':id')` @@ -73,24 +84,48 @@ export class WagonTransferRequestsController { // matches in declaration order, so `/history` would otherwise be captured by // the `:id` param route (and rejected by ParseUUIDPipe). @Get('history') + @ApiQuery({ name: 'page', required: false }) + @ApiQuery({ name: 'pageSize', required: false }) @ApiOperation({ summary: "Caller's own transfer history (requests filed/fulfilled + wagons moved)", }) - myHistory(@CurrentUser() user: TCurrentUser) { + myHistory( + @CurrentUser() user: TCurrentUser, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { // Never fall through to the all-staff view: getHistory(undefined) means // "everyone", so a missing caller id must return empty, not leak scope. - if (!user?.id) return { requests: [], movements: [] }; - return this.service.getHistory(user.id); + if (!user?.id) { + return { + requests: [], + movements: [], + meta: { + page: 1, + pageSize: 20, + requestsTotal: 0, + movementsTotal: 0, + totalPages: 1, + }, + }; + } + return this.service.getHistory(user.id, toInt(page), toInt(pageSize)); } @Get('history/all') @WagonTransferHistoryAll() @ApiQuery({ name: 'userId', required: false }) + @ApiQuery({ name: 'page', required: false }) + @ApiQuery({ name: 'pageSize', required: false }) @ApiOperation({ summary: "Admin: any/all staff's transfer history (optional ?userId filter)", }) - allHistory(@Query('userId') userId?: string) { - return this.service.getHistory(userId); + allHistory( + @Query('userId') userId?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + return this.service.getHistory(userId, toInt(page), toInt(pageSize)); } @Get(':id') @@ -110,9 +145,26 @@ export class WagonTransferRequestsController { return this.service.fulfillRequest(id, dto, user?.id); } + @Post(':id/close-short') + @WagonTransferCloseShort() + @ApiOperation({ + summary: + 'OCC: end the request with fewer wagons than asked for — what moved stays, the requester is told the shortfall', + }) + closeShort( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: CloseShortTransferRequestDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.closeShort(id, dto, user?.id); + } + @Post(':id/cancel') - @FleetManage(FREIGHT_PERMS.wagons.transferRequest) - @ApiOperation({ summary: 'Withdraw a pending transfer request' }) + @WagonTransferCancel() + @ApiOperation({ + summary: + 'Withdraw a request that has not moved any wagon yet (use close-short once wagons have moved)', + }) cancel(@Param('id', ParseUUIDPipe) id: string) { return this.service.cancelRequest(id); } diff --git a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts new file mode 100644 index 000000000..ecf512aac --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts @@ -0,0 +1,235 @@ +import { WagonTransferRequestStatus } from '@edr/types'; +import { ConflictException, BadRequestException } from '@nestjs/common'; + +import { WagonTransferRequestsService } from './wagon-transfer-requests.service'; +import type { WagonTransferRequest } from './entities/wagon-transfer-request.entity'; + +/** + * Instalment fulfilment: a request for 50 wagons is met with whatever the source + * yard can spare, whenever it can spare it. It stays open until the full count + * lands or OCC closes it short — which is what tells the requester to go ask + * another yard. + */ +describe('WagonTransferRequestsService — partial fulfilment', () => { + const request = (over: Partial = {}): WagonTransferRequest => + ({ + id: 'req-1', + fromYardId: 'yard-a', + toYardId: 'yard-b', + wagonTypeId: 'type-1', + quantity: 50, + fulfilledQuantity: 0, + status: WagonTransferRequestStatus.Pending, + requestedByUserId: 'user-1', + ...over, + }) as WagonTransferRequest; + + let requestRepo: { + findOne: jest.Mock; + find: jest.Mock; + save: jest.Mock; + create: jest.Mock; + createQueryBuilder: jest.Mock; + }; + let wagonRepo: { find: jest.Mock; count: jest.Mock }; + let wagonsService: { bulkTransfer: jest.Mock }; + let inbox: { notify: jest.Mock }; + let service: WagonTransferRequestsService; + let stored: WagonTransferRequest; + + const flush = () => new Promise((resolve) => setImmediate(resolve)); + + const build = (row: WagonTransferRequest) => { + stored = row; + requestRepo.findOne.mockImplementation(async () => stored); + requestRepo.save.mockImplementation(async (r: WagonTransferRequest) => { + stored = r; + return r; + }); + }; + + beforeEach(() => { + requestRepo = { + findOne: jest.fn(), + find: jest.fn().mockResolvedValue([]), + save: jest.fn(), + create: jest.fn((r) => r), + createQueryBuilder: jest.fn(), + }; + wagonRepo = { find: jest.fn().mockResolvedValue([]), count: jest.fn() }; + wagonsService = { bulkTransfer: jest.fn().mockResolvedValue(undefined) }; + inbox = { notify: jest.fn().mockResolvedValue(undefined) }; + service = new WagonTransferRequestsService( + requestRepo as never, + wagonRepo as never, + { find: jest.fn(), findAndCount: jest.fn() } as never, + wagonsService as never, + inbox as never, + ); + build(request()); + }); + + const availableWagons = (n: number) => + Array.from({ length: n }, (_, i) => ({ + id: `w-${i}`, + wagonNumber: `100${i}`, + currentYardId: 'yard-a', + wagonTypeId: 'type-1', + status: 'AVAILABLE', + })); + + describe('fulfillRequest', () => { + it('books an instalment and keeps the request open', async () => { + wagonRepo.find.mockResolvedValue(availableWagons(20)); + + await service.fulfillRequest('req-1', { + wagonIds: availableWagons(20).map((w) => w.id), + }); + + expect(stored.fulfilledQuantity).toBe(20); + expect(stored.status).toBe(WagonTransferRequestStatus.PartiallyFulfilled); + expect(wagonsService.bulkTransfer).toHaveBeenCalledTimes(1); + }); + + it('completes the request when the last instalment lands', async () => { + build(request({ fulfilledQuantity: 30, status: WagonTransferRequestStatus.PartiallyFulfilled })); + wagonRepo.find.mockResolvedValue(availableWagons(20)); + + await service.fulfillRequest('req-1', { + wagonIds: availableWagons(20).map((w) => w.id), + }); + + expect(stored.fulfilledQuantity).toBe(50); + expect(stored.status).toBe(WagonTransferRequestStatus.Fulfilled); + }); + + it('refuses to move more than is still owed', async () => { + build(request({ fulfilledQuantity: 45, status: WagonTransferRequestStatus.PartiallyFulfilled })); + wagonRepo.find.mockResolvedValue(availableWagons(10)); + + await expect( + service.fulfillRequest('req-1', { + wagonIds: availableWagons(10).map((w) => w.id), + }), + ).rejects.toBeInstanceOf(BadRequestException); + expect(wagonsService.bulkTransfer).not.toHaveBeenCalled(); + }); + + it('refuses to touch a request that is already closed', async () => { + build(request({ status: WagonTransferRequestStatus.ClosedShort, fulfilledQuantity: 20 })); + + await expect( + service.fulfillRequest('req-1', { wagonIds: ['w-0'] }), + ).rejects.toBeInstanceOf(ConflictException); + }); + + it('tells the requester what landed and what is still owed', async () => { + wagonRepo.find.mockResolvedValue(availableWagons(20)); + + await service.fulfillRequest('req-1', { + wagonIds: availableWagons(20).map((w) => w.id), + }); + await flush(); + + const sent = inbox.notify.mock.calls[0][0]; + expect(sent.recipients).toEqual({ userIds: ['user-1'] }); + expect(sent.body).toContain('20 wagon(s) have arrived'); + expect(sent.body).toContain('30 of 50 still to come'); + }); + }); + + describe('bulkFulfill', () => { + it('sends what the yard has instead of skipping a short request', async () => { + wagonRepo.find.mockResolvedValue(availableWagons(20)); + + const result = await service.bulkFulfill(['req-1']); + + expect(stored.fulfilledQuantity).toBe(20); + expect(stored.status).toBe(WagonTransferRequestStatus.PartiallyFulfilled); + expect(result.skipped).toHaveLength(0); + }); + + it('skips only when the yard has nothing to give', async () => { + wagonRepo.find.mockResolvedValue([]); + + const result = await service.bulkFulfill(['req-1']); + + expect(wagonsService.bulkTransfer).not.toHaveBeenCalled(); + expect(result.skipped[0].reason).toContain('No available wagons'); + }); + }); + + describe('closeShort', () => { + it('ends the request and tells the requester to ask another yard', async () => { + build(request({ fulfilledQuantity: 20, status: WagonTransferRequestStatus.PartiallyFulfilled })); + + await service.closeShort('req-1', { note: 'Yard is empty until Friday' }); + await flush(); + + expect(stored.status).toBe(WagonTransferRequestStatus.ClosedShort); + expect(stored.closedShortAt).toBeInstanceOf(Date); + const sent = inbox.notify.mock.calls[0][0]; + expect(sent.body).toContain('Only 20 of the 50'); + expect(sent.body).toContain('Yard is empty until Friday'); + expect(sent.body).toContain('Request the remaining 30'); + }); + + it('refuses when the request is already fully supplied', async () => { + build(request({ fulfilledQuantity: 50, status: WagonTransferRequestStatus.PartiallyFulfilled })); + + await expect(service.closeShort('req-1', {})).rejects.toBeInstanceOf( + ConflictException, + ); + }); + }); + + describe('cancelRequest', () => { + it('withdraws a request that never moved a wagon', async () => { + await service.cancelRequest('req-1'); + expect(stored.status).toBe(WagonTransferRequestStatus.Cancelled); + }); + + it('refuses once wagons have moved — close it short instead', async () => { + build(request({ fulfilledQuantity: 20, status: WagonTransferRequestStatus.PartiallyFulfilled })); + + await expect(service.cancelRequest('req-1')).rejects.toThrow( + /close it short/i, + ); + }); + }); + + describe('createRequest', () => { + it('accepts a count larger than what the yard holds today', async () => { + wagonRepo.count.mockResolvedValue(20); + + await service.createRequest( + { + fromYardId: 'yard-a', + toYardId: 'yard-b', + wagonTypeId: 'type-1', + quantity: 50, + reason: 'Grain campaign', + }, + 'user-1', + ); + + expect(requestRepo.save).toHaveBeenCalled(); + expect(stored.quantity).toBe(50); + }); + + it('still refuses a same-yard move', async () => { + await expect( + service.createRequest( + { + fromYardId: 'yard-a', + toYardId: 'yard-a', + wagonTypeId: 'type-1', + quantity: 5, + reason: 'x', + }, + 'user-1', + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts index 068d4dc6d..79a74f4ce 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts @@ -1,15 +1,27 @@ -import { WagonStatus, WagonTransferRequestStatus } from '@edr/types'; +import { + NotificationAudience, + NotificationType, + OPEN_WAGON_TRANSFER_STATUSES, + PaginatedResponse, + WagonStatus, + WagonTransferRequestStatus, +} from '@edr/types'; import { BadRequestException, ConflictException, Injectable, + Logger, NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { In, IsNull, Not, Repository } from 'typeorm'; +import { paginateQuery } from '../../common/utils/pagination.util'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { CloseShortTransferRequestDto } from './dto/close-short-transfer-request.dto'; import { CreateTransferRequestDto } from './dto/create-transfer-request.dto'; import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto'; +import { ListTransferRequestsQueryDto } from './dto/list-transfer-requests-query.dto'; import { Wagon } from './entities/wagon.entity'; import { WagonMovement } from './entities/wagon-movement.entity'; import { WagonTransferRequest } from './entities/wagon-transfer-request.entity'; @@ -19,10 +31,21 @@ import { WagonsService } from './wagons.service'; export interface TransferHistory { requests: WagonTransferRequest[]; movements: WagonMovement[]; + /** + * One pager drives both lists (they are shown side by side), so it carries a + * total per list and the page count of the longer one. + */ + meta: { + page: number; + pageSize: number; + requestsTotal: number; + movementsTotal: number; + totalPages: number; + }; } -/** How many ledger rows the history returns at most (newest first). */ -const HISTORY_LIMIT = 500; +/** Hard ceiling on a single history page, whatever the client asks for. */ +const HISTORY_LIMIT = 100; const REQUEST_RELATIONS = { fromYard: true, @@ -38,6 +61,8 @@ const REQUEST_RELATIONS = { */ @Injectable() export class WagonTransferRequestsService { + private readonly logger = new Logger(WagonTransferRequestsService.name); + constructor( @InjectRepository(WagonTransferRequest) private readonly requestRepo: Repository, @@ -46,13 +71,14 @@ export class WagonTransferRequestsService { @InjectRepository(WagonMovement) private readonly movementRepo: Repository, private readonly wagonsService: WagonsService, + private readonly inbox: NotificationInboxService, ) {} /** - * Record a PENDING request. Count-only — no wagons are picked here, but the - * count is capped at the AVAILABLE wagons of that type currently sitting in - * the source yard: staff may only ask for wagons that are actually there to - * give. A reason is mandatory and is shown on the OCC queue. + * Record a PENDING request. Count-only — no wagons are picked here, and the + * count is NOT capped by what the source yard holds today: OCC fulfils in + * instalments, so asking for 50 while only 20 sit there is a normal, useful + * request. A reason is mandatory and is shown on the OCC queue. */ async createRequest( dto: CreateTransferRequestDto, @@ -63,14 +89,6 @@ export class WagonTransferRequestsService { 'Source and destination yard must be different', ); } - const available = await this.countAvailable(dto.fromYardId, dto.wagonTypeId); - if (available < dto.quantity) { - throw new BadRequestException( - available === 0 - ? 'No available wagons of this type in the source yard' - : `Only ${available} available wagon(s) of this type in the source yard — request at most ${available}`, - ); - } const request = this.requestRepo.create({ fromYardId: dto.fromYardId, toYardId: dto.toYardId, @@ -85,8 +103,12 @@ export class WagonTransferRequestsService { return this.findById(saved.id); } - /** AVAILABLE wagons of `wagonTypeId` currently in `yardId`. */ - private countAvailable(yardId: string, wagonTypeId: string): Promise { + /** + * AVAILABLE wagons of `wagonTypeId` currently in `yardId` — what OCC can move + * right now. Shown on the desk beside the outstanding count so staff see at a + * glance how much of a request the yard can cover today. + */ + countAvailable(yardId: string, wagonTypeId: string): Promise { return this.wagonRepo.count({ where: { currentYardId: yardId, @@ -96,15 +118,51 @@ export class WagonTransferRequestsService { }); } - /** Requests, newest first, optionally filtered by status (OCC queue = PENDING). */ + /** + * The transfer desk list: paginated, newest first, filterable by status (one + * or a comma-separated set — the "Open" tab asks for PENDING + + * PARTIALLY_FULFILLED), yards and wagon type. Search matches the reason text. + */ async listRequests( - status?: WagonTransferRequestStatus, - ): Promise { - return this.requestRepo.find({ - where: status ? { status } : {}, - relations: REQUEST_RELATIONS, - order: { createdAt: 'DESC' }, - }); + query: ListTransferRequestsQueryDto, + ): Promise> { + const qb = this.requestRepo + .createQueryBuilder('r') + .leftJoinAndSelect('r.fromYard', 'fromYard') + .leftJoinAndSelect('r.toYard', 'toYard') + .leftJoinAndSelect('r.wagonType', 'wagonType'); + + const statuses = (query.status ?? '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + if (statuses.length) { + qb.andWhere('r.status IN (:...statuses)', { statuses }); + } + if (query.fromYardId) { + qb.andWhere('r.from_yard_id = :fromYardId', { fromYardId: query.fromYardId }); + } + if (query.toYardId) { + qb.andWhere('r.to_yard_id = :toYardId', { toYardId: query.toYardId }); + } + if (query.wagonTypeId) { + qb.andWhere('r.wagon_type_id = :wagonTypeId', { + wagonTypeId: query.wagonTypeId, + }); + } + if (query.search) { + qb.andWhere('r.reason ILIKE :search', { search: `%${query.search}%` }); + } + + const sortColumn = + query.sortBy === 'quantity' + ? 'r.quantity' + : query.sortBy === 'status' + ? 'r.status' + : 'r.created_at'; + qb.orderBy(sortColumn, query.sortOrder ?? 'DESC'); + + return paginateQuery(qb, { page: query.page, pageSize: query.pageSize }); } async findById(id: string): Promise { @@ -116,11 +174,23 @@ export class WagonTransferRequestsService { return request; } + /** Wagons still owed on an open request. */ + private remainingOn(request: WagonTransferRequest): number { + return Math.max(0, request.quantity - (request.fulfilledQuantity ?? 0)); + } + + /** True while OCC can still move wagons against this request. */ + private isOpen(request: WagonTransferRequest): boolean { + return OPEN_WAGON_TRANSFER_STATUSES.includes(request.status); + } + /** - * OCC fulfils a PENDING request with hand-picked wagons. Every wagon must sit - * in the request's source yard, match its wagon type, and the count must equal - * the requested quantity — then the transfer runs and the request is marked - * FULFILLED. + * OCC moves hand-picked wagons against an open request. Any number from 1 up + * to whatever is still owed — the yard rarely has the whole ask at once, so a + * request for 50 can be met 20 now, 30 later. Every wagon must sit in the + * source yard, match the type and be available. The request completes on its + * own once the full count has moved; short of that it stays open as + * PARTIALLY_FULFILLED and the requester is told what landed. */ async fulfillRequest( id: string, @@ -128,16 +198,17 @@ export class WagonTransferRequestsService { userId?: string | null, ): Promise { const request = await this.findById(id); - if (request.status !== WagonTransferRequestStatus.Pending) { + if (!this.isOpen(request)) { throw new ConflictException( - `Request is already ${request.status.toLowerCase()}`, + `Request is already ${request.status.toLowerCase().replace(/_/g, ' ')}`, ); } const wagonIds = [...new Set(dto.wagonIds)]; - if (wagonIds.length !== request.quantity) { + const remaining = this.remainingOn(request); + if (wagonIds.length > remaining) { throw new BadRequestException( - `Select exactly ${request.quantity} wagon(s); you selected ${wagonIds.length}`, + `Only ${remaining} wagon(s) still owed on this request; you selected ${wagonIds.length}`, ); } @@ -178,21 +249,124 @@ export class WagonTransferRequestsService { { transferRequestId: request.id }, ); - request.status = WagonTransferRequestStatus.Fulfilled; - request.fulfilledByUserId = userId ?? null; - request.fulfilledAt = new Date(); - await this.requestRepo.save(request); + await this.recordDelivery(request, wagonIds.length, userId); return this.findById(id); } /** - * OCC accepts AND executes a subset of pending requests in one action. For - * each selected request the system auto-picks the required number of - * AVAILABLE wagons of the requested type from the source yard (lowest wagon - * number first) and runs the audited transfer. A request that cannot be - * executed — already decided, or not enough available wagons left after the - * ones processed before it — is SKIPPED and simply stays PENDING, visible to - * both teams; nothing is rolled back for the others. + * Book an instalment against a request: bump the delivered count, complete it + * when the full ask has landed, and tell the requester what moved. Shared by + * the hand-picked and auto-picked (bulk) fulfilment paths. + */ + private async recordDelivery( + request: WagonTransferRequest, + moved: number, + userId?: string | null, + ): Promise { + request.fulfilledQuantity = (request.fulfilledQuantity ?? 0) + moved; + request.status = + request.fulfilledQuantity >= request.quantity + ? WagonTransferRequestStatus.Fulfilled + : WagonTransferRequestStatus.PartiallyFulfilled; + request.fulfilledByUserId = userId ?? null; + request.fulfilledAt = new Date(); + await this.requestRepo.save(request); + this.notifyRequester(request, moved); + } + + /** + * Tell the requester what landed. Fire-and-forget: a notification failure must + * never undo a transfer that already moved wagons. + */ + private notifyRequester( + request: WagonTransferRequest, + moved: number, + closedShortNote?: string | null, + ): void { + if (!request.requestedByUserId) return; + const outstanding = this.remainingOn(request); + const complete = request.status === WagonTransferRequestStatus.Fulfilled; + const closedShort = + request.status === WagonTransferRequestStatus.ClosedShort; + + const title = complete + ? `All ${request.quantity} wagon(s) transferred` + : closedShort + ? `Transfer closed short — ${request.fulfilledQuantity} of ${request.quantity} wagon(s)` + : `${moved} of ${request.quantity} wagon(s) transferred`; + + const body = complete + ? `Your wagon transfer request is complete — all ${request.quantity} wagon(s) have arrived.` + : closedShort + ? `Only ${request.fulfilledQuantity} of the ${request.quantity} wagon(s) you asked for could be supplied` + + `${closedShortNote ? `: ${closedShortNote}` : '.'} ` + + `Request the remaining ${outstanding} from another yard.` + : `${moved} wagon(s) have arrived against your request. ` + + `${outstanding} of ${request.quantity} still to come.`; + + void this.inbox + .notify({ + recipients: { userIds: [request.requestedByUserId] }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.GENERIC, + title, + body, + link: `/dashboard/wagon-transfers/${request.id}`, + data: { + transferRequestId: request.id, + delivered: request.fulfilledQuantity, + requested: request.quantity, + outstanding, + }, + }) + .catch((err) => + this.logger.warn( + `Transfer notification failed for ${request.id}: ${(err as Error).message}`, + ), + ); + } + + /** + * OCC ends a request with fewer wagons than asked for — the source yard has + * nothing more to give. What already moved stays moved; the requester is told + * the shortfall so they can raise it against another yard. Cancelling is for + * requests that never moved anything; this is the close for ones that did. + */ + async closeShort( + id: string, + dto: CloseShortTransferRequestDto, + userId?: string | null, + ): Promise { + const request = await this.findById(id); + if (!this.isOpen(request)) { + throw new ConflictException( + `Request is already ${request.status.toLowerCase().replace(/_/g, ' ')}`, + ); + } + if (this.remainingOn(request) === 0) { + throw new ConflictException( + 'Nothing outstanding — this request is already fully supplied', + ); + } + + request.status = WagonTransferRequestStatus.ClosedShort; + request.closedShortAt = new Date(); + request.closedShortByUserId = userId ?? null; + if (dto.note?.trim()) { + request.note = dto.note.trim(); + } + await this.requestRepo.save(request); + this.notifyRequester(request, 0, dto.note ?? null); + return this.findById(id); + } + + /** + * OCC executes a set of open requests in one action, auto-picking AVAILABLE + * wagons of the requested type from each source yard (lowest wagon number + * first). A yard that cannot cover the whole ask still sends what it has — + * the request stays open for the rest rather than being skipped, which is the + * whole point of instalments. Only a request with NOTHING available is + * skipped, and nothing is rolled back for the others. */ async bulkFulfill( requestIds: string[], @@ -212,13 +386,14 @@ export class WagonTransferRequestsService { skipped.push({ id, reason: 'Request not found' }); continue; } - if (request.status !== WagonTransferRequestStatus.Pending) { + if (!this.isOpen(request)) { skipped.push({ id, - reason: `Already ${request.status.toLowerCase()}`, + reason: `Already ${request.status.toLowerCase().replace(/_/g, ' ')}`, }); continue; } + const remaining = this.remainingOn(request); const wagons = await this.wagonRepo.find({ where: { currentYardId: request.fromYardId, @@ -226,12 +401,12 @@ export class WagonTransferRequestsService { status: WagonStatus.Available, }, order: { wagonNumber: 'ASC' }, - take: request.quantity, + take: remaining, }); - if (wagons.length < request.quantity) { + if (wagons.length === 0) { skipped.push({ id, - reason: `Only ${wagons.length} of ${request.quantity} wagon(s) available in the source yard — left pending`, + reason: 'No available wagons of this type in the source yard — left open', }); continue; } @@ -240,10 +415,7 @@ export class WagonTransferRequestsService { userId, { transferRequestId: request.id }, ); - request.status = WagonTransferRequestStatus.Fulfilled; - request.fulfilledByUserId = userId ?? null; - request.fulfilledAt = new Date(); - await this.requestRepo.save(request); + await this.recordDelivery(request, wagons.length, userId); fulfilled.push(await this.findById(id)); } @@ -258,17 +430,25 @@ export class WagonTransferRequestsService { * (the controller passes the caller's id unless they hold the history-all * permission) — this method trusts its argument. */ - async getHistory(userId?: string | null): Promise { - const requests = await this.requestRepo.find({ + async getHistory( + userId?: string | null, + page?: number, + pageSize?: number, + ): Promise { + const take = Math.min(pageSize ?? 20, HISTORY_LIMIT); + const skip = ((page ?? 1) - 1) * take; + + const [requests, requestsTotal] = await this.requestRepo.findAndCount({ where: userId ? [{ requestedByUserId: userId }, { fulfilledByUserId: userId }] : {}, relations: REQUEST_RELATIONS, order: { createdAt: 'DESC' }, - take: HISTORY_LIMIT, + skip, + take, }); - const movements = await this.movementRepo.find({ + const [movements, movementsTotal] = await this.movementRepo.findAndCount({ // Own view: moves I made. All view: every user-attributed move (skip the // system-written loaded/reposition legs that carry no mover). where: userId @@ -276,18 +456,39 @@ export class WagonTransferRequestsService { : { movedByUserId: Not(IsNull()) }, relations: { wagon: true, fromYard: true, toYard: true, transferRequest: true }, order: { occurredAt: 'DESC' }, - take: HISTORY_LIMIT, + skip, + take, }); - return { requests, movements }; + return { + requests, + movements, + meta: { + page: page ?? 1, + pageSize: take, + requestsTotal, + movementsTotal, + // Whichever list is longer decides how far the pager can go. + totalPages: Math.max( + 1, + Math.ceil(Math.max(requestsTotal, movementsTotal) / take), + ), + }, + }; } - /** Withdraw a still-PENDING request. */ + /** + * Withdraw a request before anything moved. Once wagons have been delivered + * the request can only be completed or closed short — cancelling would erase + * the fact that a transfer happened. + */ async cancelRequest(id: string): Promise { const request = await this.findById(id); if (request.status !== WagonTransferRequestStatus.Pending) { throw new ConflictException( - `Only pending requests can be cancelled (this one is ${request.status.toLowerCase()})`, + request.status === WagonTransferRequestStatus.PartiallyFulfilled + ? 'Wagons have already moved against this request — close it short instead of cancelling' + : `Only pending requests can be cancelled (this one is ${request.status.toLowerCase().replace(/_/g, ' ')})`, ); } request.status = WagonTransferRequestStatus.Cancelled; diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts index 2ef7f00a5..bac10299d 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts @@ -12,13 +12,12 @@ import { import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { CurrentUser } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; -import { FleetManage, FleetView, StaffReference } from '../../common/booking-guards'; +import { FleetManage, StaffReference } from '../../common/booking-guards'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { CreateWagonDto } from './dto/create-wagon.dto'; import { ListWagonsQueryDto } from './dto/list-wagons-query.dto'; import { UpdateWagonDto } from './dto/update-wagon.dto'; import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto'; -import { ReorderWagonsDto } from './dto/reorder-wagons.dto'; import { BulkTransferWagonsDto } from './dto/bulk-transfer-wagons.dto'; import { BulkSetWagonStatusDto } from './dto/bulk-set-wagon-status.dto'; import { WagonsService } from './wagons.service'; @@ -103,17 +102,3 @@ export class WagonsController { return this.wagonsService.bulkSetStatus(dto); } } - -// Separate controller for train‑specific reorder (registered in module) -@Controller('trains/:trainId/reorder-wagons') -@FleetView(FREIGHT_PERMS.trains.view) -export class TrainWagonsReorderController { - constructor(private readonly wagonsService: WagonsService) {} - - @Post() - @FleetManage(FREIGHT_PERMS.trains.assignWagons) - @ApiOperation({ summary: 'Reorder wagons of a train' }) - reorder(@Param('trainId', ParseUUIDPipe) trainId: string, @Body() dto: ReorderWagonsDto) { - return this.wagonsService.reorderWagons(trainId, dto); - } -} diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.module.ts b/apps/edr-freight-api/src/modules/wagons/wagons.module.ts index 107a31e7e..8c8a0d11f 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.module.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.module.ts @@ -5,7 +5,8 @@ import { WagonMovement } from './entities/wagon-movement.entity'; import { WagonTransferRequest } from './entities/wagon-transfer-request.entity'; import { Train } from '../trains/entities/train.entity'; import { Yard } from '../rule-engine/entities/yard.entity'; -import { WagonsController, TrainWagonsReorderController } from './wagons.controller'; +import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; +import { WagonsController } from './wagons.controller'; import { WagonTransferRequestsController } from './wagon-transfer-requests.controller'; import { WagonsService } from './wagons.service'; import { WagonTransferRequestsService } from './wagon-transfer-requests.service'; @@ -19,10 +20,11 @@ import { WagonTransferRequestsService } from './wagon-transfer-requests.service' Train, Yard, ]), + // The transfer desk notifies the requester as instalments land. + NotificationInboxModule, ], controllers: [ WagonsController, - TrainWagonsReorderController, WagonTransferRequestsController, ], providers: [WagonsService, WagonTransferRequestsService], diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts index 188bf1783..99c28469f 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -11,7 +11,6 @@ import { CreateWagonDto } from './dto/create-wagon.dto'; import { ListWagonsQueryDto } from './dto/list-wagons-query.dto'; import { UpdateWagonDto } from './dto/update-wagon.dto'; import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto'; -import { ReorderWagonsDto } from './dto/reorder-wagons.dto'; import { BulkTransferWagonsDto } from './dto/bulk-transfer-wagons.dto'; import { BulkSetWagonStatusDto } from './dto/bulk-set-wagon-status.dto'; import { Wagon } from './entities/wagon.entity'; @@ -392,20 +391,4 @@ export class WagonsService { } } - async reorderWagons(_trainId: string, dto: ReorderWagonsDto): Promise { - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction(); - try { - for (let i = 0; i < dto.wagonIds.length; i++) { - await queryRunner.manager.update(Wagon, dto.wagonIds[i], { sequenceNumber: i + 1 }); - } - await queryRunner.commitTransaction(); - } catch (err) { - await queryRunner.rollbackTransaction(); - throw err; - } finally { - await queryRunner.release(); - } - } } 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..c0b3cc060 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', @@ -3944,7 +4013,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 +4064,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 +4461,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 +5370,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 +6003,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/data/contract-template-defaults.ts b/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts index 2c4e65ae1..01442db07 100644 --- a/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts +++ b/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts @@ -411,7 +411,7 @@ The governing law shall be the laws of the Federal Democratic Republic of Ethiop a( "effectiveness", "Contract Effectiveness", - `The contract shall come into full force and effect on the date when the contract is signed by the parties and witnesses.`, + `The contract shall come into full force and effect on the date when the contract is signed by both parties.`, ), ], }; @@ -550,7 +550,7 @@ Notwithstanding the above, the Service Provider may revise transport tariffs due a( "effectiveness", "Contract Effectiveness", - `The contract is valid once signed by both parties and witnesses.`, + `The contract is valid once signed by both parties.`, ), a( "duration", @@ -698,7 +698,7 @@ If terminated for cause, the terminating party must issue a 15-day written notic a( "effectiveness", "Contract Effectiveness", - `The contract is valid once signed by both parties and witnesses.`, + `The contract is valid once signed by both parties.`, ), a( "duration", @@ -838,7 +838,7 @@ Notwithstanding the above, the Service Provider may revise transport tariffs due a( "effectiveness", "Contract Effectiveness", - `The contract is valid once signed by both parties and witnesses.`, + `The contract is valid once signed by both parties.`, ), a( "duration", diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 537bac332..2924169eb 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -64,6 +64,9 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [ perm('a1000001-0001-4000-8000-000000000012', 'edr_freight_app:fleet:manage', 'Manage fleet'), perm('a1000001-0001-4000-8000-000000000013', 'edr_freight_app:admin', 'Freight administration'), perm('a1000001-0001-4000-8000-000000000024', 'edr_freight_app:bookings:create', 'Create booking'), + // Header alarm for the document-review deadline: its own key so only the + // position types that actually decide operation requests are alerted. + perm('a1000001-0001-4000-8000-000000000025', 'edr_freight_app:bookings:doc_review_alert', 'See document-review deadline alarm'), ]; /** @@ -96,6 +99,10 @@ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [ perm('a3000001-0001-4000-8000-00000000000e', 'edr_freight_app:contracts:clearance_et_actions', 'GL Ethiopia phased clearance actions'), perm('a3000001-0001-4000-8000-00000000000f', 'edr_freight_app:contracts:clearance_dj_actions', 'GL Djibouti phased clearance actions'), perm('a3000001-0001-4000-8000-000000000010', 'edr_freight_app:contracts:clearance_duty_advise', 'Advise contract duty/tax'), + // Hazardous contracts get two extra approval steps ahead of the normal chain. + // Each has its own permission so the two desks are genuinely separate people. + perm('a3000001-0001-4000-8000-000000000019', 'edr_freight_app:contracts:hazardous_approval_one', 'Hazardous approval — first review'), + perm('a3000001-0001-4000-8000-00000000001a', 'edr_freight_app:contracts:hazardous_approval_two', 'Hazardous approval — second review'), ]; // Existing per-slug view ids are kept as-is: position-type grants reference @@ -226,6 +233,12 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [ perm('e1b00001-0001-4000-8000-000000000005', 'edr_freight_app:wagons:transfer_request', 'Request wagon transfer'), perm('e1b00001-0001-4000-8000-000000000006', 'edr_freight_app:wagons:transfer_fulfill', 'Fulfil wagon transfer (OCC)'), perm('e1b00001-0001-4000-8000-000000000007', 'edr_freight_app:wagons:transfer_history_all', "View all staff's transfer history"), + // The transfer desk is its own screen, so it carries its own per-action keys — + // seeing the queue, withdrawing a request and short-closing one are separate + // grants from filing or fulfilling. + perm('e1b00001-0001-4000-8000-000000000008', 'edr_freight_app:wagons:transfer_view', 'View wagon transfer requests'), + perm('e1b00001-0001-4000-8000-000000000009', 'edr_freight_app:wagons:transfer_cancel', 'Withdraw a wagon transfer request'), + perm('e1b00001-0001-4000-8000-00000000000a', 'edr_freight_app:wagons:transfer_close_short', 'Close a transfer request short of the requested count'), perm('e1c00001-0001-4000-8000-000000000001', 'edr_freight_app:trains:view', 'View trains'), perm('e1c00001-0001-4000-8000-000000000002', 'edr_freight_app:trains:create', 'Create train'), perm('e1c00001-0001-4000-8000-000000000003', 'edr_freight_app:trains:update', 'Update train'), @@ -401,6 +414,7 @@ export const FREIGHT_PERMS = { reviewDocuments: 'edr_freight_app:bookings:review_documents', uploadClearanceOutput: 'edr_freight_app:bookings:upload_clearance_output', finalizeClearance: 'edr_freight_app:bookings:finalize_clearance', + docReviewAlert: 'edr_freight_app:bookings:doc_review_alert', }, contracts: { view: 'edr_freight_app:contracts:view', @@ -419,6 +433,8 @@ export const FREIGHT_PERMS = { approveLineStaff: 'edr_freight_app:contracts:approve_line_staff', approveDirector: 'edr_freight_app:contracts:approve_director', approveCeo: 'edr_freight_app:contracts:approve_ceo', + hazardousApprovalOne: 'edr_freight_app:contracts:hazardous_approval_one', + hazardousApprovalTwo: 'edr_freight_app:contracts:hazardous_approval_two', generateContract: 'edr_freight_app:contracts:generate_contract', signStaff: { bulk: 'edr_freight_app:contracts:sign_staff:bulk', @@ -514,6 +530,12 @@ export const FREIGHT_PERMS = { // executes the move). Distinct keys so OCC can hold fulfil without request. transferRequest: 'edr_freight_app:wagons:transfer_request', transferFulfill: 'edr_freight_app:wagons:transfer_fulfill', + /** Open the transfer-requests desk (list + detail). */ + transferView: 'edr_freight_app:wagons:transfer_view', + /** Withdraw a request that has not moved any wagon yet. */ + transferCancel: 'edr_freight_app:wagons:transfer_cancel', + /** End a request short — anyone who can fulfil may also do this. */ + transferCloseShort: 'edr_freight_app:wagons:transfer_close_short', // Admin: read every staffer's transfer history. Without it, a user only sees // their own (the /history endpoint uses the caller id, backend-enforced). transferHistoryAll: 'edr_freight_app:wagons:transfer_history_all', @@ -781,6 +803,9 @@ export const ROLE_PERMISSION_PRESETS = { operationsOfficer: [ FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.bookings.operations, + // They are the ones who accept/reject operation requests, so they are the + // ones the doc-review countdown is for. + FREIGHT_PERMS.bookings.docReviewAlert, FREIGHT_PERMS.trainScheduling.view, FREIGHT_PERMS.trainScheduling.create, FREIGHT_PERMS.trainScheduling.update, @@ -892,6 +917,12 @@ export const POSITION_PERMISSION_PRESETS = { ...ROLE_PERMISSION_PRESETS.director, ...ROLE_PERMISSION_PRESETS.operationsOfficer, FREIGHT_PERMS.allocation.manage, + // Customer desk: onboarding intake lands on the chief — open the customer + // list and approve/suspend a submitted profile. Deliberately NOT granted: + // create, update and password reset, which stay with the customer admins. + FREIGHT_PERMS.customers.view, + FREIGHT_PERMS.customers.verify, + FREIGHT_PERMS.customers.deactivate, ]), director: dedupe([...ROLE_PERMISSION_PRESETS.director]), ceo: dedupe([...ROLE_PERMISSION_PRESETS.ceo]), diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index bb4d024c8..5e2e48f32 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -1,4 +1,5 @@ import { + ArrowLeftRight, Boxes, Building2, Container, @@ -86,6 +87,7 @@ import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage"; import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage"; import FleetResourcePage from "./pages/fleet/FleetResourcePage"; +import WagonTransfersPage from "./pages/wagons/WagonTransfersPage"; import VehicleDetailPage from "./pages/fleet/VehicleDetailPage"; import DriverDetailPage from "./pages/fleet/DriverDetailPage"; import RoutesPage from "./pages/fleet/RoutesPage"; @@ -297,6 +299,15 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: [FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view], }, + { + label: "Wagon Transfers", + href: "/dashboard/wagon-transfers", + icon: , + permission: [ + FREIGHT_PERMS.wagons.transferView, + FREIGHT_PERMS.wagons.view, + ], + }, { label: "Vehicles", href: "/dashboard/vehicles", @@ -1180,6 +1191,19 @@ const App = () => { } /> + + + + } + /> { } /> + + + + } + /> , + side: "before" | "after", +): string => + tokens + .filter((t) => + side === "before" ? t.op !== "added" : t.op !== "removed", + ) + .map((t) => t.text) + .join(""); + +describe("diffWords", () => { + it("marks only the words that actually changed", () => { + const tokens = diffWords( + "The carrier shall deliver within 30 days.", + "The carrier shall deliver within 45 days.", + ); + + expect(tokens.filter((t) => t.op === "removed").map((t) => t.text)).toEqual([ + "30", + ]); + expect(tokens.filter((t) => t.op === "added").map((t) => t.text)).toEqual([ + "45", + ]); + }); + + it("reconstructs both sides losslessly, whitespace included", () => { + const before = "Payment is due\nwithin ten (10) working days."; + const after = "Payment is due\nwithin five (5) working days of invoice."; + const tokens = diffWords(before, after); + + expect(rebuild(tokens, "before")).toBe(before); + expect(rebuild(tokens, "after")).toBe(after); + }); + + it("reports nothing changed for identical text", () => { + const tokens = diffWords("Same clause.", "Same clause."); + expect(tokens.every((t) => t.op === "same")).toBe(true); + }); + + it("handles a body being emptied or written from scratch", () => { + expect(rebuild(diffWords("Some clause.", ""), "after")).toBe(""); + expect(rebuild(diffWords("", "Brand new clause."), "before")).toBe(""); + }); + + it("falls back to a whole-block replace on pathological input", () => { + // Past MAX_TOKENS the LCS table is skipped; the change must still be + // reported truthfully rather than silently dropped. + const before = Array.from({ length: 2000 }, (_, i) => `a${i}`).join(" "); + const after = Array.from({ length: 2000 }, (_, i) => `b${i}`).join(" "); + const tokens = diffWords(before, after); + + expect(rebuild(tokens, "before")).toBe(before); + expect(rebuild(tokens, "after")).toBe(after); + }); +}); diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ArticleBodyDiff.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ArticleBodyDiff.tsx new file mode 100644 index 000000000..1f3145390 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ArticleBodyDiff.tsx @@ -0,0 +1,168 @@ +import { useMemo, useState } from "react"; +import { Box, Button, Group, Text } from "@mantine/core"; +import { ChevronDown, ChevronRight } from "lucide-react"; + +type Op = "same" | "added" | "removed"; +interface Token { + op: Op; + text: string; +} + +/** Split on whitespace but KEEP it, so rebuilt text preserves its spacing. */ +function tokenize(text: string): string[] { + return text.split(/(\s+)/).filter((t) => t !== ""); +} + +/** + * Word-level diff via the classic LCS table. + * + * ponytail: O(n·m) time and memory over word counts. Contract articles are + * paragraphs (hundreds of words), so this is microseconds; the guard below + * bails to a whole-block replace if an article ever gets pathological. Swap in + * a real diff library only if that guard starts firing. + */ +const MAX_TOKENS = 1200; + +export function diffWords(before: string, after: string): Token[] { + const a = tokenize(before); + const b = tokenize(after); + + if (a.length > MAX_TOKENS || b.length > MAX_TOKENS) { + return [ + { op: "removed", text: before }, + { op: "added", text: after }, + ]; + } + + // lcs[i][j] = length of the longest common subsequence of a[i:] and b[j:]. + const lcs: number[][] = Array.from({ length: a.length + 1 }, () => + new Array(b.length + 1).fill(0), + ); + for (let i = a.length - 1; i >= 0; i--) { + for (let j = b.length - 1; j >= 0; j--) { + lcs[i][j] = + a[i] === b[j] + ? lcs[i + 1][j + 1] + 1 + : Math.max(lcs[i + 1][j], lcs[i][j + 1]); + } + } + + const tokens: Token[] = []; + // Merge runs of the same op so the output is spans, not one node per word. + const push = (op: Op, text: string) => { + const last = tokens[tokens.length - 1]; + if (last && last.op === op) last.text += text; + else tokens.push({ op, text }); + }; + + let i = 0; + let j = 0; + while (i < a.length && j < b.length) { + if (a[i] === b[j]) { + push("same", a[i]); + i++; + j++; + } else if (lcs[i + 1][j] >= lcs[i][j + 1]) { + push("removed", a[i]); + i++; + } else { + push("added", b[j]); + j++; + } + } + while (i < a.length) push("removed", a[i++]); + while (j < b.length) push("added", b[j++]); + + return tokens; +} + +const OP_STYLE: Record = { + same: {}, + added: { + background: "var(--mantine-color-teal-1)", + color: "var(--mantine-color-teal-9)", + borderRadius: 3, + }, + removed: { + background: "var(--mantine-color-red-1)", + color: "var(--mantine-color-red-9)", + borderRadius: 3, + textDecoration: "line-through", + }, +}; + +/** + * Inline before/after of an edited article body: removed words struck through + * in red, inserted words highlighted in green. Collapsed by default — a + * revision list stays scannable, and the full text is one click away. + */ +export function ArticleBodyDiff({ + fromBody, + toBody, +}: { + fromBody: string; + toBody: string; +}) { + const [open, setOpen] = useState(false); + const tokens = useMemo( + () => (open ? diffWords(fromBody, toBody) : []), + [open, fromBody, toBody], + ); + + return ( + + + + {open && ( + + + {tokens.map((token, index) => ( + + {token.text} + + ))} + + + + + + Removed + + + + + + Added + + + + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/BookingChangesRequestedAlert.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/BookingChangesRequestedAlert.tsx new file mode 100644 index 000000000..16b1ab602 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/BookingChangesRequestedAlert.tsx @@ -0,0 +1,132 @@ +import { Alert, Button, Group, Paper, Stack, Text } from "@mantine/core"; +import { DateInput } from "@mantine/dates"; +import { AlertTriangle, Send } from "lucide-react"; +import { useState } from "react"; +import { Link } from "react-router-dom"; +import toast from "react-hot-toast"; + +import { bookingsService } from "@/services/bookings.service"; + +export interface BookingChangesRequestedAlertProps { + bookingId: string; + reference?: string | null; + /** Operations' note — what has to change before this can go back to them. */ + note?: string | null; + /** Shipment day the booking currently holds; the resubmit default. */ + scheduledDate?: string | null; + /** GL Ethiopia owns customs bookings, so only they get the resubmit control. */ + canResubmit: boolean; + onResubmitted?: () => void; +} + +/** + * Operations sent a GL-created booking back for changes. + * + * The customer cannot act on this — GL created the booking on their behalf — so + * the note and the way out both live here, on the page GL works from. Resubmit + * re-requests operation on the chosen shipment day; the server re-checks the day + * has a departure that can carry the cargo and refuses with the reason if not. + */ +export function BookingChangesRequestedAlert({ + bookingId, + reference, + note, + scheduledDate, + canResubmit, + onResubmitted, +}: BookingChangesRequestedAlertProps) { + const [day, setDay] = useState( + scheduledDate ? new Date(scheduledDate) : null, + ); + const [sending, setSending] = useState(false); + + const resubmit = async () => { + if (!day) return; + setSending(true); + try { + await bookingsService.proceedToOperation(bookingId, day.toISOString()); + toast.success("Sent back to Operations for review"); + onResubmitted?.(); + } catch { + // The http interceptor already toasts the server's own reason (no + // departure that day, no wagon that can carry the cargo, export train + // full…) — a second toast here would just duplicate it. + } finally { + setSending(false); + } + }; + + return ( + } + title={`Operations returned booking ${reference ?? ""} for changes`.trim()} + > + + {note ? ( + + + What Operations asked for + + + {note} + + + ) : ( + + Operations returned this booking without a note — contact them for + the detail before resubmitting. + + )} + + + This booking was created by GL Ethiopia, so the customer cannot fix it. + Make the correction Operations asked for, then send it back for review.{" "} + + Open the booking → + + + + {canResubmit ? ( + + setDay(v ? new Date(v) : null)} + minDate={new Date()} + size="sm" + w={230} + /> + + + ) : null} + + + ); +} + +export default BookingChangesRequestedAlert; diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceDocumentVersionsModal.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceDocumentVersionsModal.tsx new file mode 100644 index 000000000..0015ea47b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceDocumentVersionsModal.tsx @@ -0,0 +1,237 @@ +import { + Badge, + Button, + Group, + Loader, + Modal, + Paper, + Stack, + Text, + Textarea, +} from "@mantine/core"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Download, Eye, History, Upload } from "lucide-react"; +import { useState } from "react"; +import toast from "react-hot-toast"; + +import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone"; +import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; +import { contractsService } from "@/services/contracts.service"; +import { isViewable } from "@edr/ui-common"; + +import { downloadBookingFile, fetchViewableFile } from "@/services/files.service"; + +export interface ClearanceDocumentVersionsModalProps { + contractId: string; + /** The document being inspected; null closes the modal. */ + doc: { fileKey: string; label: string } | null; + onClose: () => void; + /** Hide the replace form (finalized clearance, read-only viewers). */ + canReplace?: boolean; + onReplaced?: () => void; + onView?: (file: { name: string; url: string }) => void; +} + +const fmt = (iso: string) => + new Date(iso).toLocaleString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + +/** + * Version history of one clearance document, and the way to add a version. + * + * Staff can correct a document without bouncing it back to the customer, but + * the customer's original is never overwritten — it drops down this list as a + * superseded version, stamped with who replaced it and why. The corrected file + * comes back unreviewed, so it still has to be approved before finalizing. + */ +export function ClearanceDocumentVersionsModal({ + contractId, + doc, + onClose, + canReplace = false, + onReplaced, + onView, +}: ClearanceDocumentVersionsModalProps) { + const queryClient = useQueryClient(); + const [file, setFile] = useState(null); + const [reason, setReason] = useState(""); + + const { data: versions = [], isLoading } = useQuery({ + queryKey: ["contracts", "clearance-doc-versions", contractId, doc?.fileKey], + queryFn: () => + contractsService.getClearanceDocumentVersions(contractId, doc!.fileKey), + enabled: Boolean(doc), + }); + + const replace = useMutation({ + mutationFn: () => + contractsService.replaceClearanceDocument( + contractId, + doc!.fileKey, + file!, + reason.trim(), + ), + onSuccess: async () => { + toast.success("Document replaced — the previous version is kept on file"); + setFile(null); + setReason(""); + await queryClient.invalidateQueries({ + queryKey: ["contracts", "clearance-doc-versions", contractId, doc?.fileKey], + }); + await queryClient.invalidateQueries({ + queryKey: QUERY_KEYS.CONTRACTS.clearance(contractId), + }); + onReplaced?.(); + }, + }); + + const close = () => { + setFile(null); + setReason(""); + onClose(); + }; + + return ( + + + {doc?.label ?? "Document"} — version history + + } + > + + {isLoading ? ( + + + + ) : versions.length === 0 ? ( + + Nothing uploaded under this document yet. + + ) : ( + + {versions.map((v, index) => ( + + + + + + {v.name} + + {v.isCurrent ? ( + + Current + + ) : index === versions.length - 1 ? ( + + Original + + ) : ( + + Superseded + + )} + + + Uploaded {fmt(v.uploadedAt)} + {v.replacedAt ? ` · replaced ${fmt(v.replacedAt)}` : ""} + + {v.replaceReason ? ( + + Reason: {v.replaceReason} + + ) : null} + + + {isViewable({ name: v.name, url: "" }) && onView ? ( + + ) : null} + + + + + ))} + + )} + + {canReplace ? ( + + + + Replace this document + + + Use this for a correction you can make yourself. The customer's + copy stays in the history above, and the new file has to be + approved before clearance is finalized. + + +