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/contracts/contract-document-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts index 363008660..5ceb73978 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 @@ -30,6 +30,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. */ @@ -208,6 +210,7 @@ export class ContractDocumentViewModelBuilder { signerDisplayName: row.signerDisplayName, signedAt: this.formatDate(row.signedAt), signatureImageUrl: row.signatureFile?.url ?? null, + stampImageUrl: row.stampFile?.url ?? null, }; } 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/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/contracts/contract-expiry.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.ts new file mode 100644 index 000000000..439aff804 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.ts @@ -0,0 +1,44 @@ +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'; + +/** 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, + ) {} + + @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-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index 54158c383..ea17b26a5 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 @@ -928,23 +928,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 +977,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 +1043,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 +1072,7 @@ export class ContractTransitionService { signerDisplayName, signedAt: new Date(), signatureFileId: fileRecord.id, + stampFileId: stampRecord?.id ?? null, consentText: dto.consentText ?? null, }); @@ -1127,6 +1185,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..d71a6bd78 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,46 @@ 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; + } + /** 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 +475,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 +486,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..0247ac917 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 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/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/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..cf57a9a81 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'), ]; /** @@ -401,6 +404,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', @@ -781,6 +785,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/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..032411b26 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -294,6 +294,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..01bca1e11 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", @@ -477,6 +478,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/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/ContractViewPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx index e0ad792ff..cbf6440c7 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx @@ -28,6 +28,7 @@ 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 { api } from "@/services/api"; import { extractApiError } from "@/utils/result"; @@ -52,6 +53,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 [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. @@ -360,6 +364,13 @@ export default function ContractViewPage() { ) : ( )} + + + + + + + {/* Step 0 — Setup: operation, contract, service, currency, miles. */} {step === 0 && ( @@ -964,15 +1001,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/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..1afc08186 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 @@ -25,7 +25,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. @@ -375,6 +375,13 @@ export function Step3CargoScope({ )} /> )} + {!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 && ( diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx index 11a96b4b9..0c0aaf3cd 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx @@ -27,7 +27,7 @@ import { type ContractFormInputValues, type ContractFormValues, } from "./schema"; -import { StepHeader } from "./shared"; +import { NotFinalPriceNotice, StepHeader } from "./shared"; import { formatRateUnit } from "./unit-rates"; type ContractForm = UseFormReturn< @@ -125,15 +125,14 @@ function UnitRatePanel({ fw={700} tt="uppercase" c="edr-green" - mb="xs" + mb="md" style={{ letterSpacing: "0.06em" }} > Pricing schedule - - Estimated unit rates — the final amount is calculated at booking from the - quantities you ship. - + + + {lineItems.map((item) => ( { if (values.cargoType === "container") { @@ -399,16 +396,20 @@ export function Step8Review({ } /> - } - label="First mile — pick-up" - value={firstMileValue} - /> - } - label="Last mile — delivery" - value={lastMileValue} - /> + {firstMileValue && ( + } + label="First mile — pick-up" + value={firstMileValue} + /> + )} + {lastMileValue && ( + } + label="Last mile — delivery" + value={lastMileValue} + /> + )} } label="Customs clearing" diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.ts index b4055bb1c..c778385fd 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.ts @@ -154,11 +154,12 @@ export function computeShipmentTotal( } // Lashing / cargo securing — bulk-only, applies whenever the contract shows - // it (the commodity needs lashing). Per-ton scales by tonnage; per-wagon - // depends on the wagon capacity the train stocks — shown at real pricing. + // it (the commodity needs lashing). Per-ton/per-item scales by the cargo + // amount; 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") { - const tons = Number(values.cargoWeightTons || 0); + if (lashing && (lashing.unit === "per_ton" || lashing.unit === "per_item")) { + const tons = Number(values.cargoWeightTons || values.itemCount || 0); if (tons > 0) { lines.push({ label: lashing.label, @@ -184,8 +185,8 @@ export function computeShipmentTotal( cl.unit === "per_wagon" ? Math.ceil(boxes * (cl.containerSize === "40ft" ? 1 : 0.5)) : boxes; - } else if (cl.unit === "per_ton") { - qty = Number(values.cargoWeightTons || 0); + } else if (cl.unit === "per_ton" || cl.unit === "per_item") { + qty = Number(values.cargoWeightTons || values.itemCount || 0); } else if (cl.unit === "flat") { qty = 1; } diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index 58f3b330b..79328a5dd 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -44,6 +44,7 @@ export interface ContractView { signerDisplayName: string; signedAt: string; signatureImageUrl?: string | null; + stampImageUrl?: string | null; }>; /** Current viewer's reusable saved signature, if they have one. */ savedSignature?: { @@ -124,6 +125,8 @@ export interface SubmitBookingResponse { export interface SignContractPayload { role: "CUSTOMER" | "STAFF"; signatureImageBase64: string; + /** Company stamp/seal image; required to sign a contract (not booking contracts). */ + stampImageBase64?: string; signerDisplayName: string; consentText?: string; /** Sudo-mode OTP challenge; required when role=CUSTOMER. */ diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts index bb5c5fb63..cf01939a5 100644 --- a/packages/types/src/freight/contracts.ts +++ b/packages/types/src/freight/contracts.ts @@ -184,6 +184,8 @@ export interface IContractSignature { role: ContractSignatureRole; signerDisplayName: string; signatureFileId?: string | null; + /** Company stamp/seal image, required for the CUSTOMER and STAFF parties. */ + stampFileId?: string | null; consentText?: string | null; signedAt: string; }