From 75b75e3d4efc77604ddd4ba1650e06877b347a55 Mon Sep 17 00:00:00 2001 From: marshal Date: Sun, 6 Sep 2026 12:33:40 +0000 Subject: [PATCH] feat: add contract extension request functionality - Implemented method in to allow customers to request an extension for expired contracts. - Added component in for users to initiate extension requests. - Updated to include logic for handling extension requests for expired contracts. - Enhanced to display extension request options and status. - Created migration to add and columns to the contracts table. - Added unit tests for contract extension request and handling in . - Defined DTOs for request and extension in . - Updated types in to include new fields related to contract extensions. --- apps/edr-freight-api/src/config/app.config.ts | 4 +- .../3920000000000-ContractExtensionRequest.ts | 99 ++++++ .../contracts/contract-extension.spec.ts | 170 ++++++++++ .../contracts/contract-notifier.service.ts | 20 ++ .../contracts/contract-transition.service.ts | 99 ++++++ .../modules/contracts/contracts.controller.ts | 50 +++ .../modules/contracts/contracts.repository.ts | 6 +- .../modules/contracts/contracts.service.ts | 14 + .../contracts/dto/extend-contract.dto.ts | 31 ++ .../entities/contract-review-note.entity.ts | 4 + .../contracts/entities/contract.entity.ts | 24 ++ .../train-scheduling.controller.ts | 2 +- .../train-scheduling-global-rules.entity.ts | 7 + .../services/train-scheduling.service.spec.ts | 43 +++ .../services/train-scheduling.service.ts | 293 ++++++++---------- .../train-scheduling.module.ts | 2 - .../utils/wagon-list-workbook.util.spec.ts | 169 ++++++++++ .../utils/wagon-list-workbook.util.ts | 219 +++++++++++++ .../utils/wagon-plan.util.spec.ts | 25 +- .../train-scheduling/utils/wagon-plan.util.ts | 28 +- .../src/seed/freight-permissions.registry.ts | 10 + .../contracts/ContractActionsToolbar.tsx | 145 ++++++++- .../backoffice/src/constants/URLS.ts | 1 + .../src/hooks/contracts/useContracts.ts | 9 + .../backoffice/src/lib/permissions.ts | 1 + .../src/services/contracts.service.ts | 8 + .../ContractCustomerAction.tsx | 164 ++++++++++ .../deriveContractCustomerAction.ts | 30 ++ .../portal/src/constants/URLS.ts | 1 + .../pages/contracts/ContractDetailPage.tsx | 40 ++- .../portal/src/services/contracts.service.ts | 13 + packages/types/src/freight/contracts.ts | 30 ++ 32 files changed, 1582 insertions(+), 179 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3920000000000-ContractExtensionRequest.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/contract-extension.spec.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/dto/extend-contract.dto.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-list-workbook.util.spec.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-list-workbook.util.ts diff --git a/apps/edr-freight-api/src/config/app.config.ts b/apps/edr-freight-api/src/config/app.config.ts index 8e03ac3f0..f88661950 100644 --- a/apps/edr-freight-api/src/config/app.config.ts +++ b/apps/edr-freight-api/src/config/app.config.ts @@ -17,9 +17,9 @@ export default registerAs("app", () => ({ portalBaseUrl: ( process.env.FREIGHT_PORTAL_URL ?? "http://localhost:5173" ).replace(/\/+$/, ""), + // Train weight/length are not env-configured: they come from locomotive + // configuration (see TrainSchedulingService.resolveTrainLimitConfig). trainScheduling: { - maxTrainWeightTons: numberFromEnv("TRAIN_SCHEDULING_MAX_WEIGHT_TONS", 3500), - maxTrainLengthMeters: numberFromEnv("TRAIN_SCHEDULING_MAX_LENGTH_METERS", 760), maxWagonsPerTrain: numberFromEnv("TRAIN_SCHEDULING_MAX_WAGONS_PER_TRAIN", 53), }, // Consumed by @edr/api-common ExchangeModule.forRootAsync (see bookings.module.ts). diff --git a/apps/edr-freight-api/src/migrations/3920000000000-ContractExtensionRequest.ts b/apps/edr-freight-api/src/migrations/3920000000000-ContractExtensionRequest.ts new file mode 100644 index 000000000..d51ae6ddd --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3920000000000-ContractExtensionRequest.ts @@ -0,0 +1,99 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Customer-requested validity extension of an EXPIRED contract. + * + * Flow: the customer asks from the portal (`extension_requested_at` is stamped, + * the reason lands in contract_review_notes as EXTENSION_REQUESTED), then staff + * add days on the backoffice detail page and the contract returns to the status + * it held before it lapsed. Both expiry paths (nightly sweep + lazy flip on + * read) now stash that status in `status_before_expiry`, mirroring + * `status_before_suspension`; rows expired before this column existed fall + * back to the kind's resting status on extension. + * + * Also seeds `edr_freight_app:contracts:extend`. `FreightPositionsSeeder` + * resolves every registry key against `iam.permissions` at boot and throws on + * a missing row, so the catalog row must exist wherever the registry ships. + * The grant is copied from whoever already holds `contracts:suspend` — the + * registry places both keys on the same desk (marketing), and the position + * seeder only re-syncs presets when SEED_EDR_ORG is set. + */ +export class ContractExtensionRequest3920000000000 implements MigrationInterface { + name = 'ContractExtensionRequest3920000000000'; + + private static readonly KEY = 'edr_freight_app:contracts:extend'; + private static readonly ID = 'a3000001-0001-4000-8000-00000000001d'; + private static readonly SIBLING_KEY = 'edr_freight_app:contracts:suspend'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.contracts + ADD COLUMN IF NOT EXISTS extension_requested_at timestamptz NULL + `); + await queryRunner.query(` + ALTER TABLE freight.contracts + ADD COLUMN IF NOT EXISTS status_before_expiry varchar(40) NULL + `); + + await queryRunner.query( + `INSERT INTO iam.permissions (id, key, name, application_id) + SELECT $2::uuid, + $1::varchar, + '{"am": "Extend an expired contract", "en": "Extend an expired contract"}'::jsonb, + a.id + FROM iam.application a + WHERE a.key = 'edr_freight_app' + AND NOT EXISTS (SELECT 1 FROM iam.permissions p WHERE p.key = $1::varchar)`, + [ContractExtensionRequest3920000000000.KEY, ContractExtensionRequest3920000000000.ID], + ); + + // Grant wherever suspend is already granted (positions and roles alike). + await queryRunner.query( + `INSERT INTO iam.position_permissions (position_id, permission_id) + SELECT pp.position_id, np.id + FROM iam.position_permissions pp + JOIN iam.permissions sp ON sp.id = pp.permission_id AND sp.key = $2::varchar + JOIN iam.permissions np ON np.key = $1::varchar + WHERE NOT EXISTS ( + SELECT 1 FROM iam.position_permissions x + WHERE x.position_id = pp.position_id AND x.permission_id = np.id + )`, + [ContractExtensionRequest3920000000000.KEY, ContractExtensionRequest3920000000000.SIBLING_KEY], + ); + await queryRunner.query( + `INSERT INTO iam.role_permissions (role_id, permission_id) + SELECT rp.role_id, np.id + FROM iam.role_permissions rp + JOIN iam.permissions sp ON sp.id = rp.permission_id AND sp.key = $2::varchar + JOIN iam.permissions np ON np.key = $1::varchar + WHERE NOT EXISTS ( + SELECT 1 FROM iam.role_permissions x + WHERE x.role_id = rp.role_id AND x.permission_id = np.id + )`, + [ContractExtensionRequest3920000000000.KEY, ContractExtensionRequest3920000000000.SIBLING_KEY], + ); + } + + /** Grants go first, or the delete trips the permission foreign keys. */ + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM iam.position_permissions + WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`, + [ContractExtensionRequest3920000000000.KEY], + ); + await queryRunner.query( + `DELETE FROM iam.role_permissions + WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`, + [ContractExtensionRequest3920000000000.KEY], + ); + await queryRunner.query(`DELETE FROM iam.permissions WHERE key = $1`, [ + ContractExtensionRequest3920000000000.KEY, + ]); + await queryRunner.query( + `ALTER TABLE freight.contracts DROP COLUMN IF EXISTS status_before_expiry`, + ); + await queryRunner.query( + `ALTER TABLE freight.contracts DROP COLUMN IF EXISTS extension_requested_at`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/contracts/contract-extension.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-extension.spec.ts new file mode 100644 index 000000000..da3fdf04f --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-extension.spec.ts @@ -0,0 +1,170 @@ +import { ContractTransitionService } from './contract-transition.service'; +import type { Contract } from './entities/contract.entity'; + +/** + * A lapsed contract comes back only on the customer's say-so: they ask once, + * staff add days, and the contract lands back where it was before it expired. + * Those three rules are the feature. + */ +describe('ContractTransitionService — extension request / extend', () => { + const contract = (over: Partial = {}): Contract => + ({ + id: 'c-1', + reference: 'CTR-2026-00042', + companyId: 'co-1', + contractKind: 'GENERAL', + status: 'EXPIRED', + freightType: 'CONTAINER', + contractValidUntil: new Date('2026-01-31T21:00:00Z'), + statusBeforeExpiry: 'CONTRACT_ACTIVE', + extensionRequestedAt: null, + ...over, + }) as Contract; + + let current: Contract; + let repo: { update: jest.Mock; createReviewNote: jest.Mock }; + let notifier: { extended: jest.Mock; extensionRequestedToStaff: jest.Mock }; + let service: ContractTransitionService; + + /** A staff user holding the extend key — authorization is tested elsewhere. */ + const staff = { + permissions: [{ key: 'edr_freight_app:contracts:extend' }], + }; + + beforeEach(() => { + current = contract(); + repo = { + update: jest.fn().mockImplementation((_id: string, patch: object) => { + current = { ...current, ...patch } as Contract; + return Promise.resolve(current); + }), + createReviewNote: jest.fn().mockResolvedValue(undefined), + }; + notifier = { extended: jest.fn(), extensionRequestedToStaff: jest.fn() }; + service = Object.create( + ContractTransitionService.prototype, + ) as ContractTransitionService; + Object.assign(service, { + contractsRepository: repo, + contractsService: { findById: () => Promise.resolve(current) }, + notifier, + }); + }); + + it('records the customer request and tells the contract desk', async () => { + await service.requestExtension('c-1', ' Two more shipments due ', 'user-1'); + + expect(repo.createReviewNote).toHaveBeenCalledWith( + 'c-1', + 'Two more shipments due', + 'EXTENSION_REQUESTED', + 'user-1', + 'CUSTOMER', + ); + expect(repo.update).toHaveBeenCalledWith('c-1', { + extensionRequestedAt: expect.any(Date), + }); + expect(notifier.extensionRequestedToStaff).toHaveBeenCalledWith( + expect.objectContaining({ id: 'c-1' }), + 'Two more shipments due', + ); + }); + + it('refuses a request on a contract that has not expired', async () => { + current = contract({ status: 'CONTRACT_ACTIVE' }); + + await expect( + service.requestExtension('c-1', undefined, 'user-1'), + ).rejects.toThrow(/CONTRACT_ACTIVE/); + expect(repo.update).not.toHaveBeenCalled(); + }); + + it('allows one pending request at a time', async () => { + current = contract({ extensionRequestedAt: new Date() }); + + await expect( + service.requestExtension('c-1', undefined, 'user-1'), + ).rejects.toThrow(/already awaiting/); + expect(repo.update).not.toHaveBeenCalled(); + }); + + it('refuses to extend before the customer has asked', async () => { + await expect( + service.extend('c-1', 30, undefined, 'staff-1', staff as never), + ).rejects.toThrow(/not requested/); + expect(repo.update).not.toHaveBeenCalled(); + }); + + it('adds days from today on a lapsed contract and restores the pre-expiry status', async () => { + current = contract({ + extensionRequestedAt: new Date(), + statusBeforeExpiry: 'ACTIVE_SHIPMENT_IN_PROGRESS', + }); + const before = Date.now(); + + await service.extend('c-1', 10, 'Approved by desk', 'staff-1', staff as never); + + const patch = repo.update.mock.calls[0][1] as { + status: string; + statusBeforeExpiry: null; + extensionRequestedAt: null; + contractValidUntil: Date; + }; + expect(patch.status).toBe('ACTIVE_SHIPMENT_IN_PROGRESS'); + expect(patch.statusBeforeExpiry).toBeNull(); + expect(patch.extensionRequestedAt).toBeNull(); + // The old end (Jan 2026) is in the past, so the ten days count from now. + const tenDays = 10 * 86_400_000; + expect(patch.contractValidUntil.getTime()).toBeGreaterThanOrEqual(before + tenDays - 1000); + expect(patch.contractValidUntil.getTime()).toBeLessThanOrEqual(Date.now() + tenDays + 3_600_000); + expect(repo.createReviewNote).toHaveBeenCalledWith( + 'c-1', + expect.stringMatching(/^Extended by 10 days to .*\. Approved by desk$/), + 'EXTENDED', + 'staff-1', + 'STAFF', + ); + expect(notifier.extended).toHaveBeenCalledWith( + expect.objectContaining({ id: 'c-1' }), + 10, + patch.contractValidUntil, + 'Approved by desk', + ); + }); + + it('extends from the current end date when it is still in the future', async () => { + const future = new Date(Date.now() + 5 * 86_400_000); + current = contract({ extensionRequestedAt: new Date(), contractValidUntil: future }); + + await service.extend('c-1', 7, undefined, 'staff-1', staff as never); + + const patch = repo.update.mock.calls[0][1] as { contractValidUntil: Date }; + const expected = new Date(future); + expected.setDate(expected.getDate() + 7); + expect(patch.contractValidUntil.getTime()).toBe(expected.getTime()); + }); + + it('falls back to the resting status for rows expired before it was tracked', async () => { + current = contract({ + extensionRequestedAt: new Date(), + statusBeforeExpiry: null, + contractKind: 'ONE_TIME', + }); + + await service.extend('c-1', 1, undefined, 'staff-1', staff as never); + + expect(repo.update).toHaveBeenCalledWith( + 'c-1', + expect.objectContaining({ status: 'FULLY_EXECUTED' }), + ); + }); + + it('refuses to extend without the extend permission', async () => { + current = contract({ extensionRequestedAt: new Date() }); + + await expect( + service.extend('c-1', 30, undefined, 'staff-1', { permissions: [] } as never), + ).rejects.toThrow(); + expect(repo.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts index 10b6afd37..8395d28c5 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts @@ -188,6 +188,26 @@ export class ContractNotifierService { this.inApp(c, 'Contract cancelled', msg); } + /** Staff extended the validity of a lapsed contract — it is live again. */ + extended(c: Contract, days: number, validUntil: Date, note?: string | null): void { + const msg = + `Your contract ${c.reference} has been extended by ${days} day${days === 1 ? '' : 's'} ` + + `and is now valid until ${validUntil.toLocaleDateString('en-GB')}. ` + + `You can book shipments under it again.${note ? ` Note: ${note}` : ''}`; + void this.notifyContact(c, msg, 'EXTENDED'); + this.inApp(c, 'Contract extended', msg); + } + + /** Customer asked for their expired contract to be extended — staff-side record. */ + extensionRequestedToStaff(c: Contract, note: string | null): void { + this.inAppStaff( + c, + 'Contract extension requested', + `The customer asked to extend expired contract ${this.ref(c)}.` + + `${note ? ` Reason: ${note}` : ''} Open the contract to add validity days.`, + ); + } + /** Customer cancelled their own contract — staff-side record. */ cancelledByCustomer(c: Contract, reason: string): void { this.inAppStaff( 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 5d8c24060..bdacdb69d 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 @@ -1502,6 +1502,105 @@ export class ContractTransitionService { return updated; } + /** + * Customer asks EDR to extend the validity of their EXPIRED contract. Only + * stamps the request and tells the contract desk — nothing on the contract + * moves until staff {@link extend} it. One pending request at a time. + */ + async requestExtension( + contractId: string, + note: string | undefined, + userId?: string, + ): Promise { + const contract = await this.contractsService.findById(contractId); + assertContractStatus(contract, ['EXPIRED']); + if (contract.extensionRequestedAt) { + throw new ConflictException( + 'An extension request for this contract is already awaiting EDR.', + ); + } + + const reason = note?.trim() || null; + await this.contractsRepository.createReviewNote( + contractId, + reason ?? 'Customer requested a validity extension.', + 'EXTENSION_REQUESTED', + userId, + 'CUSTOMER', + ); + await this.contractsRepository.update(contractId, { + extensionRequestedAt: new Date(), + } as never); + const updated = await this.contractsService.findById(contractId); + this.notifier.extensionRequestedToStaff(updated, reason); + return updated; + } + + /** + * Staff add validity days to an EXPIRED contract the customer asked to + * extend, and the contract returns to the status it held before it lapsed + * (stashed in statusBeforeExpiry by both expiry paths). Days count from + * today once the contract has lapsed — adding to a date already in the past + * could leave it expired — and from the current end date otherwise. + * + * Gated on the customer's request: the portal button is the only way to set + * extensionRequestedAt, so staff cannot silently revive a contract nobody + * asked about. + */ + async extend( + contractId: string, + days: number, + note: string | undefined, + actorId: string, + user?: TCurrentUser | null, + ): Promise { + const contract = await this.contractsService.findById(contractId); + assertFreightPermission(user, FREIGHT_PERMS.contracts.extend); + assertContractStatus(contract, ['EXPIRED']); + if (!contract.extensionRequestedAt) { + throw new ConflictException( + 'The customer has not requested an extension for this contract. A contract is only extended on customer request.', + ); + } + if (!Number.isInteger(days) || days < 1) { + throw new BadRequestException('An extension must add at least one day.'); + } + + const now = new Date(); + const currentEnd = contract.contractValidUntil + ? new Date(contract.contractValidUntil) + : null; + const base = currentEnd && currentEnd.getTime() > now.getTime() ? currentEnd : now; + const validUntil = new Date(base); + validUntil.setDate(validUntil.getDate() + days); + + // Rows that lapsed before statusBeforeExpiry existed have nothing to + // restore — fall back to the kind's post-signature resting status, the + // same default resume() uses. + const restored = + contract.statusBeforeExpiry ?? + (contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED'); + + const trimmed = note?.trim() || null; + await this.contractsRepository.createReviewNote( + contractId, + `Extended by ${days} day${days === 1 ? '' : 's'} to ${validUntil.toLocaleDateString('en-GB')}.` + + (trimmed ? ` ${trimmed}` : ''), + 'EXTENDED', + actorId, + 'STAFF', + ); + await this.contractsRepository.update(contractId, { + status: restored, + statusBeforeExpiry: null, + extensionRequestedAt: null, + contractValidUntil: validUntil, + } as never); + const updated = await this.contractsService.findById(contractId); + this.notifier.extended(updated, days, validUntil, trimmed); + return updated; + } + async renew(contractId: string, userId?: string): Promise { const source = await this.contractsService.findById(contractId); diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 7cf7a8398..1df3eea0e 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -78,6 +78,10 @@ import { import { SignContractDto } from './dto/sign-contract.dto'; import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto'; import { RenewContractDto } from './dto/renew-contract.dto'; +import { + ExtendContractDto, + RequestContractExtensionDto, +} from './dto/extend-contract.dto'; import { CompleteConsolidatedPairDto, CreateBookingUnderContractDto, @@ -528,6 +532,52 @@ export class ContractsController { ); } + @Post(':id/extension-request') + @PortalCustomer() + @ApiOperation({ + summary: 'Customer asks EDR to extend the validity of their expired contract', + }) + async requestExtension( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: RequestContractExtensionDto, + @CurrentUser() user: TCurrentUser, + ) { + // Same ownership rule as cancel/renew: staff with bookings.view/contracts.view + // pass through, everyone else must own the contract's company. + const contract = await this.contractsService.findById(id); + if ( + !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && + !hasFreightPermission(user, FREIGHT_PERMS.contracts.view) + ) { + await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); + } + return this.transitionService.requestExtension( + id, + dto.note, + resolveAuthUserId(user), + ); + } + + @Post(':id/extend') + @BookingStaff(FREIGHT_PERMS.contracts.extend) + @ApiOperation({ + summary: + 'Staff extend an expired contract the customer asked to extend — it returns to its pre-expiry status', + }) + extend( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: ExtendContractDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.transitionService.extend( + id, + dto.days, + dto.note, + resolveAuthUserId(user), + user, + ); + } + @Post(':id/cancel') @PortalCustomer() @ApiOperation({ 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 5b5e73021..9f025f350 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -135,7 +135,9 @@ export class ContractsRepository extends BaseRepository { const result = await this.repository .createQueryBuilder() .update(Contract) - .set({ status: 'EXPIRED' }) + // SET reads the pre-update row, so status_before_expiry gets the status + // being replaced — the value ContractTransitionService.extend restores. + .set({ status: 'EXPIRED', statusBeforeExpiry: () => 'status' }) .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', { @@ -155,7 +157,7 @@ export class ContractsRepository extends BaseRepository { const result = await this.repository .createQueryBuilder() .update(Contract) - .set({ status: 'EXPIRED' }) + .set({ status: 'EXPIRED', statusBeforeExpiry: () => 'status' }) .where('id = :id', { id }) .andWhere('deleted_at IS NULL') .andWhere('status NOT IN (:...terminal)', { terminal: TERMINAL_CONTRACT_STATUSES }) 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 dc9f8ac7b..83e8f8952 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -953,6 +953,20 @@ export class ContractsService { } } + // Why the customer wants more time — shown on the staff detail page while + // the extension request is pending. + if (contract.status === 'EXPIRED' && contract.extensionRequestedAt) { + try { + const note = await this.contractsRepository.findLatestReviewNote( + contract.id, + 'EXTENSION_REQUESTED', + ); + contract.latestExtensionRequestNote = note?.body ?? null; + } catch { + contract.latestExtensionRequestNote = null; + } + } + // Lets the portal disable "Cancel contract" instead of letting the customer // click it and read a 400. The API re-checks on cancel regardless. contract.activeBookingCount = diff --git a/apps/edr-freight-api/src/modules/contracts/dto/extend-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/extend-contract.dto.ts new file mode 100644 index 000000000..7c82947c8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/dto/extend-contract.dto.ts @@ -0,0 +1,31 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator'; + +/** Customer asks EDR to extend the validity of their EXPIRED contract. */ +export class RequestContractExtensionDto { + @ApiPropertyOptional({ description: 'Why the customer needs the contract extended' }) + @IsOptional() + @IsString() + @MaxLength(2000) + note?: string; +} + +/** Staff extend an EXPIRED contract that the customer asked to extend. */ +export class ExtendContractDto { + @ApiProperty({ + description: + 'Days to add. Counted from today when the contract has already lapsed, otherwise from its current end date.', + minimum: 1, + maximum: 3650, + }) + @IsInt() + @Min(1) + @Max(3650) + days!: number; + + @ApiPropertyOptional({ description: 'Optional note recorded with the extension and shown to the customer' }) + @IsOptional() + @IsString() + @MaxLength(2000) + note?: string; +} diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract-review-note.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract-review-note.entity.ts index 5b64fe5f7..693c0edbe 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract-review-note.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract-review-note.entity.ts @@ -19,6 +19,10 @@ export const CONTRACT_REVIEW_NOTE_TYPES = [ 'SUSPENSION_LIFTED', /** Customer cancelled their own contract; body is their reason. */ 'CANCELLATION', + /** Customer asked for an EXPIRED contract's validity to be extended. */ + 'EXTENSION_REQUESTED', + /** Staff extended the validity; body records the days added and the new end. */ + 'EXTENDED', ] as const; export type ContractReviewNoteType = (typeof CONTRACT_REVIEW_NOTE_TYPES)[number]; diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts index b776333b9..fd3b54b58 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts @@ -239,6 +239,23 @@ export class Contract extends BaseEntity { @Column({ name: 'status_before_suspension', type: 'varchar', length: 40, nullable: true }) statusBeforeSuspension?: string | null; + /** + * Status the contract held when it lapsed to EXPIRED (stamped by both the + * nightly sweep and the lazy flip on read), restored when staff extend the + * validity. Null on rows that expired before the column existed — extension + * then falls back to the kind's post-signature resting status. + */ + @Column({ name: 'status_before_expiry', type: 'varchar', length: 40, nullable: true }) + statusBeforeExpiry?: string | null; + + /** + * When the customer asked for the validity of this EXPIRED contract to be + * extended. Set by the portal request, cleared when staff extend. Staff + * cannot extend a contract the customer has not asked about. + */ + @Column({ name: 'extension_requested_at', type: 'timestamptz', nullable: true }) + extensionRequestedAt?: Date | null; + @Column({ name: 'clearance_status', type: 'varchar', length: 40, default: 'NOT_APPLICABLE' }) clearanceStatus!: string; @@ -373,6 +390,13 @@ export class Contract extends BaseEntity { */ latestSuspensionNote?: string | null; + /** + * Body of the most recent EXTENSION_REQUESTED review note, attached by + * ContractsService.findById while an extension request is pending so staff + * see why the customer wants the contract extended. Not a column. + */ + latestExtensionRequestNote?: string | null; + /** * Count of this contract's non-terminal bookings, attached by * ContractsService.findById. The portal disables customer cancellation while diff --git a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts index 1c7cd11ca..1416d1d16 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts @@ -871,7 +871,7 @@ export class TrainSchedulingController { @TrainSchedulingView() @ApiOperation({ summary: - "Download the schedule's wagon list as an Excel workbook (one row per container: wagon, container, VGM, route, customer)", + "Download the schedule's wagon list as an Excel workbook (containers grouped by customer: wagon, container, size, route, company, transitor)", }) async scheduleWagonListExport( @Param("id", ParseUUIDPipe) id: string, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts index 50debc6c0..1037364f2 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts @@ -3,6 +3,13 @@ import { Column, Entity } from 'typeorm'; @Entity({ schema: 'freight', name: 'train_scheduling_global_rules' }) export class TrainSchedulingGlobalRules extends BaseEntity { + /** + * LEGACY — `max_train_length_meters`, `max_train_weight_tons` and + * `max_20ft_container_weight_tons` are no longer read by planning: train + * weight/length come from locomotive configuration and per-box ceilings from + * the rule engine's weight limit rules (`max_capacity_tons`). Kept only so + * existing rows keep loading. + */ @Column({ name: 'max_train_length_meters', type: 'numeric', diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts index f61593442..03235a983 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts @@ -5,6 +5,7 @@ import { Wagon } from '../../wagons/entities/wagon.entity'; import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity'; import { TrainSchedulingGlobalRules } from '../entities/train-scheduling-global-rules.entity'; +import { WeightLimitRule } from '../../rule-engine/entities/weight-limit-rule.entity'; import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity'; import { TrainSchedulingService } from './train-scheduling.service'; @@ -2211,4 +2212,46 @@ describe('TrainSchedulingService', () => { expect(written.windowPhase).toBeUndefined(); }); }); + + describe('containerCapacityCeilingsByLine — weight limit rule capacity', () => { + const ceilings = (bookings: unknown[]) => + ( + service as never as { + containerCapacityCeilingsByLine: (b: unknown[]) => Promise>; + } + ).containerCapacityCeilingsByLine(bookings); + + it('maps each container line to its rule capacity, exact direction winning over BOTH', async () => { + const find = jest.fn().mockResolvedValue([ + { containerTypeId: 'ct-20', tradeDirection: 'BOTH', maxCapacityTons: '28.000' }, + { containerTypeId: 'ct-20', tradeDirection: 'EXPORT', maxCapacityTons: '26.000' }, + { containerTypeId: 'ct-40', tradeDirection: 'IMPORT', maxCapacityTons: null }, + ]); + dataSource.getRepository.mockImplementation((entity: unknown) => { + if (entity === WeightLimitRule) return { find }; + throw new Error('unexpected repository'); + }); + + const result = await ceilings([ + { + tradeDirection: 'EXPORT', + bookingContainers: [ + { id: 'line-a', containerTypeId: 'ct-20' }, + { id: 'line-b', containerTypeId: 'ct-40' }, + ], + }, + { tradeDirection: 'IMPORT', bookingContainers: [{ id: 'line-c', containerTypeId: 'ct-20' }] }, + ]); + + expect(result).toEqual({ 'line-a': 26, 'line-c': 28 }); + expect(find).toHaveBeenCalledTimes(1); + }); + + it('queries nothing when the bookings carry no container lines', async () => { + dataSource.getRepository.mockImplementation(() => { + throw new Error('should not be called'); + }); + await expect(ceilings([{ tradeDirection: 'EXPORT', bookingContainers: [] }])).resolves.toEqual({}); + }); + }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index 4d8a5ea34..bfbeafed6 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -75,25 +75,13 @@ import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; import { WagonTypesRepository } from '../../wagon-types/wagon-types.repository'; import { Wagon } from '../../wagons/entities/wagon.entity'; import { WagonEventInput, WagonHistoryService } from '../../wagon-history/wagon-history.service'; -import { TabularExportService } from '../../exports/tabular-export.service'; +import { + buildWagonListWorkbook, + groupWagonListLines, + WagonListLine, +} from '../utils/wagon-list-workbook.util'; /** One line of the schedule wagon-list export (raw SQL projection). */ -interface ScheduleWagonListRow { - sequenceNo: number | null; - wagonNumber: string | null; - wagonType: string | null; - containerNumber: string | null; - containerSizeFt: number | null; - loadType: string | null; - status: string | null; - bulkCargoDescription: string | null; - /** numeric columns arrive as strings from pg. */ - vgmTons: string | null; - originLabel: string | null; - destinationLabel: string | null; - bookingReference: string | null; - customerName: string | null; -} import { AdjustScheduleConsistDto } from '../dto/adjust-schedule-consist.dto'; import { AssignBookingsDto } from '../dto/assign-bookings.dto'; import { CreateContainerTrainScheduleDto } from '../dto/create-container-train-schedule.dto'; @@ -112,6 +100,7 @@ import { PreviewBulkTrainScheduleDto } from '../dto/preview-bulk-train-schedule. import { PreviewContainerTrainScheduleDto } from '../dto/preview-container-train-schedule.dto'; import { PreviewTrainScheduleDto } from '../dto/preview-train-schedule.dto'; import { TrainSchedulingGlobalRules } from '../entities/train-scheduling-global-rules.entity'; +import { WeightLimitRule } from '../../rule-engine/entities/weight-limit-rule.entity'; import { UpdateTrainSchedulingGlobalRulesDto } from '../dto/update-train-scheduling-global-rules.dto'; import { ImportDjiboutiOperation, @@ -160,6 +149,7 @@ import { validateMixedTrainLimitsPerEdge, MAX_TEU_SLOTS_PER_WAGON, type ContainerPlacementInput, + type ContainerPlacementRules, type WagonPlanSlot, } from '../utils/wagon-plan.util'; import { @@ -190,6 +180,8 @@ import { trainSetLocomotiveLimits, wagonTypeDimensionsFromEntity, LocomotiveLimits, + MAX_FALLBACK_LENGTH, + MAX_FALLBACK_WEIGHT, WagonTypeDimensions, } from '../train-capacity.util'; import { @@ -379,13 +371,13 @@ export interface UnassignedBookingsResponse { bookings: CompositionUnassignedBookingRow[]; } -const DEFAULT_TRAIN_LIMITS: Required = { - maxWeightTons: 3500, - maxLengthMeters: 760, - maxWagonsPerTrain: Math.floor(760 / 14), - max20ftContainerWeightTons: 30, - max20ftPairWeightDiffTons: 10, -}; +/** + * Train weight/length come from locomotive configuration (the assigned set, or + * the strongest in-service locomotive when none is assigned yet); per-box + * container ceilings come from the rule engine's weight limit rules. Only the + * 20ft pair-imbalance tolerance is a static default. + */ +const DEFAULT_20FT_PAIR_WEIGHT_DIFF_TONS = 10; /** Raw row shape for the booking-window queries (company- and contract-scoped). */ interface BookingWindowRow { @@ -447,11 +439,9 @@ export class TrainSchedulingService { // Per-wagon history ledger (global module). @Optional keeps the positional // spec constructors working; production always has it. @Optional() private readonly wagonHistory?: WagonHistoryService, - // Trailing + @Optional so the positional constructors in the existing specs - // keep working; production always resolves it from ExportsModule. - @Optional() private readonly tabularExport?: TabularExportService, // Crew composition gate (ITLMS Rolling Stock §1.2 "prior to departure"). - // Trailing + @Optional for the same positional-spec reason as above. + // Trailing + @Optional so the positional constructors in the existing specs + // keep working; production always resolves it. @Optional() private readonly trainCrewAssignments?: TrainCrewAssignmentService, ) {} @@ -813,9 +803,9 @@ export class TrainSchedulingService { } /** - * Train length/weight and 20ft weight caps are engine-internal (wagon - * planning still reads them off the row); they are no longer exposed or - * editable through the global-rules endpoints. + * Train length/weight and the 20ft weight cap columns are legacy: planning + * now takes weight/length from locomotive configuration and per-box ceilings + * from weight limit rules. They are neither read nor exposed here. */ private toPublicGlobalRules(row: TrainSchedulingGlobalRules | null) { if (!row) return row; @@ -3779,15 +3769,15 @@ export class TrainSchedulingService { } /** - * The schedule detail page's wagon-list Excel export. - * - * One row per container (a wagon carrying two boxes yields two rows, repeating - * the wagon number) so each container's own VGM is present and totals footable. - * Bulk wagons, having no containers, yield a single row carrying the bulk - * description and the allocated tonnage as the VGM figure. + * The schedule detail page's wagon-list Excel export, laid out like the + * wagon sheet the yard circulates by hand: containers grouped by customer, + * one line per container (a two-box wagon repeats its wagon number under one + * "No."), a blank line between customers, and the wagon count / company / + * transitor merged down each group. See buildWagonListWorkbook. * * Only wagon slots that actually carry an allocation are listed — empty slots - * on the consist are omitted. + * on the consist are omitted. A bulk wagon yields one line carrying the cargo + * description in place of a container number. */ async scheduleWagonListWorkbook( scheduleId: string, @@ -3796,56 +3786,37 @@ export class TrainSchedulingService { if (!schedule) { throw new NotFoundException(`Train schedule ${scheduleId} not found`); } - if (!this.tabularExport) { - throw new BadRequestException('Tabular export service is unavailable'); - } - // Row grain is the container item; the LEFT JOIN keeps bulk (and any - // container-less) allocation as one row. `booking_container_units` is joined - // on BOTH container number and its booking_container line — container - // numbers repeat across bookings, so number alone would multiply rows. - const rows: ScheduleWagonListRow[] = await this.dataSource.query( + // Row grain is the container item; the LEFT JOIN keeps a bulk (or any + // container-less) allocation as one row. The transitor is the customs + // clearing agent the customer named on the booking. + const lines: WagonListLine[] = await this.dataSource.query( `SELECT tsw.sequence_no AS "sequenceNo", w.wagon_number AS "wagonNumber", - COALESCE(wt.name, wt.code) AS "wagonType", ci.container_number AS "containerNumber", cit.size_ft AS "containerSizeFt", a.load_type AS "loadType", - a.status AS "status", bl.cargo_description AS "bulkCargoDescription", - COALESCE( - ci.gross_weight_tons, - bcu.vgm_tons, - bc.vgm_per_unit_tons, - a.allocated_weight_tons - ) AS "vgmTons", COALESCE(by_.label, so.label) AS "originLabel", COALESCE(ay.label, sd.label) AS "destinationLabel", - b.reference AS "bookingReference", COALESCE( slc.name, CASE WHEN b.is_government THEN NULLIF(TRIM(b.government_institution), '') END, c.name - ) AS "customerName" + ) AS "customerName", + NULLIF(TRIM(b.customs_clearing_agent), '') AS "transitor" FROM freight.train_schedules s JOIN freight.train_set_wagons tsw ON tsw.train_set_id = s.train_set_id AND tsw.deleted_at IS NULL JOIN freight.wagon_booking_allocations a ON a.train_set_wagon_id = tsw.id AND a.deleted_at IS NULL LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id - LEFT JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id LEFT JOIN freight.bookings b ON b.id = a.booking_id LEFT JOIN freight.companies c ON c.id = b.company_id LEFT JOIN freight.shipping_line_companies slc ON slc.id = b.shipping_line_company_id LEFT JOIN freight.wagon_allocation_container_items ci ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL LEFT JOIN freight.container_types cit ON cit.id = ci.container_type_id - LEFT JOIN freight.booking_container bc - ON bc.id = ci.booking_container_id AND bc.deleted_at IS NULL - LEFT JOIN freight.booking_container_units bcu - ON bcu.container_number = ci.container_number - AND bcu.booking_container_id = bc.id - AND bcu.deleted_at IS NULL LEFT JOIN freight.wagon_allocation_bulk_loads bl ON bl.wagon_booking_allocation_id = a.id AND bl.deleted_at IS NULL LEFT JOIN freight.yards so ON so.id = s.origin_station_id @@ -3857,47 +3828,13 @@ export class TrainSchedulingService { [scheduleId], ); - // "number" is the printed line number of the sheet, not the wagon sequence — - // a two-container wagon occupies two lines, and the reader counts lines. - const sheetRows = rows.map((row, index) => ({ - number: index + 1, - wagonNumber: row.wagonNumber ?? '—', - containerNumber: - row.containerNumber ?? - (row.loadType === 'BULK' ? (row.bulkCargoDescription ?? 'Bulk') : '—'), - vgmTons: row.vgmTons === null ? null : Number(row.vgmTons), - originLabel: row.originLabel ?? '—', - destinationLabel: row.destinationLabel ?? '—', - customerName: row.customerName ?? '—', - })); - - const totalVgm = sheetRows.reduce((sum, r) => sum + (r.vgmTons ?? 0), 0); - const reference = schedule.reference ?? schedule.trainNumber ?? schedule.id; - - const buffer = await this.tabularExport.toXlsx({ - title: `Wagons ${reference}`.slice(0, 31), - description: `Wagon list for train ${reference}`, - label: 'train-schedule:wagon-list', - kpis: [ - { label: 'Lines', value: sheetRows.length }, - { - label: 'Wagons', - value: new Set(rows.map((r) => r.sequenceNo)).size, - }, - { label: 'Total VGM', value: Number(totalVgm.toFixed(3)), unit: 't' }, - ], - columns: [ - { key: 'number', label: 'No.', type: 'number' }, - { key: 'wagonNumber', label: 'Wagon', type: 'string' }, - { key: 'containerNumber', label: 'Container number', type: 'string' }, - { key: 'vgmTons', label: 'VGM', type: 'tons' }, - { key: 'originLabel', label: 'Origin', type: 'string' }, - { key: 'destinationLabel', label: 'Destination', type: 'string' }, - { key: 'customerName', label: 'Customer', type: 'string' }, - ], - rows: sheetRows, + const { groups, totalWagons } = groupWagonListLines(lines); + const buffer = await buildWagonListWorkbook({ + trainLabel: schedule.trainNumber ?? schedule.reference ?? schedule.id, + groups, + totalWagons, }); - + const reference = schedule.reference ?? schedule.trainNumber ?? schedule.id; return { filename: `wagon-list-${this.safeDocumentName(reference)}.xlsx`, buffer, @@ -6717,11 +6654,6 @@ export class TrainSchedulingService { )), ); - const placementRules = { - max20ftContainerWeightTons: trainLimits.max20ftContainerWeightTons, - max20ftPairWeightDiffTons: trainLimits.max20ftPairWeightDiffTons, - }; - // With forceAssign, capacity-shaped rules (train limits, total weight, // locomotive capability) become warnings — staff owns the override. Physical // impossibilities (no wagon of the required type at the yard, wrong route, @@ -6754,6 +6686,11 @@ export class TrainSchedulingService { ); if (requireContainerPlacements && resolvedMode !== 'BULK') { const containerBookings = fittingBookings.filter((b) => b.freightType === 'CONTAINER'); + const placementRules: ContainerPlacementRules = { + maxContainerWeightTonsByLineId: + await this.containerCapacityCeilingsByLine(containerBookings), + max20ftPairWeightDiffTons: trainLimits.max20ftPairWeightDiffTons, + }; violations.push( ...validateContainerPlacements( containerBookings, @@ -6901,6 +6838,70 @@ export class TrainSchedulingService { } } + /** + * Hard per-box ceiling for every container line of the given bookings, from + * the rule engine's weight limit rule (`max_capacity_tons`) matching the + * line's container type and the booking's trade direction (a `BOTH` rule + * applies to either direction; an exact-direction rule wins over it). Lines + * whose rule has no capacity set get no entry — capacity is optional. + */ + private async containerCapacityCeilingsByLine( + bookings: Booking[], + ): Promise> { + const lines: Array<{ lineId: string; containerTypeId: string; tradeDirection: string }> = []; + for (const booking of bookings) { + const direction = String(booking.tradeDirection ?? '').toUpperCase(); + for (const line of booking.bookingContainers ?? []) { + if (!line.containerTypeId) continue; + lines.push({ lineId: line.id, containerTypeId: line.containerTypeId, tradeDirection: direction }); + } + } + if (!lines.length) return {}; + + const typeIds = [...new Set(lines.map((l) => l.containerTypeId))]; + const rules = await this.dataSource + .getRepository(WeightLimitRule) + .find({ where: { containerTypeId: In(typeIds) } }); + + const ceilings: Record = {}; + for (const { lineId, containerTypeId, tradeDirection } of lines) { + const candidates = rules.filter( + (r) => r.containerTypeId === containerTypeId && r.maxCapacityTons != null, + ); + const rule = + candidates.find((r) => r.tradeDirection === tradeDirection) ?? + candidates.find((r) => r.tradeDirection === 'BOTH'); + const cap = Number(rule?.maxCapacityTons); + if (Number.isFinite(cap) && cap > 0) ceilings[lineId] = cap; + } + return ceilings; + } + + /** + * Limits for a train that has no locomotive assigned yet: the strongest + * in-service locomotive on each axis, so planning assumes the most capable + * power that could be coupled. Null when no locomotive is configured at all. + */ + private async strongestFleetLocomotiveLimits(): Promise { + const fleet = await this.locomotivesRepository.findAll({ + where: { status: Not('OUT_OF_SERVICE' as Locomotive['status']) }, + }); + const pulls = fleet.map((l) => Number(l.maxPullWeightTons)).filter((v) => v > 0); + const lengths = fleet.map((l) => Number(l.maxTrainLengthMeters)).filter((v) => v > 0); + if (!pulls.length && !lengths.length) return null; + const strongest = (axis: number[], pick: (l: Locomotive) => number) => + fleet.find((l) => pick(l) === Math.max(...axis)); + return { + maxPullWeightTons: pulls.length ? Math.max(...pulls) : Infinity, + maxTrainLengthMeters: lengths.length ? Math.max(...lengths) : Infinity, + overageToleranceTons: + Number(strongest(pulls, (l) => Number(l.maxPullWeightTons))?.overageToleranceTons) || 0, + overageToleranceMeters: + Number(strongest(lengths, (l) => Number(l.maxTrainLengthMeters))?.overageToleranceMeters) || + 0, + }; + } + private async resolveTrainLimitConfig( dto?: { maxTrainWeightTons?: number; @@ -6911,24 +6912,14 @@ export class TrainSchedulingService { builtWagonCount?: number, ): Promise> { const row = await this.loadGlobalRulesRow(); - const configured = this.configService?.get<{ - maxTrainWeightTons?: number; - maxTrainLengthMeters?: number; - maxWagonsPerTrain?: number; - }>('app.trainScheduling'); - - const ruleWeightCap = - dto?.maxTrainWeightTons ?? - (row?.maxTrainWeightTons != null - ? Number(row.maxTrainWeightTons) - : configured?.maxTrainWeightTons); - const ruleLengthCap = - dto?.maxTrainLengthMeters ?? - (row?.maxTrainLengthMeters != null - ? Number(row.maxTrainLengthMeters) - : configured?.maxTrainLengthMeters); - + const configured = this.configService?.get<{ maxWagonsPerTrain?: number }>( + 'app.trainScheduling', + ); const wagonTypes = await this.loadSchedulingWagonTypeDimensions(); + const max20ftPairWeightDiffTons = this.positiveNumber( + undefined, + Number(row?.max20ftPairWeightDiffTons) || DEFAULT_20FT_PAIR_WEIGHT_DIFF_TONS, + ); if (locomotive) { // With a locomotive assigned its own limits are the single source of @@ -6963,52 +6954,40 @@ export class TrainSchedulingService { : builtWagonCount && builtWagonCount > 0 ? builtWagonCount : derived.maxWagonSlots, - max20ftContainerWeightTons: this.positiveNumber( - undefined, - Number(row?.max20ftContainerWeightTons) || - DEFAULT_TRAIN_LIMITS.max20ftContainerWeightTons, - ), - max20ftPairWeightDiffTons: this.positiveNumber( - undefined, - Number(row?.max20ftPairWeightDiffTons) || - DEFAULT_TRAIN_LIMITS.max20ftPairWeightDiffTons, - ), + max20ftPairWeightDiffTons, }; } - const maxWeightTons = this.positiveNumber( - dto?.maxTrainWeightTons, - ruleWeightCap ?? DEFAULT_TRAIN_LIMITS.maxWeightTons, - ); - const maxLengthMeters = this.positiveNumber( - dto?.maxTrainLengthMeters, - ruleLengthCap ?? DEFAULT_TRAIN_LIMITS.maxLengthMeters, - ); - const derivedWithoutLoco = deriveTrainCapacityFromLocomotive( - { maxPullWeightTons: maxWeightTons, maxTrainLengthMeters: maxLengthMeters }, + // No locomotive on the set yet: plan against the strongest in-service + // locomotive's configuration. An explicit dto override still narrows it. + const fleet = await this.strongestFleetLocomotiveLimits(); + if (!fleet) { + this.logger.warn( + 'No in-service locomotive is configured — train weight/length limits fall back to ' + + `${MAX_FALLBACK_WEIGHT}T / ${MAX_FALLBACK_LENGTH}m until a locomotive is added`, + ); + } + const derived = deriveTrainCapacityFromLocomotive( + fleet ?? { maxPullWeightTons: MAX_FALLBACK_WEIGHT, maxTrainLengthMeters: MAX_FALLBACK_LENGTH }, wagonTypes, + { + maxTrainWeightTons: dto?.maxTrainWeightTons, + maxTrainLengthMeters: dto?.maxTrainLengthMeters, + }, ); return { - maxWeightTons, - maxLengthMeters, + maxWeightTons: derived.maxWeightTons, + maxLengthMeters: derived.maxLengthMeters, maxWagonsPerTrain: Math.floor( this.positiveNumber( dto?.maxWagonsPerTrain, row?.maxWagonsPerTrain != null ? Number(row.maxWagonsPerTrain) - : configured?.maxWagonsPerTrain ?? derivedWithoutLoco.maxWagonSlots, + : configured?.maxWagonsPerTrain ?? derived.maxWagonSlots, ), ), - max20ftContainerWeightTons: this.positiveNumber( - undefined, - Number(row?.max20ftContainerWeightTons) || DEFAULT_TRAIN_LIMITS.max20ftContainerWeightTons, - ), - max20ftPairWeightDiffTons: this.positiveNumber( - undefined, - Number(row?.max20ftPairWeightDiffTons) || - DEFAULT_TRAIN_LIMITS.max20ftPairWeightDiffTons, - ), + max20ftPairWeightDiffTons, }; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts index 69bbbc965..0ce67e959 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -6,7 +6,6 @@ import { BillingModule } from '../billing/billing.module'; import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module'; import { BookingsModule } from '../bookings/bookings.module'; import { Container } from '../container-management/entities/container.entity'; -import { ExportsModule } from '../exports/exports.module'; import { LocomotivesModule } from '../locomotives/locomotives.module'; import { RuleEngineModule } from '../rule-engine/rule-engine.module'; import { FacilityHandlingService } from './facility-handling.service'; @@ -69,7 +68,6 @@ import { ContractsModule } from '../contracts/contracts.module'; UserTradeAccessModule, NotificationsModule, NotificationInboxModule, - ExportsModule, LocomotivesModule, WagonTypesModule, TrainSetsModule, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-list-workbook.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-list-workbook.util.spec.ts new file mode 100644 index 000000000..58bf11a3b --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-list-workbook.util.spec.ts @@ -0,0 +1,169 @@ +import ExcelJS from 'exceljs'; + +import { + buildWagonListWorkbook, + groupWagonListLines, + WAGON_LIST_HEADERS, + WagonListLine, + wagonListSheetName, +} from './wagon-list-workbook.util'; + +const line = (overrides: Partial): WagonListLine => ({ + sequenceNo: 1, + wagonNumber: 'ER0001', + containerNumber: 'CONT0000001', + containerSizeFt: 40, + loadType: 'CONTAINER', + bulkCargoDescription: null, + originLabel: 'DCT', + destinationLabel: 'GMP', + customerName: 'ABC transit', + transitor: null, + ...overrides, +}); + +// Mirrors the reference sheet: a 40ft wagon, a wagon carrying two 20ft boxes, +// then a second customer's single wagon, and a bulk wagon for a third. +const fixture: WagonListLine[] = [ + line({ sequenceNo: 1, wagonNumber: 'ER0691', containerNumber: 'TLLU4855720' }), + line({ + sequenceNo: 2, + wagonNumber: 'ER0693', + containerNumber: 'CXDU1833620', + containerSizeFt: 20, + transitor: 'Semuzu Transit', + }), + line({ + sequenceNo: 2, + wagonNumber: 'ER0693', + containerNumber: 'TTNU1328287', + containerSizeFt: 20, + transitor: 'Semuzu Transit', + }), + line({ + sequenceNo: 3, + wagonNumber: 'ER0444', + containerNumber: 'ESLU0720200', + containerSizeFt: 20, + customerName: 'SYNTRANS LOGISTICS PLC', + }), + line({ + sequenceNo: 4, + wagonNumber: 'ER0716', + containerNumber: null, + containerSizeFt: null, + loadType: 'BULK', + bulkCargoDescription: 'Wheat', + customerName: 'Baili food processing', + }), +]; + +describe('groupWagonListLines', () => { + it('groups by customer in first-appearance order and counts wagons, not containers', () => { + const { groups, totalWagons } = groupWagonListLines(fixture); + + expect(groups.map((g) => g.companyName)).toEqual([ + 'ABC transit', + 'SYNTRANS LOGISTICS PLC', + 'Baili food processing', + ]); + expect(groups.map((g) => g.wagonCount)).toEqual([2, 1, 1]); + expect(totalWagons).toBe(4); + }); + + it('numbers wagons across the whole sheet, repeating the ordinal for a second container', () => { + const { groups } = groupWagonListLines(fixture); + + expect(groups[0].lines.map((l) => l.wagonOrdinal)).toEqual([1, 2, 2]); + expect(groups[1].lines.map((l) => l.wagonOrdinal)).toEqual([3]); + expect(groups[2].lines.map((l) => l.wagonOrdinal)).toEqual([4]); + }); + + it('renders container size as "NNft", bulk loads by cargo description, and the transitor once per group', () => { + const { groups } = groupWagonListLines(fixture); + + expect(groups[0].lines.map((l) => l.containerType)).toEqual(['40ft', '20ft', '20ft']); + expect(groups[0].transitor).toBe('Semuzu Transit'); + expect(groups[2].lines[0]).toMatchObject({ + containerNumber: 'Wheat', + containerType: 'Bulk', + }); + expect(groups[2].transitor).toBe(''); + }); + + it('files lines with no customer under a placeholder group', () => { + const { groups } = groupWagonListLines([line({ customerName: null })]); + expect(groups[0].companyName).toBe('—'); + }); +}); + +describe('wagonListSheetName', () => { + it('strips characters Excel forbids and caps at 31 characters', () => { + expect(wagonListSheetName('V138U/8502')).toBe('V138U 8502'); + expect(wagonListSheetName('a'.repeat(40))).toHaveLength(31); + expect(wagonListSheetName('///')).toBe('Wagons'); + }); +}); + +describe('buildWagonListWorkbook', () => { + let sheet: ExcelJS.Worksheet; + + beforeAll(async () => { + const grouped = groupWagonListLines(fixture); + const buffer = await buildWagonListWorkbook({ trainLabel: 'V138U/8502', ...grouped }); + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.load(buffer as unknown as ExcelJS.Buffer); + sheet = workbook.worksheets[0]; + }); + + const cell = (address: string) => sheet.getCell(address).value; + const merged = (address: string) => sheet.getCell(address).isMerged; + + it('opens with the banner (train + total wagons) merged across every column, then the headers', () => { + expect(sheet.name).toBe('V138U 8502'); + expect(String(cell('A1'))).toMatch(/^V138U\/8502\s+Total wagons= 4$/); + expect(merged('I1')).toBe(true); + expect(sheet.getRow(2).values).toEqual([undefined, ...WAGON_LIST_HEADERS]); + expect(sheet.getCell('A2').font?.bold).toBe(true); + }); + + it('lays each customer out as a contiguous block separated by a blank row', () => { + // Rows 3-5: ABC transit; row 6 blank; row 7: SYNTRANS; row 8 blank; row 9: Baili. + expect([cell('B3'), cell('B4'), cell('B5')]).toEqual(['ER0691', 'ER0693', 'ER0693']); + expect(sheet.getRow(6).values).toEqual([]); + expect(cell('B7')).toBe('ER0444'); + expect(sheet.getRow(8).values).toEqual([]); + expect(cell('B9')).toBe('ER0716'); + expect(cell('C9')).toBe('Wheat'); + expect(cell('G9')).toBe('Bulk'); + }); + + it('prints "No." once per wagon, merged down a two-container wagon', () => { + expect([cell('A3'), cell('A4'), cell('A5')]).toEqual([1, 2, 2]); + expect(merged('A4')).toBe(true); + expect(merged('A5')).toBe(true); + expect(merged('A3')).toBe(false); + expect(cell('A7')).toBe(3); + expect(cell('A9')).toBe(4); + }); + + it('merges wagon count, company and transitor down the whole customer block', () => { + expect(cell('D3')).toBe(2); + expect(cell('H3')).toBe('ABC transit'); + expect(cell('I3')).toBe('Semuzu Transit'); + for (const col of ['D', 'H', 'I']) { + expect(merged(`${col}3`)).toBe(true); + expect(merged(`${col}5`)).toBe(true); + } + expect(sheet.getCell('H3').font?.bold).toBe(true); + // A single-line block has nothing to merge. + expect(merged('H7')).toBe(false); + expect(cell('D7')).toBe(1); + expect(cell('I7')).toBeNull(); + }); + + it('carries the route and container size on every line', () => { + expect([cell('E3'), cell('F3'), cell('G3')]).toEqual(['DCT', 'GMP', '40ft']); + expect([cell('E5'), cell('F5'), cell('G5')]).toEqual(['DCT', 'GMP', '20ft']); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-list-workbook.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-list-workbook.util.ts new file mode 100644 index 000000000..adfd04075 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-list-workbook.util.ts @@ -0,0 +1,219 @@ +import ExcelJS from 'exceljs'; + +/** + * One loaded container (or one bulk load) on a wagon of the schedule — the + * input grain of the wagon-list workbook. A wagon carrying two boxes arrives + * as two lines sharing `sequenceNo`. + */ +export interface WagonListLine { + sequenceNo: number | null; + wagonNumber: string | null; + containerNumber: string | null; + /** 20 / 40 / 45 …; null for bulk or unknown. */ + containerSizeFt: number | null; + loadType: string | null; + bulkCargoDescription: string | null; + originLabel: string | null; + destinationLabel: string | null; + customerName: string | null; + /** The customs clearing / transit agent named on the booking. */ + transitor: string | null; +} + +export interface WagonListGroupLine { + /** Sheet-wide wagon counter — printed once per wagon, not once per container. */ + wagonOrdinal: number; + sequenceNo: number | null; + wagonNumber: string; + containerNumber: string; + containerType: string; + origin: string; + destination: string; +} + +/** All lines of one customer, contiguous on the sheet. */ +export interface WagonListGroup { + companyName: string; + transitor: string; + /** Distinct wagons in the group — the "Number of Wagons" cell. */ + wagonCount: number; + lines: WagonListGroupLine[]; +} + +export interface WagonListWorkbookInput { + /** Train number (falls back to the schedule reference) — the banner text. */ + trainLabel: string; + groups: WagonListGroup[]; + totalWagons: number; +} + +const BLANK = '—'; + +/** + * Groups the container-grain lines by customer, in order of first appearance, + * keeping consist order inside each group. Wagon ordinals run across the whole + * sheet so the reader can count wagons down the "No." column. + */ +export function groupWagonListLines(lines: WagonListLine[]): { + groups: WagonListGroup[]; + totalWagons: number; +} { + const groups = new Map< + string, + WagonListGroup & { transitors: Set; wagons: Set } + >(); + const ordinalByGroupWagon = new Map(); + let nextOrdinal = 1; + + for (const line of lines) { + const companyName = line.customerName?.trim() || BLANK; + let group = groups.get(companyName); + if (!group) { + group = { + companyName, + transitor: '', + wagonCount: 0, + lines: [], + transitors: new Set(), + wagons: new Set(), + }; + groups.set(companyName, group); + } + + const wagonKey = `${line.sequenceNo ?? ''}|${line.wagonNumber ?? ''}`; + const ordinalKey = `${companyName} ${wagonKey}`; + let wagonOrdinal = ordinalByGroupWagon.get(ordinalKey); + if (wagonOrdinal === undefined) { + wagonOrdinal = nextOrdinal++; + ordinalByGroupWagon.set(ordinalKey, wagonOrdinal); + group.wagons.add(wagonKey); + } + const transitor = line.transitor?.trim(); + if (transitor) group.transitors.add(transitor); + + const isBulk = line.loadType === 'BULK' && !line.containerNumber; + group.lines.push({ + wagonOrdinal, + sequenceNo: line.sequenceNo, + wagonNumber: line.wagonNumber ?? BLANK, + containerNumber: + line.containerNumber ?? (isBulk ? (line.bulkCargoDescription ?? 'Bulk') : BLANK), + containerType: isBulk ? 'Bulk' : line.containerSizeFt ? `${line.containerSizeFt}ft` : BLANK, + origin: line.originLabel ?? BLANK, + destination: line.destinationLabel ?? BLANK, + }); + } + + const result = [...groups.values()].map(({ transitors, wagons, ...group }) => ({ + ...group, + transitor: [...transitors].join(', '), + wagonCount: wagons.size, + })); + return { + groups: result, + totalWagons: result.reduce((sum, g) => sum + g.wagonCount, 0), + }; +} + +const COLUMN_WIDTHS = [3.7, 14.9, 14.9, 17.3, 15, 12.8, 16.2, 27.5, 29.9]; +export const WAGON_LIST_HEADERS = [ + 'No.', + 'Wagon', + 'Container No.', + 'Number of Wagons', + 'Origin', + 'Destination', + 'Type of Container', + 'Company Name', + 'Transitor', +]; +const LAST_COLUMN = WAGON_LIST_HEADERS.length; +/** Excel's "Blue-Gray, Text 2, Lighter 60%" — the banner fill of the reference sheet. */ +const BANNER_FILL: ExcelJS.Fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFACB9CA' }, +}; +const CENTERED: Partial = { horizontal: 'center', vertical: 'middle' }; + +/** Excel forbids `[]:*?/\` in sheet names and caps them at 31 characters. */ +export function wagonListSheetName(trainLabel: string): string { + const cleaned = trainLabel.replace(/[[\]:*?/\\]+/g, ' ').trim(); + return (cleaned || 'Wagons').slice(0, 31); +} + +/** + * The operations wagon-list sheet, laid out like the hand-made one the yard + * circulates: a banner row (train number + total wagons), one header row, then + * the containers grouped by customer with a blank row between customers. + * Inside a group the wagon number repeats per container while "No." is merged + * down the wagon; "Number of Wagons", "Company Name" and "Transitor" are merged + * down the whole group. + */ +export async function buildWagonListWorkbook(input: WagonListWorkbookInput): Promise { + const workbook = new ExcelJS.Workbook(); + const sheet = workbook.addWorksheet(wagonListSheetName(input.trainLabel), { + views: [{ zoomScale: 85 }], + }); + COLUMN_WIDTHS.forEach((width, i) => { + sheet.getColumn(i + 1).width = width; + }); + + const banner = sheet.addRow([ + `${input.trainLabel}${' '.repeat(40)}Total wagons= ${input.totalWagons}`, + ]); + sheet.mergeCells(1, 1, 1, LAST_COLUMN); + banner.height = 28; + const bannerCell = banner.getCell(1); + bannerCell.font = { name: 'Calibri', size: 12, bold: true }; + bannerCell.alignment = CENTERED; + bannerCell.fill = BANNER_FILL; + + const header = sheet.addRow(WAGON_LIST_HEADERS); + header.eachCell((cell) => { + cell.font = { name: 'Calibri', size: 11, bold: true }; + cell.alignment = CENTERED; + }); + + input.groups.forEach((group, groupIndex) => { + if (groupIndex > 0) sheet.addRow([]); + const firstRow = sheet.rowCount + 1; + + let wagonStartRow = firstRow; + group.lines.forEach((line, lineIndex) => { + const isFirstLine = lineIndex === 0; + const newWagon = isFirstLine || group.lines[lineIndex - 1].wagonOrdinal !== line.wagonOrdinal; + const row = sheet.addRow([ + newWagon ? line.wagonOrdinal : null, + line.wagonNumber, + line.containerNumber, + isFirstLine ? group.wagonCount : null, + line.origin, + line.destination, + line.containerType, + isFirstLine ? group.companyName : null, + isFirstLine ? group.transitor || null : null, + ]); + for (let col = 1; col <= LAST_COLUMN; col++) { + const cell = row.getCell(col); + cell.font = { name: 'Calibri', size: 11, bold: col === 8 }; + if (col === 8) cell.alignment = { ...CENTERED, wrapText: true }; + else if (col !== 2 && col !== 3) cell.alignment = CENTERED; + } + row.getCell(1).numFmt = '#,##0'; + + if (newWagon && !isFirstLine) { + if (row.number - 1 > wagonStartRow) sheet.mergeCells(wagonStartRow, 1, row.number - 1, 1); + wagonStartRow = row.number; + } + }); + + const lastRow = sheet.rowCount; + if (lastRow > wagonStartRow) sheet.mergeCells(wagonStartRow, 1, lastRow, 1); + if (lastRow > firstRow) { + for (const col of [4, 8, 9]) sheet.mergeCells(firstRow, col, lastRow, col); + } + }); + + return Buffer.from(await workbook.xlsx.writeBuffer()); +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.spec.ts index a9741b476..f8b831bb0 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.spec.ts @@ -171,7 +171,7 @@ describe('wagon-plan.util', () => { expect(validateContainerPlacements([booking], plan, placements)).toEqual([]); }); - it('rejects 20ft container over max individual weight', () => { + it('rejects a container over its line weight-limit-rule capacity', () => { const booking = makeContainerBooking('c20', [{ quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 35 }]); const units = expandBookingContainerUnits([booking]); const placements = units.map((unit, index) => ({ @@ -182,11 +182,29 @@ describe('wagon-plan.util', () => { })); const violations = validate20ftContainerRules(units, placements, { - max20ftContainerWeightTons: 30, + maxContainerWeightTonsByLineId: { [units[0]!.bookingContainerId]: 30 }, max20ftPairWeightDiffTons: 10, }); - expect(violations.some((v) => v.includes('exceeds max 30T'))).toBe(true); + expect(violations.filter((v) => v.includes('weight limit rule capacity of 30T'))).toHaveLength(2); + }); + + it('applies no per-box ceiling to a line without a weight-limit-rule capacity', () => { + const booking = makeContainerBooking('c20b', [{ quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 35 }]); + const units = expandBookingContainerUnits([booking]); + const placements = units.map((unit, index) => ({ + bookingContainerId: unit.bookingContainerId, + unitIndex: unit.unitIndex, + sequenceNo: 1, + containerNumber: `CNTR-${index + 1}`, + })); + + const violations = validate20ftContainerRules(units, placements, { + maxContainerWeightTonsByLineId: {}, + max20ftPairWeightDiffTons: 10, + }); + + expect(violations).toEqual([]); }); it('rejects 20ft pair when weight difference exceeds limit', () => { @@ -204,7 +222,6 @@ describe('wagon-plan.util', () => { })); const violations = validate20ftContainerRules(units, placements, { - max20ftContainerWeightTons: 30, max20ftPairWeightDiffTons: 10, }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts index b4e87d398..b31b5c7e2 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts @@ -20,12 +20,17 @@ export type TrainLimitConfig = { maxWeightTons?: number; maxLengthMeters?: number; maxWagonsPerTrain?: number; - max20ftContainerWeightTons?: number; max20ftPairWeightDiffTons?: number; }; export type ContainerPlacementRules = { - max20ftContainerWeightTons?: number; + /** + * Hard per-box weight ceiling keyed by booking container LINE id, resolved + * from the rule engine's weight limit rule (`max_capacity_tons`) for the + * line's container type and the booking's trade direction. A line with no + * entry has no ceiling — the rule's capacity is optional. + */ + maxContainerWeightTonsByLineId?: Record; max20ftPairWeightDiffTons?: number; }; @@ -820,15 +825,21 @@ export function perEdgeConsistUsage( ); } +/** + * Per-box weight rules for a container plan: + * - every unit is checked against its line's weight-limit-rule capacity + * ceiling (`maxContainerWeightTonsByLineId`, any size); + * - 20ft pairs sharing a wagon are checked for weight imbalance. + */ export function validate20ftContainerRules( units: ContainerUnitRow[], placements: ContainerPlacementInput[], rules?: ContainerPlacementRules, ): string[] { const violations: string[] = []; - const maxEach = rules?.max20ftContainerWeightTons; + const capacityByLine = rules?.maxContainerWeightTonsByLineId; const maxDiff = rules?.max20ftPairWeightDiffTons; - if (maxEach == null && maxDiff == null) return violations; + if (capacityByLine == null && maxDiff == null) return violations; const placementByUnit = new Map( placements.map((p) => [`${p.bookingContainerId}:${p.unitIndex}`, p]), @@ -837,15 +848,16 @@ export function validate20ftContainerRules( const weightsBySlot = new Map(); for (const unit of units) { - const sizeFt = unit.sizeFt ?? (unit.containerTypeCode.includes('40') ? 40 : 20); - if (sizeFt >= 40) continue; - + const maxEach = capacityByLine?.[unit.bookingContainerId]; if (maxEach != null && unit.grossWeightTons > maxEach) { violations.push( - `${unit.label} weight ${unit.grossWeightTons}T exceeds max ${maxEach}T for 20ft containers`, + `${unit.label} weight ${unit.grossWeightTons}T exceeds the weight limit rule capacity of ${maxEach}T for ${unit.containerTypeCode} containers`, ); } + const sizeFt = unit.sizeFt ?? (unit.containerTypeCode.includes('40') ? 40 : 20); + if (sizeFt >= 40) continue; + const placement = placementByUnit.get(`${unit.bookingContainerId}:${unit.unitIndex}`); if (!placement?.sequenceNo) continue; 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 7e90f6dff..2eb64913d 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -472,6 +472,13 @@ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:contracts:cancel", "Cancel a contract (terminal)", ), + // Add validity days to an EXPIRED contract the customer asked to extend and + // put it back where it was. Sits on the same desk as suspend/cancel. + perm( + "a3000001-0001-4000-8000-00000000001d", + "edr_freight_app:contracts:extend", + "Extend an expired contract", + ), ]; // Historical ids. EdrOrgSeeder no longer sends them — it upserts on `key` and @@ -2124,6 +2131,7 @@ export const FREIGHT_PERMS = { clearanceDutyAdvise: "edr_freight_app:contracts:clearance_duty_advise", suspend: "edr_freight_app:contracts:suspend", cancel: "edr_freight_app:contracts:cancel", + extend: "edr_freight_app:contracts:extend", editDocument: "edr_freight_app:contracts:edit_document", finalInvoiceRaise: "edr_freight_app:contracts:final_invoice_raise", finalInvoiceConfirm: "edr_freight_app:contracts:final_invoice_confirm", @@ -2958,6 +2966,8 @@ export const ROLE_PERMISSION_PRESETS = { // Terminal kill switch, granted alongside suspend on the same desk that // already rejects contracts and cancels bookings. FREIGHT_PERMS.contracts.cancel, + // Validity extension of an expired contract, on customer request. + FREIGHT_PERMS.contracts.extend, FREIGHT_PERMS.contracts.editDocument, ...BOOKING_DESK_NOTIFICATION_KEYS, // Marketing follows up with the customer when a reviewer sends profile diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx index be70ffe4d..d1508d475 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx @@ -1,9 +1,19 @@ import { useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; -import { Button, Group, Modal, Stack, Text, Textarea } from "@mantine/core"; +import { + Button, + Group, + Modal, + NumberInput, + Stack, + Text, + Textarea, +} from "@mantine/core"; import { Ban, + CalendarClock, + CalendarPlus, Check, Eye, // FilePen, // ponytail: back with the "Edit contract articles" button @@ -84,6 +94,9 @@ export function ContractActionsToolbar({ const maySuspend = hasPermission(user, FREIGHT_PERMS.contracts.suspend); // Cancel is its own key — it is terminal, so it is NOT implied by suspend. const mayCancel = hasPermission(user, FREIGHT_PERMS.contracts.cancel); + // Revive an EXPIRED contract by adding validity days — only after the + // customer asked for it from the portal (the API enforces the same). + const mayExtend = hasPermission(user, FREIGHT_PERMS.contracts.extend); const [editorOpen, setEditorOpen] = useState(false); const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept"); @@ -98,6 +111,9 @@ export function ContractActionsToolbar({ const [resumeNote, setResumeNote] = useState(""); const [cancelOpen, setCancelOpen] = useState(false); const [cancelReason, setCancelReason] = useState(""); + const [extendOpen, setExtendOpen] = useState(false); + const [extendDays, setExtendDays] = useState(30); + const [extendNote, setExtendNote] = useState(""); // Shared by the suspended branch and the normal toolbar — both can cancel. const cancelModal = ( @@ -183,7 +199,132 @@ export function ContractActionsToolbar({ [validitySetting], ); - if (["REJECTED", "CANCELLED", "EXPIRED", "CONTRACT_CLOSED"].includes(status)) { + // Lapsed: nothing to do until the customer asks for more time from the + // portal. Once they have, staff add days and the contract returns to the + // status it held before it expired. + if (status === "EXPIRED") { + const requestedAt = contract.extensionRequestedAt + ? new Date(contract.extensionRequestedAt) + : null; + const currentEnd = contract.contractValidUntil + ? new Date(contract.contractValidUntil) + : null; + // Mirrors ContractTransitionService.extend: days count from today once the + // contract has lapsed, from the current end date otherwise. + const base = + currentEnd && currentEnd.getTime() > Date.now() ? currentEnd : new Date(); + const newEnd = new Date(base); + newEnd.setDate(newEnd.getDate() + Math.max(0, Math.floor(extendDays || 0))); + const restoredStatus = + contract.statusBeforeExpiry ?? + (contract.contractKind === "GENERAL" ? "CONTRACT_ACTIVE" : "FULLY_EXECUTED"); + const daysValid = Number.isInteger(extendDays) && extendDays >= 1; + + return ( + + + + This contract's validity ended + {currentEnd ? ` on ${currentEnd.toLocaleDateString()}` : ""}. New + bookings are blocked until it is extended. + + {requestedAt ? ( + <> + + Extension requested by the customer on{" "} + {requestedAt.toLocaleDateString()}. + + {contract.latestExtensionRequestNote && ( + + Reason: {contract.latestExtensionRequestNote} + + )} + {mayExtend ? ( + + ) : ( + + You do not have permission to extend a contract. + + )} + + ) : ( + + The customer has not requested an extension. A contract can only + be extended once they ask for it from the portal. + + )} + + + setExtendOpen(false)} + title="Extend this contract?" + centered + > + + + Contract {contract.reference} gets the days below added to + its validity, returns to {restoredStatus}, and the customer + is notified. Bookings under it are possible again immediately. + + setExtendDays(typeof v === "number" ? v : Number(v) || 0)} + /> + + New validity end:{" "} + {daysValid ? newEnd.toLocaleDateString() : "—"} + +