diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index 92958b364..3e086101c 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); 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/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/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-AddContractHazardDeclaration.ts b/apps/edr-freight-api/src/migrations/2920000000000-AddContractHazardDeclaration.ts new file mode 100644 index 000000000..bef24c3e4 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2920000000000-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 AddContractHazardDeclaration2920000000000 + implements MigrationInterface +{ + name = 'AddContractHazardDeclaration2920000000000'; + + 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/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..4072a462e 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.`; 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..39b6f4145 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,124 @@ 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); + }); +}); 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..290b46433 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 { @@ -529,7 +530,13 @@ export class BookingPricingService { const usedRatesMap = new Map(); const warnings: string[] = []; const blocked: string[] = []; - const wagonCount = await this.resolveWagonCount(booking); + // Bulk bookings carry no container lines, so the container-based wagon + // aggregate is 0 for them — a PER_WAGON bulk rate would bill nothing. Use + // the tonnage-derived estimate instead (the eval input already carries it + // for saved bookings; a preview derives it here). + const wagonCount = isBulk + ? Number(evalInput.bulkWagons ?? 0) || (await this.bulkWagonCount(booking)) || 0 + : await this.resolveWagonCount(booking); for (const container of evalInput.containers) { const rate = this.pickRate( @@ -600,7 +607,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,11 +618,17 @@ 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 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 @@ -719,6 +735,7 @@ export class BookingPricingService { quantity = containerCount; break; case 'PER_TON': + case 'PER_ITEM': quantity = bulkTons; break; case 'FLAT': @@ -804,6 +821,7 @@ export class BookingPricingService { return 1; case 'PER_CONTAINER': case 'PER_TON': + case 'PER_ITEM': default: return quantity; } @@ -860,6 +878,7 @@ export class BookingPricingService { case 'PER_WAGON': return unitValue * wagonCount; case 'PER_TON': + case 'PER_ITEM': return unitValue * quantity; case 'FLAT': return unitValue; @@ -1044,7 +1063,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 +1100,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/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/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index d76391f42..88cb2149b 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 @@ -84,6 +84,14 @@ 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; @@ -301,11 +309,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, @@ -349,6 +370,8 @@ export class ContractClearanceService { linkedBookingId: cycle?.bookingId ?? null, linkedBookingReference, linkedBookingStatus, + linkedBookingReviewNote, + linkedBookingScheduledDate, dutyAdvice, workflowFiles, t1, 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-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-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..5a22d2fb9 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, @@ -530,12 +531,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 +943,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 +992,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 +1058,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 +1087,7 @@ export class ContractTransitionService { signerDisplayName, signedAt: new Date(), signatureFileId: fileRecord.id, + stampFileId: stampRecord?.id ?? null, consentText: dto.consentText ?? null, }); @@ -1053,7 +1126,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 +1160,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 +1217,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.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..6b16d5647 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,70 @@ 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, + }) + .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; @@ -434,7 +499,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 +510,7 @@ export class ContractsRepository extends BaseRepository { ): Promise { return this.dataSource.getRepository(ContractSignature).findOne({ where: { contractId, role }, - relations: ['signatureFile'], + relations: ['signatureFile', 'stampFile'], }); } 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..02459f2a5 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,7 @@ 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 { FileRecord } from '../files/entities/file.entity'; /** Paginated contract list: flat `total` (backoffice) + `meta` block (portal). */ @@ -155,6 +157,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 +224,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 @@ -302,6 +343,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', @@ -474,6 +519,13 @@ export class ContractsService { paymentCurrency: dto.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, 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..6e68765f1 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; @@ -228,6 +230,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/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/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/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/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.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index 700d6366c..37166f544 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); @@ -380,11 +405,12 @@ 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({ @@ -562,12 +588,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, + 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/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.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 614afe275..da8982d95 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 @@ -6659,6 +6659,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 +6671,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, @@ -6779,8 +6785,7 @@ 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: (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 +6793,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)), @@ -6859,7 +6875,12 @@ export class TrainSchedulingService { })) ?? [], }; }) - .concat(emptyConsistWagons), + .concat(emptyConsistWagons) + .sort((a, b) => + schedule.reverseWagonOrder + ? b.sequenceNo - a.sequenceNo + : a.sequenceNo - b.sequenceNo, + ), } : null, bookings: 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/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/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..05162ea23 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,7 @@ 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 { WagonsController } from './wagons.controller'; import { WagonTransferRequestsController } from './wagon-transfer-requests.controller'; import { WagonsService } from './wagons.service'; import { WagonTransferRequestsService } from './wagon-transfer-requests.service'; @@ -22,7 +22,6 @@ import { WagonTransferRequestsService } from './wagon-transfer-requests.service' ], 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/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..65ca3bcde 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 @@ -401,6 +408,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 +427,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', @@ -781,6 +791,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, 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/ContractApprovalStepsCard.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx index 25f877de3..3747c2d2d 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from "react"; -import { Check, ShieldCheck, X } from "lucide-react"; +import { Check, Flame, ShieldCheck, X } from "lucide-react"; import { Stack, Group, @@ -14,10 +14,22 @@ import { import type { Freight } from "@edr/types"; import { formatContractApprovalProgress } from "@/features/contracts/contract-approval-progress"; +import { HazardDeclarationPanel } from "./HazardDeclarationPanel"; import { SectionCard } from "@/components/bookings/detail/SectionCard"; import type { useContractMutations } from "@/hooks/contracts/useContracts"; import { useAuth } from "@/auth/useAuth"; -import { canApproveContractStep } from "@/lib/permissions"; +import { + canApproveContractStep, + CONTRACT_APPROVAL_ROLE_LABELS, + HAZARDOUS_APPROVAL_ROLE_PERMISSION, +} from "@/lib/permissions"; + +/** Chain roles that exist only because the contract carries dangerous goods. */ +const isHazardStep = (requiredRole: string): boolean => + requiredRole in HAZARDOUS_APPROVAL_ROLE_PERMISSION; + +const roleLabel = (requiredRole: string): string => + CONTRACT_APPROVAL_ROLE_LABELS[requiredRole] ?? requiredRole; type Mutations = ReturnType; @@ -126,7 +138,7 @@ export function ContractApprovalStepsCard({ const subtitle = summary.detail || (nextPending - ? `Next: ${nextPending.requiredRole} · step ${nextPending.stepOrder}` + ? `Next: ${roleLabel(nextPending.requiredRole)} · step ${nextPending.stepOrder}` : steps.length ? "All steps complete" : "Accept submission to begin"); @@ -196,7 +208,7 @@ export function ContractApprovalStepsCard({ You are about to approve the{" "} - {pendingStep?.requiredRole} + {roleLabel(pendingStep?.requiredRole ?? "")} {" "} step for contract{" "} @@ -204,6 +216,9 @@ export function ContractApprovalStepsCard({ . This action cannot be undone from this screen. + {pendingStep && isHazardStep(pendingStep.requiredRole) && ( + + )} @@ -333,6 +348,7 @@ function StepRow({ : isNext ? "edr-green" : "gray"; + const hazard = isHazardStep(step.requiredRole); return ( @@ -371,9 +395,23 @@ function StepRow({ {step.stepOrder} - - {step.requiredRole} - + + + {roleLabel(step.requiredRole)} + + {hazard && ( + } + style={{ flexShrink: 0 }} + > + Hazmat + + )} + {step.note && ( {step.note} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractDocumentEditorModal.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractDocumentEditorModal.tsx index 95f0ce860..e9e7e4712 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractDocumentEditorModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractDocumentEditorModal.tsx @@ -9,7 +9,7 @@ import { Group, Loader, Modal, - Select, + // Select, // ponytail: unused now the validity dropdown below is commented out Stack, Text, Textarea, @@ -25,6 +25,7 @@ import { Plus, Trash2, } from "lucide-react"; +import { DateInput } from "@mantine/dates"; import type { Freight } from "@edr/types"; import { contractsService } from "@/services/contracts.service"; @@ -75,8 +76,10 @@ export function ContractDocumentEditorModal({ onClose, contractId, mode, - validityOptions = [], - validityLoading = false, + // ponytail: validityOptions/validityLoading fed the now-commented dropdown + // above — caller still passes them, left unread here for a quick revert. + // validityOptions = [], + // validityLoading = false, accepting = false, saving = false, onAccept, @@ -93,7 +96,12 @@ export function ContractDocumentEditorModal({ const [documentTitle, setDocumentTitle] = useState(""); const [whereasClauses, setWhereasClauses] = useState([]); const [articles, setArticles] = useState([]); - const [validityDays, setValidityDays] = useState(null); + // const [validityDays, setValidityDays] = useState(null); + // ponytail: client keeps flip-flopping the validity requirement — swapped + // the validity dropdown for explicit start/end dates, kept above commented + // instead of deleted so it's a one-line revert if they flip back. + const [validityStart, setValidityStart] = useState(null); + const [validityEnd, setValidityEnd] = useState(null); // Seed the editor from the loaded draft whenever the dialog (re)opens. useEffect(() => { @@ -110,11 +118,11 @@ export function ContractDocumentEditorModal({ }, [opened, draft]); // Default validity to the first configured option (accept mode). - useEffect(() => { - if (mode === "accept" && !validityDays && validityOptions.length > 0) { - setValidityDays(validityOptions[0].value); - } - }, [mode, validityDays, validityOptions]); + // useEffect(() => { + // if (mode === "accept" && !validityDays && validityOptions.length > 0) { + // setValidityDays(validityOptions[0].value); + // } + // }, [mode, validityDays, validityOptions]); // Editing rights belong to the approver whose turn it is, so the server // decides per-caller — the client cannot derive this from the contract alone. @@ -169,8 +177,13 @@ export function ContractDocumentEditorModal({ const submit = () => { const snapshot = buildSnapshot(); if (mode === "accept") { - const days = Number(validityDays); - if (!days) return; + // const days = Number(validityDays); + // if (!days) return; + if (!validityStart || !validityEnd) return; + const days = Math.ceil( + (validityEnd.getTime() - validityStart.getTime()) / (24 * 60 * 60 * 1000), + ); + if (days <= 0) return; onAccept?.(days, snapshot); } else { onSaveEdit?.(snapshot); @@ -181,7 +194,8 @@ export function ContractDocumentEditorModal({ const canSubmit = hasArticles && !locked && - (mode === "edit" || Boolean(validityDays)) && + // (mode === "edit" || Boolean(validityDays)) && + (mode === "edit" || Boolean(validityStart && validityEnd)) && !submitting; return ( @@ -375,7 +389,10 @@ export function ContractDocumentEditorModal({ {mode === "accept" && ( <> - {validityOptions.length > 0 ? ( + {/* ponytail: client keeps changing this requirement — swapped + the validity-period dropdown for explicit start/end dates, + left the old block commented instead of deleted. */} + {/* {validityOptions.length > 0 ? ( readFile(e.currentTarget.files?.[0])} + /> + + {value ? ( + + + + Company stamp + + + + {fileName ?? "Stamp attached"} + + + This stamp is applied next to your signature on the contract. + + + + + + + + + ) : ( + { + e.preventDefault(); + setDragging(true); + }} + onDragLeave={() => setDragging(false)} + onDrop={(e) => { + e.preventDefault(); + setDragging(false); + readFile(e.dataTransfer.files?.[0]); + }} + style={{ + borderColor: dragging + ? "var(--mantine-color-edr-green-6)" + : undefined, + borderStyle: "dashed", + backgroundColor: dragging + ? "var(--mantine-color-edr-green-0)" + : undefined, + cursor: "pointer", + }} + > + + + + Upload company stamp + + + {description} Drop an image here or click to browse — PNG or JPG, + up to {MAX_STAMP_MB} MB. + + + + )} + + {error && ( + + {error} + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/total.ts b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/total.ts index 2b84d9483..95b677c1d 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/total.ts +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/total.ts @@ -172,7 +172,7 @@ export function computeGlShipmentTotal( // it (the commodity needs lashing). Per-ton scales by tonnage; per-wagon // depends on the wagon capacity the train stocks — shown at real pricing. const lashing = items.find((i) => i.conditionalOn === "has_lashing"); - if (lashing && lashing.unit === "per_ton") { + if (lashing && (lashing.unit === "per_ton" || lashing.unit === "per_item")) { const tons = q.bulkQuantity; if (tons > 0) { lines.push({ @@ -199,7 +199,7 @@ export function computeGlShipmentTotal( cl.unit === "per_wagon" ? Math.ceil(boxes * (cl.containerSize === "40ft" ? 1 : 0.5)) : boxes; - } else if (cl.unit === "per_ton") { + } else if (cl.unit === "per_ton" || cl.unit === "per_item") { qty = q.bulkQuantity; } else if (cl.unit === "flat") { qty = 1; diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx index 1e44c5268..2ad4e1f28 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx @@ -23,6 +23,7 @@ import { import { type ReactNode } from "react"; import { useNavigate } from "react-router-dom"; +import DocReviewAlertButton from "@/features/bookingWindows/DocReviewAlertButton"; import NotificationBellContainer from "@/features/notifications/NotificationBellContainer"; import type { PageMeta } from "./types"; @@ -114,8 +115,12 @@ const FreightDashboardHeader = ({ - {/* Right: actions + avatar */} + {/* Right: actions + avatar. The doc-review alarm leads the group — it + only renders in the last half of a review phase that still has + undecided requests, so it never competes for space otherwise. */} + + diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index d08cac603..48c581519 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -156,6 +156,8 @@ export const URL_CONSTANTS = { `/bookings/${id}/clearance/ro-amendment`, CLEARANCE_EXPORT_RELEASE: (id: string) => `/bookings/${id}/clearance/export-release`, + // Re-request operation after Operations sent the booking back for changes. + CLEARANCE_PROCEED: (id: string) => `/bookings/${id}/clearance/proceed`, CLEARANCE_ET_QUEUE: "/bookings/clearance/et-queue", CLEARANCE_DJ_QUEUE: "/bookings/clearance/dj-queue", }, @@ -294,6 +296,7 @@ export const URL_CONSTANTS = { `/train-scheduling/schedules/${id}/run-allocation`, DOC_REVIEW_COMPLETE: (id: string) => `/train-scheduling/schedules/${id}/doc-review-complete`, + DOC_REVIEW_ALERT: "/train-scheduling/doc-review-alert", ASSIGN_UNASSIGNED_BOOKING: (id: string) => `/train-scheduling/schedules/${id}/assign-unassigned-booking`, BOOKING_WINDOW: (id: string) => diff --git a/apps/edr-freight-web/backoffice/src/features/bookingWindows/DocReviewAlertButton.tsx b/apps/edr-freight-web/backoffice/src/features/bookingWindows/DocReviewAlertButton.tsx new file mode 100644 index 000000000..16af5b6ab --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/features/bookingWindows/DocReviewAlertButton.tsx @@ -0,0 +1,125 @@ +import { Text, Tooltip, UnstyledButton } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { AlertTriangle, ChevronRight } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { useAuth } from "@/auth/useAuth"; +import { canSeeDocReviewAlert } from "@/lib/permissions"; +import { api } from "@/services/api"; + +/** + * Booking-request statuses that count as "nobody decided yet". These are the + * exact statuses the window engine expires when document review ends + * (findUnacceptedForRouteDay), so the deep-linked list shows precisely the + * requests the countdown is warning about. + */ +const UNDECIDED_STATUSES = [ + "OPERATION_REQUESTED", + "OPERATION_REQUEST_PENDING", + "OPERATION_CHANGES_REQUESTED", + "OPERATION_PRICE_PENDING_CONFIRM", +].join(","); + +const pendingRequestsHref = (tradeDirection: string) => + `/dashboard/booking-requests?statuses=${UNDECIDED_STATUSES}&tradeDirection=${tradeDirection}`; + +/** mm:ss (or h:mm:ss past an hour), fixed width so the pill never jitters. */ +function formatRemaining(ms: number): string { + const total = Math.max(0, Math.floor(ms / 1000)); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const seconds = total % 60; + const mm = String(minutes).padStart(2, "0"); + const ss = String(seconds).padStart(2, "0"); + return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`; +} + +/** + * Header alarm for the document-review deadline. Appears only once the review + * phase is half spent AND requests are still undecided — everything still + * pending when the clock runs out is expired automatically, so this is the last + * call to accept or reject. Clicking opens the booking requests already + * filtered to those undecided import requests. + */ +export default function DocReviewAlertButton() { + const navigate = useNavigate(); + const { user } = useAuth(); + + const { data: alert } = useQuery({ + ...api.trainScheduling.docReviewAlert.queryOptions(), + // Dedicated permission: only the position types granted it are alarmed. + enabled: canSeeDocReviewAlert(user), + // The window engine ticks every 10s; a minute is close enough for a header + // chip — the countdown itself runs locally. + refetchInterval: 60_000, + }); + + const deadlineMs = alert ? new Date(alert.docReviewEndsAt).getTime() : 0; + const [remaining, setRemaining] = useState(() => deadlineMs - Date.now()); + + useEffect(() => { + if (!deadlineMs) return; + const tick = () => setRemaining(deadlineMs - Date.now()); + tick(); + const id = window.setInterval(tick, 1000); + return () => window.clearInterval(id); + }, [deadlineMs]); + + if (!alert) return null; + // Half the review phase has to be gone before staff are alarmed — a 30-minute + // review warns with 15 minutes left. + const halfMs = (Math.max(alert.docReviewMinutes, 1) * 60_000) / 2; + if (remaining <= 0 || remaining > halfMs) return null; + + const requestLabel = alert.pendingCount === 1 ? "request" : "requests"; + + return ( + + navigate(pendingRequestsHref(alert.tradeDirection))} + aria-label={`${alert.pendingCount} import booking ${requestLabel} awaiting a decision — document review ends in ${formatRemaining(remaining)}`} + className="group flex h-9 shrink-0 items-center gap-2 rounded-full border border-red-600/60 bg-red-600 pl-2.5 pr-2 text-white shadow-[0_2px_10px_rgba(220,38,38,0.35)] transition-transform hover:scale-[1.02] hover:bg-red-700" + > + {/* Live dot: a ping ring behind a solid core, so the pill reads as + active without animating the whole chip. */} + + + + + + + + + {alert.pendingCount} undecided + + + + {formatRemaining(remaining)} + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts b/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts index ae3b9d4b8..378ff1fb8 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts @@ -119,7 +119,13 @@ export const useCargoLeafOptions = (enabled = true) => const code = String(row.code ?? "").trim(); const label = name && code ? `${name} (${code})` : name || code || String(row.id); - return { label, value: String(row.id) }; + // The commodity's unit of measure rides along so the rate form can + // offer per-item units for counted (break-bulk) commodities. + return { + label, + value: String(row.id), + unitOfMeasure: String(row.unitOfMeasure ?? ""), + }; }); }, }); diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index 389719964..88b5872d3 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -26,6 +26,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", @@ -45,6 +46,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", @@ -441,6 +444,22 @@ const CONTRACT_APPROVE_ROLE_PERMISSION: Record = { CEO: FREIGHT_PERMS.contracts.approveCeo, }; +/** + * The two hazardous-goods steps prepended to a hazardous contract's chain. + * They are not position types — they authorize purely on their own permission, + * exactly as the API's HAZARDOUS_APPROVAL_ROLE_PERMISSION does. + */ +export const HAZARDOUS_APPROVAL_ROLE_PERMISSION: Record = { + HAZARDOUS_APPROVAL_ONE: FREIGHT_PERMS.contracts.hazardousApprovalOne, + HAZARDOUS_APPROVAL_TWO: FREIGHT_PERMS.contracts.hazardousApprovalTwo, +}; + +/** Display label for an approval step's role (hazardous steps get real names). */ +export const CONTRACT_APPROVAL_ROLE_LABELS: Record = { + HAZARDOUS_APPROVAL_ONE: "Hazardous review — first approver", + HAZARDOUS_APPROVAL_TWO: "Hazardous review — second approver", +}; + /** * Can this user action a contract approval step requiring `requiredRole`? * @@ -463,6 +482,10 @@ export function canApproveContractStep( if (!user || !requiredRole) return false; if (isFreightApprovalAdmin(user)) return true; + // Hazardous steps are permission-only — no position type stands in for them. + const hazardousPermission = HAZARDOUS_APPROVAL_ROLE_PERMISSION[requiredRole]; + if (hazardousPermission) return hasPermission(user, hazardousPermission); + const positionTypes = getPositionTypeKeys(user); if (positionTypes.includes(requiredRole)) return true; @@ -477,6 +500,17 @@ export function canAccessBookings(user: AuthUser | null | undefined): boolean { return hasPermission(user, FREIGHT_PERMS.bookings.view); } +/** + * Sees the header countdown warning that document review is about to end with + * requests still undecided. Its own permission — granted per position type, so + * only the desks that act on those requests get alarmed. + */ +export function canSeeDocReviewAlert( + user: AuthUser | null | undefined, +): boolean { + return hasPermission(user, FREIGHT_PERMS.bookings.docReviewAlert); +} + export function canAccessContracts(user: AuthUser | null | undefined): boolean { return hasPermission(user, FREIGHT_PERMS.contracts.view); } diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx index 8ca532819..4aee1e32a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx @@ -27,8 +27,8 @@ import { User, X, } from "lucide-react"; -import { useCallback, useMemo, useRef, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu"; @@ -123,14 +123,25 @@ function formatDate(value: string | null | undefined): string { export default function BookingRequestsPage() { const navigate = useNavigate(); + // Deep links land here pre-filtered (?statuses=A,B&tradeDirection=IMPORT) — + // the header's document-review alarm opens exactly the undecided requests it + // is counting down for. Read once as the initial state so staff can then + // change the filters like any other visit. + const [searchParams] = useSearchParams(); const { pagination, setPagination } = usePagination({ pageSize: 10 }); const [query, setQuery] = useState(""); const [debouncedQuery] = useDebouncedValue(query, 300); // Booking kind is a filter now — one list holds both kinds (null = "all"). const [kindFilter, setKindFilter] = useState(null); // Filter controls (empty/null = "all"). - const [statusFilter, setStatusFilter] = useState([]); - const [directionFilter, setDirectionFilter] = useState(null); + const paramStatuses = searchParams.get("statuses") ?? ""; + const paramDirection = searchParams.get("tradeDirection"); + const [statusFilter, setStatusFilter] = useState(() => + paramStatuses.split(",").filter(Boolean), + ); + const [directionFilter, setDirectionFilter] = useState( + paramDirection, + ); const [freightTypeFilter, setFreightTypeFilter] = useState(null); const [paymentStatusFilter, setPaymentStatusFilter] = useState(null); const [ownershipFilter, setOwnershipFilter] = useState(null); @@ -150,6 +161,15 @@ export default function BookingRequestsPage() { }, 400); }, []); + // Follow the URL when a deep link arrives while the page is already open + // (clicking the header alarm from this very list). Same-value writes are + // dropped so a manual filter change is never undone. + useEffect(() => { + const next = paramStatuses.split(",").filter(Boolean); + setStatusFilter((prev) => (prev.join(",") === next.join(",") ? prev : next)); + setDirectionFilter(paramDirection); + }, [paramStatuses, paramDirection]); + const filter: BookingListFilter = useMemo(() => { return { page: pagination.pageIndex + 1, diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx index 1ed7d71ec..68b3443c8 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx @@ -35,6 +35,7 @@ import { isDjiboutiGl, } from "@/lib/permissions"; +import { BookingChangesRequestedAlert } from "@/components/contracts/BookingChangesRequestedAlert"; import { ClearanceOpsTabs } from "@/components/contracts/ClearanceOpsTabs"; import { PageContainer } from "@/components/page/PageContainer"; import { PageHeader } from "@/components/page/PageHeader"; @@ -137,10 +138,15 @@ export default function ContractClearanceDetailPage() { // The GL-created booking expired unpaid — the slot is free again and GL // rebooks on the customer's behalf (customs bookings are never self-booked). const bookingExpired = clearance?.linkedBookingStatus === "EXPIRED"; - const canRebook = - bookingExpired && + // Operations sent the GL-created booking back. GL owns customs bookings, so + // the note and the resubmit belong here, not in the customer's portal. + const bookingNeedsChanges = + clearance?.linkedBookingStatus === "OPERATION_CHANGES_REQUESTED"; + const isGlBookingOwner = hasPermission(user, FREIGHT_PERMS.contracts.createBooking) && !isDjiboutiGl(user); + const canResubmitBooking = bookingNeedsChanges && isGlBookingOwner; + const canRebook = bookingExpired && isGlBookingOwner; const rebookHref = linkedBookingId ? `${bookingHref}?copyFrom=${linkedBookingId}` : bookingHref; @@ -294,6 +300,19 @@ export default function ContractClearanceDetailPage() { ) : null} + ) : bookingNeedsChanges && linkedBookingId ? ( + { + void refetch(); + void refetchContract(); + refetchBookingMilestonesIfLinked(); + }} + /> ) : bookingAlreadyCreated ? ( ) : null} + {contract.isHazardous ? ( + + + + ) : null} {(contract.cargoScope ?? []).length === 0 ? ( No cargo scope lines. diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractViewPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractViewPage.tsx index 147d637ef..89fc51225 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractViewPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractViewPage.tsx @@ -18,7 +18,9 @@ import toast from "react-hot-toast"; import { ContractSignSuccessModal } from "@/components/contracts/ContractSignSuccessModal"; import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad"; +import { StampUpload } from "@/components/contracts/StampUpload"; import { contractsService } from "@/services/contracts.service"; +import { extractApiError } from "@/utils/result"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; import { useAuth } from "@/auth/useAuth"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; @@ -38,6 +40,7 @@ export default function ContractViewPage() { const [successOpen, setSuccessOpen] = useState(false); const [signerName, setSignerName] = useState(""); const [signatureData, setSignatureData] = useState(null); + const [stampData, setStampData] = useState(null); const [drawNew, setDrawNew] = useState(false); const { data, isLoading, isError, refetch } = useQuery({ @@ -56,6 +59,7 @@ export default function ContractViewPage() { signatureImageBase64: usingSaved ? (savedSignatureImage as string) : (signatureData ?? ""), + stampImageBase64: stampData ?? "", signerDisplayName: signerName.trim(), consentText: "I confirm this contract on behalf of EDR.", }), @@ -66,7 +70,12 @@ export default function ContractViewPage() { void qc.invalidateQueries({ queryKey: QUERY_KEYS.CONTRACTS.byId(id!) }); void qc.invalidateQueries({ queryKey: QUERY_KEYS.CONTRACTS.ROOT }); }, - onError: () => toast.error("Failed to sign contract"), + // Surface the server's reason verbatim — the missing-customer-stamp gate + // and the status guards all explain themselves in the message. + onError: (err) => + toast.error( + extractApiError(err).message ?? "Failed to sign contract", + ), }); const handlePrint = () => iframeRef.current?.contentWindow?.print(); @@ -89,12 +98,13 @@ export default function ContractViewPage() { const openSign = () => { setSignerName(data?.savedSignature?.signerDisplayName ?? ""); setSignatureData(null); + setStampData(null); setDrawNew(false); setSignOpen(true); }; const confirmSign = () => { - if (!signerName.trim()) return; + if (!signerName.trim() || !stampData) return; const image = usingSaved ? savedSignatureImage : signatureData; if (!image) return; signMutation.mutate(); @@ -231,6 +241,14 @@ export default function ContractViewPage() { ) : ( )} + + + + + + + + + ) : ( + { + e.preventDefault(); + setDragging(true); + }} + onDragLeave={() => setDragging(false)} + onDrop={(e) => { + e.preventDefault(); + setDragging(false); + readFile(e.dataTransfer.files?.[0]); + }} + style={{ + borderColor: dragging + ? "var(--mantine-color-edr-green-6)" + : undefined, + borderStyle: "dashed", + backgroundColor: dragging + ? "var(--mantine-color-edr-green-0)" + : undefined, + cursor: "pointer", + }} + > + + + + Upload company stamp + + + {description} Drop an image here or click to browse — PNG or JPG, + up to {MAX_STAMP_MB} MB. + + + + )} + + {error && ( + + {error} + + )} + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx index fe9209c1d..4288117ec 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -923,7 +923,7 @@ export default function ContractDetailPage() { )} {item.isClearance && ( - + Customs service fee — billed on your shipment booking invoice together with the freight diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractStepBanner.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractStepBanner.tsx index 3278e34e1..bc7d55691 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractStepBanner.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractStepBanner.tsx @@ -15,7 +15,14 @@ import { import type { LucideIcon } from "lucide-react"; import type { Freight } from "@edr/types"; -import { BORDER, GREEN, GREEN_DARK, INK, MUTED } from "./contract-ui"; +import { + BORDER, + GREEN, + GREEN_DARK, + INK, + MUTED, + expiryNoticeDays, +} from "./contract-ui"; /** * The customer-facing contract journey, in order. This is the *contract track* @@ -124,15 +131,6 @@ function resolveStep(status: string): StepState { } } -/** Days until the contract validity lapses, if any (negative = already lapsed). */ -function daysUntil(dateIso?: string | null): number | null { - if (!dateIso) return null; - const end = new Date(dateIso).getTime(); - if (Number.isNaN(end)) return null; - const ms = end - Date.now(); - return Math.ceil(ms / 86_400_000); -} - export interface ContractStepBannerProps { contract: Freight.IContract; } @@ -145,9 +143,9 @@ export function ContractStepBanner({ contract }: ContractStepBannerProps) { const { activeIdx, terminal, next } = resolveStep(contract.status); const isTerminalBad = terminal === "REJECTED" || terminal === "CANCELLED" || terminal === "EXPIRED"; - const expiryDays = daysUntil(contract.contractValidUntil); - const expirySoon = - !terminal && expiryDays !== null && expiryDays >= 0 && expiryDays <= 14; + // Same notice window as the list badge and the API's reminder. + const expiryDays = expiryNoticeDays(contract); + const expirySoon = !terminal && expiryDays !== null; return ( (null); + const [stampData, setStampData] = useState(null); const [drawNew, setDrawNew] = useState(false); const [hasScrolledToBottom, setHasScrolledToBottom] = useState(false); const [agreedToTerms, setAgreedToTerms] = useState(false); @@ -138,6 +140,7 @@ export default function ContractViewPage() { signatureImageBase64: usingSaved ? (savedSignatureImage as string) : (signatureData as string), + stampImageBase64: stampData as string, signerDisplayName: signerName.trim(), consentText: CONSENT_TEXT, otp: otpCode.trim(), @@ -161,6 +164,7 @@ export default function ContractViewPage() { if (!canProceedToSign) return; setSignerName(data?.savedSignature?.signerDisplayName ?? ""); setSignatureData(null); + setStampData(null); setDrawNew(false); setSignOpen(true); }; @@ -168,7 +172,7 @@ export default function ContractViewPage() { const confirmSign = () => { if (!signerName.trim()) return; const image = usingSaved ? savedSignatureImage : signatureData; - if (!image) return; + if (!image || !stampData) return; // The server resolves and validates the signer's own contacts; if the // account has neither phone nor email it returns a clear 400 that surfaces // via the mutation's onError. @@ -248,6 +252,15 @@ export default function ContractViewPage() { + {/* Signed before company stamps were required — re-signing is the only + way to attach one, and EDR cannot counter-sign until it is there. */} + {data.canSignCustomer && data.status === "SIGNED_CUSTOMER" && ( + + This contract was signed before a company stamp was required. Please + sign again and attach your stamp so EDR can counter-sign it. + + )} + {data.canSignCustomer && !hasScrolledToBottom && ( Please scroll through the entire contract before signing. @@ -360,6 +373,13 @@ export default function ContractViewPage() { ) : ( )} + + + + + + + {/* Step 0 — Setup: operation, contract, service, currency, miles. */} {step === 0 && ( @@ -964,15 +1006,14 @@ export default function NewContractPage({ fw={700} tt="uppercase" c="edr-green" - mb="xs" + mb="md" style={{ letterSpacing: "0.06em" }} > Pricing schedule - - Final amount is calculated at booking — quantities are unknown - at the contract stage. - + + + {pricingData.lineItems.map((item) => ( )} {item.isClearance && ( - + Customs service fee — billed on your shipment booking invoice together with the freight diff --git a/apps/edr-freight-web/portal/src/pages/contracts/contract-expiry-notice.test.ts b/apps/edr-freight-web/portal/src/pages/contracts/contract-expiry-notice.test.ts new file mode 100644 index 000000000..2b2ca987c --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/contracts/contract-expiry-notice.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +import { EXPIRY_NOTICE_DAYS, expiryNoticeDays, expiryNoticeLabel } from "./contract-ui"; + +const inDays = (days: number): string => + // Half a day past the boundary so ceil() lands on `days` regardless of the + // clock at test time. + new Date(Date.now() + (days - 0.5) * 86_400_000).toISOString(); + +const contract = (over: Record = {}) => + ({ + status: "CONTRACT_ACTIVE", + contractValidUntil: inDays(5), + ...over, + }) as never; + +describe("expiryNoticeDays", () => { + it("counts the days left once inside the notice window", () => { + expect(expiryNoticeDays(contract({ contractValidUntil: inDays(5) }))).toBe(5); + expect( + expiryNoticeDays( + contract({ contractValidUntil: inDays(EXPIRY_NOTICE_DAYS) }), + ), + ).toBe(EXPIRY_NOTICE_DAYS); + }); + + it("stays silent while the contract is further out than the window", () => { + expect( + expiryNoticeDays( + contract({ contractValidUntil: inDays(EXPIRY_NOTICE_DAYS + 1) }), + ), + ).toBeNull(); + }); + + it("stays silent for a contract with no validity date", () => { + expect(expiryNoticeDays(contract({ contractValidUntil: null }))).toBeNull(); + }); + + it("stays silent once the date has passed — that is expiry, not a warning", () => { + expect(expiryNoticeDays(contract({ contractValidUntil: inDays(-1) }))).toBeNull(); + }); + + it("stays silent on contracts that are already over", () => { + for (const status of ["EXPIRED", "CANCELLED", "REJECTED", "CONTRACT_CLOSED"]) { + expect(expiryNoticeDays(contract({ status }))).toBeNull(); + } + }); +}); + +describe("expiryNoticeLabel", () => { + it("reads naturally at the edges", () => { + expect(expiryNoticeLabel(0)).toBe("Expires today"); + expect(expiryNoticeLabel(1)).toBe("1 day left"); + expect(expiryNoticeLabel(10)).toBe("10 days left"); + }); +}); diff --git a/apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx b/apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx index 7028943df..4043fc71d 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx @@ -1,5 +1,5 @@ import { Box, Group, Paper, Text, Tooltip } from "@mantine/core"; -import { FileText } from "lucide-react"; +import { AlertTriangle, FileText } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import type { ReactNode } from "react"; import type { Freight } from "@edr/types"; @@ -209,6 +209,88 @@ export function ContractStatusBadge({ status }: { status: string }) { ); } +/** + * How close to its validity end a contract has to be before the customer is + * warned. The API notifies at the same distance (contract-expiry.service), so + * the inbox message and the list badge agree. + */ +export const EXPIRY_NOTICE_DAYS = 10; + +/** Whole days until a date (0 = today, negative = already past). Null if unset. */ +export function daysUntil(dateIso?: string | null): number | null { + if (!dateIso) return null; + const end = new Date(dateIso).getTime(); + if (Number.isNaN(end)) return null; + return Math.ceil((end - Date.now()) / 86_400_000); +} + +/** Contracts that are already over — no point warning about their expiry. */ +const CLOSED_CONTRACT_STATUSES = [ + "REJECTED", + "CANCELLED", + "CONTRACT_CLOSED", + "ARCHIVED", + "EXPIRED", +]; + +/** + * Days left on a live contract, but only inside the notice window — null when + * the contract is closed, has no validity date, has already lapsed, or is still + * further out than {@link EXPIRY_NOTICE_DAYS}. + */ +export function expiryNoticeDays( + contract: Pick, +): number | null { + if (CLOSED_CONTRACT_STATUSES.includes(contract.status)) return null; + const days = daysUntil(contract.contractValidUntil); + if (days == null || days < 0 || days > EXPIRY_NOTICE_DAYS) return null; + return days; +} + +/** "Expires today" / "5 days left" — the wording shared by list and banner. */ +export function expiryNoticeLabel(days: number): string { + if (days === 0) return "Expires today"; + return `${days} day${days === 1 ? "" : "s"} left`; +} + +/** + * Amber countdown pill shown on a contract that is about to lapse. Renders + * nothing outside the notice window, so callers can drop it in unconditionally. + */ +export function ContractExpiryBadge({ + contract, +}: { + contract: Pick; +}) { + const days = expiryNoticeDays(contract); + if (days == null) return null; + return ( + + + + + {expiryNoticeLabel(days)} + + + + ); +} + /** A labelled value used across the contract detail summary cards. */ export function MetaItem({ label, diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/contractToForm.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/contractToForm.ts index 38a7b3463..21de86b42 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/contractToForm.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/contractToForm.ts @@ -122,6 +122,8 @@ export function contractToFormValues( bulkQuantityCap: isGeneral && bulkRow?.quantityCap != null ? bulkRow.quantityCap : 0, isHazardous: contract.isHazardous, + hazardClass: contract.hazardClass ?? "", + unNumber: contract.unNumber ?? "", isRefrigerated: contract.isReefer, originYard: primaryRoute?.originYardId ?? "", diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts index d20719c24..3105ac485 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts @@ -1,5 +1,6 @@ import { DeepPartial, Path } from "react-hook-form"; import * as z from "zod"; +import { HAZARD_CLASS_VALUES } from "@edr/types"; // Wizard steps for the contract creation flow. Condensed to four steps: the // pickers are dropdown selects so each step fits one screen without scrolling. @@ -177,6 +178,10 @@ export const contractFormSchema = z bulkQuantityCap: nonNegativeQuantityCap.default(0), // Contract-level billing flags. isHazardous: z.boolean().default(false), + // Dangerous-goods declaration — collected with the hazard documents and + // mandatory whenever isHazardous (enforced in the superRefine below). + hazardClass: z.string().default(""), + unNumber: z.string().default(""), isRefrigerated: z.boolean().default(false), // ── Route ── (one route per contract — general contracts included) @@ -241,6 +246,24 @@ export const contractFormSchema = z }); } } + // Hazardous cargo must name its UN/ADR class and UN number — the API + // rejects the contract otherwise, so catch it before the wizard submits. + if (data.isHazardous) { + if (!HAZARD_CLASS_VALUES.includes(data.hazardClass)) { + ctx.addIssue({ + code: "custom", + path: ["hazardClass"], + message: "Select the dangerous-goods class.", + }); + } + if (!data.unNumber.trim()) { + ctx.addIssue({ + code: "custom", + path: ["unNumber"], + message: "Enter the UN number.", + }); + } + } // GENERAL contracts are uncapped: no quantity cap is collected, so the // customer can book repeatedly until the contract's validity expires. The // cap fields default to 0/empty and map to quantityCap = NULL (uncapped) at @@ -271,6 +294,8 @@ export const initialContractFormValues: DeepPartial = { cargoFreeText: "", bulkQuantityCap: 0, isHazardous: false, + hazardClass: "", + unNumber: "", isRefrigerated: false, originYard: "", @@ -307,6 +332,8 @@ export const contractStepFields: Record< "cargoFreeText", "bulkQuantityCap", "isHazardous", + "hazardClass", + "unNumber", "isRefrigerated", "originYard", "destinationYard", diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/shared.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/shared.tsx index 7e3b06695..6165044bd 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/shared.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/shared.tsx @@ -1,11 +1,15 @@ import { + Box, Combobox, + Group, Input, InputBase, Select, + Stack, + Text, useCombobox, } from "@mantine/core"; -import { Loader } from "lucide-react"; +import { Loader, Receipt } from "lucide-react"; import type { ReactNode } from "react"; import { useMemo } from "react"; import type { @@ -84,6 +88,55 @@ export function SelectField< ); } +/** + * Prominent "these are unit rates, not your bill" banner. Shown wherever the + * customer is looking at contract pricing (review step + quotation modal) — + * the contract quotes per-unit rates only; the payable total is computed at + * booking from the quantities actually shipped. + */ +export function NotFinalPriceNotice() { + return ( + + + + + + + This is not your final price + + + The figures below are per-unit rates for each service + — not a total. Your payable amount is calculated on every booking from + the quantities you actually ship, and invoiced then. + + + + ); +} + interface AsyncComboboxOption { value: string; label: string; diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx index f89a0008d..84248d3e7 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx @@ -3,6 +3,7 @@ import { Controller, type UseFormReturn } from "react-hook-form"; // Snowflake — restore with the Refrigerated Cargo switch below. import { Container, Flame, RotateCcw } from "lucide-react"; import { + Badge, Box, Button, Group, @@ -13,10 +14,11 @@ import { Stack, Switch, Text, + TextInput, } from "@mantine/core"; import { useQuery } from "@tanstack/react-query"; import { SmartFileInput } from "@edr/ui-common"; -import type { Freight } from "@edr/types"; +import { HAZARD_CLASSES, hazardClassLabel, type Freight } from "@edr/types"; import { api } from "@/services/api"; import { CONTAINER_SIZES, @@ -25,7 +27,7 @@ import { type ContractFormValues, } from "./schema"; import { operationToTradeDirection } from "./helpers"; -import { fieldStyles, SelectField, StepLabel } from "./shared"; +import { AlertBox, fieldStyles, SelectField, StepLabel } from "./shared"; /** * file_upload_settings code holding the hazardous-cargo document requirements. @@ -95,6 +97,18 @@ export function Step3CargoScope({ const [hazardModalOpen, setHazardModalOpen] = useState(false); const [hazardDraft, setHazardDraft] = useState({}); const [hazardErrors, setHazardErrors] = useState>({}); + // Dangerous-goods declaration, edited in the modal and only written back to + // the form once the customer confirms — cancelling must leave the contract + // exactly as it was. + const [classDraft, setClassDraft] = useState(null); + const [unDraft, setUnDraft] = useState(""); + const [declErrors, setDeclErrors] = useState<{ + hazardClass?: string; + unNumber?: string; + }>({}); + + const hazardClass = form.watch("hazardClass"); + const unNumber = form.watch("unNumber"); /** Drop every hazardous document from the contract's document map. */ const clearHazardDocs = () => { @@ -117,6 +131,9 @@ export function Step3CargoScope({ ), ); setHazardErrors({}); + setClassDraft(form.getValues("hazardClass") || null); + setUnDraft(form.getValues("unNumber") ?? ""); + setDeclErrors({}); setHazardModalOpen(true); }; @@ -124,14 +141,20 @@ export function Step3CargoScope({ const missing = hazardFields.filter( (f) => f.isRequired && !hasUploaded(hazardDraft[f.fileKey]), ); - if (missing.length > 0) { - setHazardErrors( - Object.fromEntries( - missing.map((f) => [f.fileKey, `${f.fileLabel} is required.`]), - ), - ); - return; + const nextDeclErrors: typeof declErrors = {}; + if (!classDraft) { + nextDeclErrors.hazardClass = "Select the dangerous-goods class."; } + if (!unDraft.trim()) nextDeclErrors.unNumber = "Enter the UN number."; + + setHazardErrors( + Object.fromEntries( + missing.map((f) => [f.fileKey, `${f.fileLabel} is required.`]), + ), + ); + setDeclErrors(nextDeclErrors); + if (missing.length > 0 || Object.keys(nextDeclErrors).length > 0) return; + form.setValue( "documents", { @@ -140,16 +163,29 @@ export function Step3CargoScope({ }, { shouldDirty: true }, ); + form.setValue("hazardClass", classDraft!, { shouldDirty: true }); + form.setValue("unNumber", unDraft.trim().toUpperCase(), { + shouldDirty: true, + }); + form.clearErrors(["hazardClass", "unNumber"]); form.setValue("isHazardous", true, { shouldDirty: true }); setHazardModalOpen(false); }; + /** Turning the switch off drops the declaration with the documents. */ + const clearHazardDeclaration = () => { + form.setValue("hazardClass", "", { shouldDirty: true }); + form.setValue("unNumber", "", { shouldDirty: true }); + form.clearErrors(["hazardClass", "unNumber"]); + }; + // A hidden flag must never leak into the payload: a general contract can't be // hazardous, and a non-import contract carries neither reefer nor empty return. useEffect(() => { if (isOneTime) return; if (form.getValues("isHazardous")) { form.setValue("isHazardous", false, { shouldDirty: true }); + clearHazardDeclaration(); } // Runs again once the hazard field list loads — a no-op when nothing matches. clearHazardDocs(); @@ -356,25 +392,42 @@ export function Step3CargoScope({ name="isHazardous" control={form.control} render={({ field }) => ( - } - iconBg="#FBEAE7" - iconColor="#C0392B" - title="Hazardous Material" - description="Applies a hazard surcharge as a per-container unit rate. Requires hazard documents." - checked={field.value ?? false} - onChange={(v) => { - if (v) { - openHazardModal(); - return; - } - field.onChange(false); - clearHazardDocs(); - }} - /> + + } + iconBg="#FBEAE7" + iconColor="#C0392B" + title="Hazardous Material" + description="Applies a hazard surcharge as a per-container unit rate. Requires a UN class, UN number and hazard documents." + checked={field.value ?? false} + onChange={(v) => { + if (v) { + openHazardModal(); + return; + } + field.onChange(false); + clearHazardDocs(); + clearHazardDeclaration(); + }} + /> + {field.value && ( + + )} + )} /> )} + {!isOneTime && ( + + Hazardous material can only be declared on a one-time contract. + Switch Contract Kind to one-time to carry + hazardous cargo. + + )} {/* Refrigerated cargo is hidden for now — import-only when re-enabled. The effect above keeps isRefrigerated false while it's off. {isImport && ( @@ -422,17 +475,83 @@ export function Step3CargoScope({ setHazardModalOpen(false)} - title="Hazardous cargo documents" + title={ + + + + + + Hazardous cargo declaration + + + } size="lg" centered radius={14} > - + - Hazardous cargo can only move once the documents below are attached - to the contract. + Dangerous goods move only once the class and UN number are declared + and the documents below are attached to the contract. EDR reviews + this declaration in two dedicated hazardous approval steps. + + Dangerous-goods declaration +
+