mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 22:25:42 +00:00
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.
This commit is contained in:
@@ -17,9 +17,9 @@ export default registerAs("app", () => ({
|
|||||||
portalBaseUrl: (
|
portalBaseUrl: (
|
||||||
process.env.FREIGHT_PORTAL_URL ?? "http://localhost:5173"
|
process.env.FREIGHT_PORTAL_URL ?? "http://localhost:5173"
|
||||||
).replace(/\/+$/, ""),
|
).replace(/\/+$/, ""),
|
||||||
|
// Train weight/length are not env-configured: they come from locomotive
|
||||||
|
// configuration (see TrainSchedulingService.resolveTrainLimitConfig).
|
||||||
trainScheduling: {
|
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),
|
maxWagonsPerTrain: numberFromEnv("TRAIN_SCHEDULING_MAX_WAGONS_PER_TRAIN", 53),
|
||||||
},
|
},
|
||||||
// Consumed by @edr/api-common ExchangeModule.forRootAsync (see bookings.module.ts).
|
// Consumed by @edr/api-common ExchangeModule.forRootAsync (see bookings.module.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<void> {
|
||||||
|
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<void> {
|
||||||
|
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`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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> = {}): 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -188,6 +188,26 @@ export class ContractNotifierService {
|
|||||||
this.inApp(c, 'Contract cancelled', msg);
|
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. */
|
/** Customer cancelled their own contract — staff-side record. */
|
||||||
cancelledByCustomer(c: Contract, reason: string): void {
|
cancelledByCustomer(c: Contract, reason: string): void {
|
||||||
this.inAppStaff(
|
this.inAppStaff(
|
||||||
|
|||||||
@@ -1502,6 +1502,105 @@ export class ContractTransitionService {
|
|||||||
return updated;
|
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<Contract> {
|
||||||
|
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<Contract> {
|
||||||
|
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<Contract> {
|
async renew(contractId: string, userId?: string): Promise<Contract> {
|
||||||
const source = await this.contractsService.findById(contractId);
|
const source = await this.contractsService.findById(contractId);
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ import {
|
|||||||
import { SignContractDto } from './dto/sign-contract.dto';
|
import { SignContractDto } from './dto/sign-contract.dto';
|
||||||
import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto';
|
import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto';
|
||||||
import { RenewContractDto } from './dto/renew-contract.dto';
|
import { RenewContractDto } from './dto/renew-contract.dto';
|
||||||
|
import {
|
||||||
|
ExtendContractDto,
|
||||||
|
RequestContractExtensionDto,
|
||||||
|
} from './dto/extend-contract.dto';
|
||||||
import {
|
import {
|
||||||
CompleteConsolidatedPairDto,
|
CompleteConsolidatedPairDto,
|
||||||
CreateBookingUnderContractDto,
|
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')
|
@Post(':id/cancel')
|
||||||
@PortalCustomer()
|
@PortalCustomer()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
|
|||||||
@@ -135,7 +135,9 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
const result = await this.repository
|
const result = await this.repository
|
||||||
.createQueryBuilder()
|
.createQueryBuilder()
|
||||||
.update(Contract)
|
.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')
|
.where('deleted_at IS NULL')
|
||||||
.andWhere('status NOT IN (:...terminal)', { terminal: TERMINAL_CONTRACT_STATUSES })
|
.andWhere('status NOT IN (:...terminal)', { terminal: TERMINAL_CONTRACT_STATUSES })
|
||||||
.andWhere('contract_valid_until IS NOT NULL AND contract_valid_until < :now', {
|
.andWhere('contract_valid_until IS NOT NULL AND contract_valid_until < :now', {
|
||||||
@@ -155,7 +157,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
const result = await this.repository
|
const result = await this.repository
|
||||||
.createQueryBuilder()
|
.createQueryBuilder()
|
||||||
.update(Contract)
|
.update(Contract)
|
||||||
.set({ status: 'EXPIRED' })
|
.set({ status: 'EXPIRED', statusBeforeExpiry: () => 'status' })
|
||||||
.where('id = :id', { id })
|
.where('id = :id', { id })
|
||||||
.andWhere('deleted_at IS NULL')
|
.andWhere('deleted_at IS NULL')
|
||||||
.andWhere('status NOT IN (:...terminal)', { terminal: TERMINAL_CONTRACT_STATUSES })
|
.andWhere('status NOT IN (:...terminal)', { terminal: TERMINAL_CONTRACT_STATUSES })
|
||||||
|
|||||||
@@ -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
|
// Lets the portal disable "Cancel contract" instead of letting the customer
|
||||||
// click it and read a 400. The API re-checks on cancel regardless.
|
// click it and read a 400. The API re-checks on cancel regardless.
|
||||||
contract.activeBookingCount =
|
contract.activeBookingCount =
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -19,6 +19,10 @@ export const CONTRACT_REVIEW_NOTE_TYPES = [
|
|||||||
'SUSPENSION_LIFTED',
|
'SUSPENSION_LIFTED',
|
||||||
/** Customer cancelled their own contract; body is their reason. */
|
/** Customer cancelled their own contract; body is their reason. */
|
||||||
'CANCELLATION',
|
'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;
|
] as const;
|
||||||
export type ContractReviewNoteType =
|
export type ContractReviewNoteType =
|
||||||
(typeof CONTRACT_REVIEW_NOTE_TYPES)[number];
|
(typeof CONTRACT_REVIEW_NOTE_TYPES)[number];
|
||||||
|
|||||||
@@ -239,6 +239,23 @@ export class Contract extends BaseEntity {
|
|||||||
@Column({ name: 'status_before_suspension', type: 'varchar', length: 40, nullable: true })
|
@Column({ name: 'status_before_suspension', type: 'varchar', length: 40, nullable: true })
|
||||||
statusBeforeSuspension?: string | null;
|
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' })
|
@Column({ name: 'clearance_status', type: 'varchar', length: 40, default: 'NOT_APPLICABLE' })
|
||||||
clearanceStatus!: string;
|
clearanceStatus!: string;
|
||||||
|
|
||||||
@@ -373,6 +390,13 @@ export class Contract extends BaseEntity {
|
|||||||
*/
|
*/
|
||||||
latestSuspensionNote?: string | null;
|
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
|
* Count of this contract's non-terminal bookings, attached by
|
||||||
* ContractsService.findById. The portal disables customer cancellation while
|
* ContractsService.findById. The portal disables customer cancellation while
|
||||||
|
|||||||
@@ -871,7 +871,7 @@ export class TrainSchedulingController {
|
|||||||
@TrainSchedulingView()
|
@TrainSchedulingView()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
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(
|
async scheduleWagonListExport(
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import { Column, Entity } from 'typeorm';
|
|||||||
|
|
||||||
@Entity({ schema: 'freight', name: 'train_scheduling_global_rules' })
|
@Entity({ schema: 'freight', name: 'train_scheduling_global_rules' })
|
||||||
export class TrainSchedulingGlobalRules extends BaseEntity {
|
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({
|
@Column({
|
||||||
name: 'max_train_length_meters',
|
name: 'max_train_length_meters',
|
||||||
type: 'numeric',
|
type: 'numeric',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Wagon } from '../../wagons/entities/wagon.entity';
|
|||||||
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||||
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
|
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
|
||||||
import { TrainSchedulingGlobalRules } from '../entities/train-scheduling-global-rules.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 { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity';
|
||||||
import { TrainSchedulingService } from './train-scheduling.service';
|
import { TrainSchedulingService } from './train-scheduling.service';
|
||||||
|
|
||||||
@@ -2211,4 +2212,46 @@ describe('TrainSchedulingService', () => {
|
|||||||
expect(written.windowPhase).toBeUndefined();
|
expect(written.windowPhase).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('containerCapacityCeilingsByLine — weight limit rule capacity', () => {
|
||||||
|
const ceilings = (bookings: unknown[]) =>
|
||||||
|
(
|
||||||
|
service as never as {
|
||||||
|
containerCapacityCeilingsByLine: (b: unknown[]) => Promise<Record<string, number>>;
|
||||||
|
}
|
||||||
|
).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({});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -75,25 +75,13 @@ import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
|||||||
import { WagonTypesRepository } from '../../wagon-types/wagon-types.repository';
|
import { WagonTypesRepository } from '../../wagon-types/wagon-types.repository';
|
||||||
import { Wagon } from '../../wagons/entities/wagon.entity';
|
import { Wagon } from '../../wagons/entities/wagon.entity';
|
||||||
import { WagonEventInput, WagonHistoryService } from '../../wagon-history/wagon-history.service';
|
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). */
|
/** 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 { AdjustScheduleConsistDto } from '../dto/adjust-schedule-consist.dto';
|
||||||
import { AssignBookingsDto } from '../dto/assign-bookings.dto';
|
import { AssignBookingsDto } from '../dto/assign-bookings.dto';
|
||||||
import { CreateContainerTrainScheduleDto } from '../dto/create-container-train-schedule.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 { PreviewContainerTrainScheduleDto } from '../dto/preview-container-train-schedule.dto';
|
||||||
import { PreviewTrainScheduleDto } from '../dto/preview-train-schedule.dto';
|
import { PreviewTrainScheduleDto } from '../dto/preview-train-schedule.dto';
|
||||||
import { TrainSchedulingGlobalRules } from '../entities/train-scheduling-global-rules.entity';
|
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 { UpdateTrainSchedulingGlobalRulesDto } from '../dto/update-train-scheduling-global-rules.dto';
|
||||||
import {
|
import {
|
||||||
ImportDjiboutiOperation,
|
ImportDjiboutiOperation,
|
||||||
@@ -160,6 +149,7 @@ import {
|
|||||||
validateMixedTrainLimitsPerEdge,
|
validateMixedTrainLimitsPerEdge,
|
||||||
MAX_TEU_SLOTS_PER_WAGON,
|
MAX_TEU_SLOTS_PER_WAGON,
|
||||||
type ContainerPlacementInput,
|
type ContainerPlacementInput,
|
||||||
|
type ContainerPlacementRules,
|
||||||
type WagonPlanSlot,
|
type WagonPlanSlot,
|
||||||
} from '../utils/wagon-plan.util';
|
} from '../utils/wagon-plan.util';
|
||||||
import {
|
import {
|
||||||
@@ -190,6 +180,8 @@ import {
|
|||||||
trainSetLocomotiveLimits,
|
trainSetLocomotiveLimits,
|
||||||
wagonTypeDimensionsFromEntity,
|
wagonTypeDimensionsFromEntity,
|
||||||
LocomotiveLimits,
|
LocomotiveLimits,
|
||||||
|
MAX_FALLBACK_LENGTH,
|
||||||
|
MAX_FALLBACK_WEIGHT,
|
||||||
WagonTypeDimensions,
|
WagonTypeDimensions,
|
||||||
} from '../train-capacity.util';
|
} from '../train-capacity.util';
|
||||||
import {
|
import {
|
||||||
@@ -379,13 +371,13 @@ export interface UnassignedBookingsResponse {
|
|||||||
bookings: CompositionUnassignedBookingRow[];
|
bookings: CompositionUnassignedBookingRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_TRAIN_LIMITS: Required<TrainLimitConfig> = {
|
/**
|
||||||
maxWeightTons: 3500,
|
* Train weight/length come from locomotive configuration (the assigned set, or
|
||||||
maxLengthMeters: 760,
|
* the strongest in-service locomotive when none is assigned yet); per-box
|
||||||
maxWagonsPerTrain: Math.floor(760 / 14),
|
* container ceilings come from the rule engine's weight limit rules. Only the
|
||||||
max20ftContainerWeightTons: 30,
|
* 20ft pair-imbalance tolerance is a static default.
|
||||||
max20ftPairWeightDiffTons: 10,
|
*/
|
||||||
};
|
const DEFAULT_20FT_PAIR_WEIGHT_DIFF_TONS = 10;
|
||||||
|
|
||||||
/** Raw row shape for the booking-window queries (company- and contract-scoped). */
|
/** Raw row shape for the booking-window queries (company- and contract-scoped). */
|
||||||
interface BookingWindowRow {
|
interface BookingWindowRow {
|
||||||
@@ -447,11 +439,9 @@ export class TrainSchedulingService {
|
|||||||
// Per-wagon history ledger (global module). @Optional keeps the positional
|
// Per-wagon history ledger (global module). @Optional keeps the positional
|
||||||
// spec constructors working; production always has it.
|
// spec constructors working; production always has it.
|
||||||
@Optional() private readonly wagonHistory?: WagonHistoryService,
|
@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").
|
// 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,
|
@Optional() private readonly trainCrewAssignments?: TrainCrewAssignmentService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -813,9 +803,9 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Train length/weight and 20ft weight caps are engine-internal (wagon
|
* Train length/weight and the 20ft weight cap columns are legacy: planning
|
||||||
* planning still reads them off the row); they are no longer exposed or
|
* now takes weight/length from locomotive configuration and per-box ceilings
|
||||||
* editable through the global-rules endpoints.
|
* from weight limit rules. They are neither read nor exposed here.
|
||||||
*/
|
*/
|
||||||
private toPublicGlobalRules(row: TrainSchedulingGlobalRules | null) {
|
private toPublicGlobalRules(row: TrainSchedulingGlobalRules | null) {
|
||||||
if (!row) return row;
|
if (!row) return row;
|
||||||
@@ -3779,15 +3769,15 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The schedule detail page's wagon-list Excel export.
|
* 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 row per container (a wagon carrying two boxes yields two rows, repeating
|
* one line per container (a two-box wagon repeats its wagon number under one
|
||||||
* the wagon number) so each container's own VGM is present and totals footable.
|
* "No."), a blank line between customers, and the wagon count / company /
|
||||||
* Bulk wagons, having no containers, yield a single row carrying the bulk
|
* transitor merged down each group. See buildWagonListWorkbook.
|
||||||
* description and the allocated tonnage as the VGM figure.
|
|
||||||
*
|
*
|
||||||
* Only wagon slots that actually carry an allocation are listed — empty slots
|
* 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(
|
async scheduleWagonListWorkbook(
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
@@ -3796,56 +3786,37 @@ export class TrainSchedulingService {
|
|||||||
if (!schedule) {
|
if (!schedule) {
|
||||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
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
|
// Row grain is the container item; the LEFT JOIN keeps a bulk (or any
|
||||||
// container-less) allocation as one row. `booking_container_units` is joined
|
// container-less) allocation as one row. The transitor is the customs
|
||||||
// on BOTH container number and its booking_container line — container
|
// clearing agent the customer named on the booking.
|
||||||
// numbers repeat across bookings, so number alone would multiply rows.
|
const lines: WagonListLine[] = await this.dataSource.query(
|
||||||
const rows: ScheduleWagonListRow[] = await this.dataSource.query(
|
|
||||||
`SELECT tsw.sequence_no AS "sequenceNo",
|
`SELECT tsw.sequence_no AS "sequenceNo",
|
||||||
w.wagon_number AS "wagonNumber",
|
w.wagon_number AS "wagonNumber",
|
||||||
COALESCE(wt.name, wt.code) AS "wagonType",
|
|
||||||
ci.container_number AS "containerNumber",
|
ci.container_number AS "containerNumber",
|
||||||
cit.size_ft AS "containerSizeFt",
|
cit.size_ft AS "containerSizeFt",
|
||||||
a.load_type AS "loadType",
|
a.load_type AS "loadType",
|
||||||
a.status AS "status",
|
|
||||||
bl.cargo_description AS "bulkCargoDescription",
|
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(by_.label, so.label) AS "originLabel",
|
||||||
COALESCE(ay.label, sd.label) AS "destinationLabel",
|
COALESCE(ay.label, sd.label) AS "destinationLabel",
|
||||||
b.reference AS "bookingReference",
|
|
||||||
COALESCE(
|
COALESCE(
|
||||||
slc.name,
|
slc.name,
|
||||||
CASE WHEN b.is_government THEN NULLIF(TRIM(b.government_institution), '') END,
|
CASE WHEN b.is_government THEN NULLIF(TRIM(b.government_institution), '') END,
|
||||||
c.name
|
c.name
|
||||||
) AS "customerName"
|
) AS "customerName",
|
||||||
|
NULLIF(TRIM(b.customs_clearing_agent), '') AS "transitor"
|
||||||
FROM freight.train_schedules s
|
FROM freight.train_schedules s
|
||||||
JOIN freight.train_set_wagons tsw
|
JOIN freight.train_set_wagons tsw
|
||||||
ON tsw.train_set_id = s.train_set_id AND tsw.deleted_at IS NULL
|
ON tsw.train_set_id = s.train_set_id AND tsw.deleted_at IS NULL
|
||||||
JOIN freight.wagon_booking_allocations a
|
JOIN freight.wagon_booking_allocations a
|
||||||
ON a.train_set_wagon_id = tsw.id AND a.deleted_at IS NULL
|
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.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.bookings b ON b.id = a.booking_id
|
||||||
LEFT JOIN freight.companies c ON c.id = b.company_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.shipping_line_companies slc ON slc.id = b.shipping_line_company_id
|
||||||
LEFT JOIN freight.wagon_allocation_container_items ci
|
LEFT JOIN freight.wagon_allocation_container_items ci
|
||||||
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
|
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.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
|
LEFT JOIN freight.wagon_allocation_bulk_loads bl
|
||||||
ON bl.wagon_booking_allocation_id = a.id AND bl.deleted_at IS NULL
|
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
|
LEFT JOIN freight.yards so ON so.id = s.origin_station_id
|
||||||
@@ -3857,47 +3828,13 @@ export class TrainSchedulingService {
|
|||||||
[scheduleId],
|
[scheduleId],
|
||||||
);
|
);
|
||||||
|
|
||||||
// "number" is the printed line number of the sheet, not the wagon sequence —
|
const { groups, totalWagons } = groupWagonListLines(lines);
|
||||||
// a two-container wagon occupies two lines, and the reader counts lines.
|
const buffer = await buildWagonListWorkbook({
|
||||||
const sheetRows = rows.map((row, index) => ({
|
trainLabel: schedule.trainNumber ?? schedule.reference ?? schedule.id,
|
||||||
number: index + 1,
|
groups,
|
||||||
wagonNumber: row.wagonNumber ?? '—',
|
totalWagons,
|
||||||
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 reference = schedule.reference ?? schedule.trainNumber ?? schedule.id;
|
||||||
return {
|
return {
|
||||||
filename: `wagon-list-${this.safeDocumentName(reference)}.xlsx`,
|
filename: `wagon-list-${this.safeDocumentName(reference)}.xlsx`,
|
||||||
buffer,
|
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,
|
// With forceAssign, capacity-shaped rules (train limits, total weight,
|
||||||
// locomotive capability) become warnings — staff owns the override. Physical
|
// locomotive capability) become warnings — staff owns the override. Physical
|
||||||
// impossibilities (no wagon of the required type at the yard, wrong route,
|
// impossibilities (no wagon of the required type at the yard, wrong route,
|
||||||
@@ -6754,6 +6686,11 @@ export class TrainSchedulingService {
|
|||||||
);
|
);
|
||||||
if (requireContainerPlacements && resolvedMode !== 'BULK') {
|
if (requireContainerPlacements && resolvedMode !== 'BULK') {
|
||||||
const containerBookings = fittingBookings.filter((b) => b.freightType === 'CONTAINER');
|
const containerBookings = fittingBookings.filter((b) => b.freightType === 'CONTAINER');
|
||||||
|
const placementRules: ContainerPlacementRules = {
|
||||||
|
maxContainerWeightTonsByLineId:
|
||||||
|
await this.containerCapacityCeilingsByLine(containerBookings),
|
||||||
|
max20ftPairWeightDiffTons: trainLimits.max20ftPairWeightDiffTons,
|
||||||
|
};
|
||||||
violations.push(
|
violations.push(
|
||||||
...validateContainerPlacements(
|
...validateContainerPlacements(
|
||||||
containerBookings,
|
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<Record<string, number>> {
|
||||||
|
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<string, number> = {};
|
||||||
|
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<LocomotiveLimits | null> {
|
||||||
|
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(
|
private async resolveTrainLimitConfig(
|
||||||
dto?: {
|
dto?: {
|
||||||
maxTrainWeightTons?: number;
|
maxTrainWeightTons?: number;
|
||||||
@@ -6911,24 +6912,14 @@ export class TrainSchedulingService {
|
|||||||
builtWagonCount?: number,
|
builtWagonCount?: number,
|
||||||
): Promise<Required<TrainLimitConfig>> {
|
): Promise<Required<TrainLimitConfig>> {
|
||||||
const row = await this.loadGlobalRulesRow();
|
const row = await this.loadGlobalRulesRow();
|
||||||
const configured = this.configService?.get<{
|
const configured = this.configService?.get<{ maxWagonsPerTrain?: number }>(
|
||||||
maxTrainWeightTons?: number;
|
'app.trainScheduling',
|
||||||
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 wagonTypes = await this.loadSchedulingWagonTypeDimensions();
|
const wagonTypes = await this.loadSchedulingWagonTypeDimensions();
|
||||||
|
const max20ftPairWeightDiffTons = this.positiveNumber(
|
||||||
|
undefined,
|
||||||
|
Number(row?.max20ftPairWeightDiffTons) || DEFAULT_20FT_PAIR_WEIGHT_DIFF_TONS,
|
||||||
|
);
|
||||||
|
|
||||||
if (locomotive) {
|
if (locomotive) {
|
||||||
// With a locomotive assigned its own limits are the single source of
|
// With a locomotive assigned its own limits are the single source of
|
||||||
@@ -6963,52 +6954,40 @@ export class TrainSchedulingService {
|
|||||||
: builtWagonCount && builtWagonCount > 0
|
: builtWagonCount && builtWagonCount > 0
|
||||||
? builtWagonCount
|
? builtWagonCount
|
||||||
: derived.maxWagonSlots,
|
: derived.maxWagonSlots,
|
||||||
max20ftContainerWeightTons: this.positiveNumber(
|
max20ftPairWeightDiffTons,
|
||||||
undefined,
|
|
||||||
Number(row?.max20ftContainerWeightTons) ||
|
|
||||||
DEFAULT_TRAIN_LIMITS.max20ftContainerWeightTons,
|
|
||||||
),
|
|
||||||
max20ftPairWeightDiffTons: this.positiveNumber(
|
|
||||||
undefined,
|
|
||||||
Number(row?.max20ftPairWeightDiffTons) ||
|
|
||||||
DEFAULT_TRAIN_LIMITS.max20ftPairWeightDiffTons,
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxWeightTons = this.positiveNumber(
|
// No locomotive on the set yet: plan against the strongest in-service
|
||||||
dto?.maxTrainWeightTons,
|
// locomotive's configuration. An explicit dto override still narrows it.
|
||||||
ruleWeightCap ?? DEFAULT_TRAIN_LIMITS.maxWeightTons,
|
const fleet = await this.strongestFleetLocomotiveLimits();
|
||||||
);
|
if (!fleet) {
|
||||||
const maxLengthMeters = this.positiveNumber(
|
this.logger.warn(
|
||||||
dto?.maxTrainLengthMeters,
|
'No in-service locomotive is configured — train weight/length limits fall back to ' +
|
||||||
ruleLengthCap ?? DEFAULT_TRAIN_LIMITS.maxLengthMeters,
|
`${MAX_FALLBACK_WEIGHT}T / ${MAX_FALLBACK_LENGTH}m until a locomotive is added`,
|
||||||
);
|
);
|
||||||
const derivedWithoutLoco = deriveTrainCapacityFromLocomotive(
|
}
|
||||||
{ maxPullWeightTons: maxWeightTons, maxTrainLengthMeters: maxLengthMeters },
|
const derived = deriveTrainCapacityFromLocomotive(
|
||||||
|
fleet ?? { maxPullWeightTons: MAX_FALLBACK_WEIGHT, maxTrainLengthMeters: MAX_FALLBACK_LENGTH },
|
||||||
wagonTypes,
|
wagonTypes,
|
||||||
|
{
|
||||||
|
maxTrainWeightTons: dto?.maxTrainWeightTons,
|
||||||
|
maxTrainLengthMeters: dto?.maxTrainLengthMeters,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
maxWeightTons,
|
maxWeightTons: derived.maxWeightTons,
|
||||||
maxLengthMeters,
|
maxLengthMeters: derived.maxLengthMeters,
|
||||||
maxWagonsPerTrain: Math.floor(
|
maxWagonsPerTrain: Math.floor(
|
||||||
this.positiveNumber(
|
this.positiveNumber(
|
||||||
dto?.maxWagonsPerTrain,
|
dto?.maxWagonsPerTrain,
|
||||||
row?.maxWagonsPerTrain != null
|
row?.maxWagonsPerTrain != null
|
||||||
? Number(row.maxWagonsPerTrain)
|
? Number(row.maxWagonsPerTrain)
|
||||||
: configured?.maxWagonsPerTrain ?? derivedWithoutLoco.maxWagonSlots,
|
: configured?.maxWagonsPerTrain ?? derived.maxWagonSlots,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
max20ftContainerWeightTons: this.positiveNumber(
|
max20ftPairWeightDiffTons,
|
||||||
undefined,
|
|
||||||
Number(row?.max20ftContainerWeightTons) || DEFAULT_TRAIN_LIMITS.max20ftContainerWeightTons,
|
|
||||||
),
|
|
||||||
max20ftPairWeightDiffTons: this.positiveNumber(
|
|
||||||
undefined,
|
|
||||||
Number(row?.max20ftPairWeightDiffTons) ||
|
|
||||||
DEFAULT_TRAIN_LIMITS.max20ftPairWeightDiffTons,
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { BillingModule } from '../billing/billing.module';
|
|||||||
import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module';
|
import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module';
|
||||||
import { BookingsModule } from '../bookings/bookings.module';
|
import { BookingsModule } from '../bookings/bookings.module';
|
||||||
import { Container } from '../container-management/entities/container.entity';
|
import { Container } from '../container-management/entities/container.entity';
|
||||||
import { ExportsModule } from '../exports/exports.module';
|
|
||||||
import { LocomotivesModule } from '../locomotives/locomotives.module';
|
import { LocomotivesModule } from '../locomotives/locomotives.module';
|
||||||
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
||||||
import { FacilityHandlingService } from './facility-handling.service';
|
import { FacilityHandlingService } from './facility-handling.service';
|
||||||
@@ -69,7 +68,6 @@ import { ContractsModule } from '../contracts/contracts.module';
|
|||||||
UserTradeAccessModule,
|
UserTradeAccessModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
NotificationInboxModule,
|
NotificationInboxModule,
|
||||||
ExportsModule,
|
|
||||||
LocomotivesModule,
|
LocomotivesModule,
|
||||||
WagonTypesModule,
|
WagonTypesModule,
|
||||||
TrainSetsModule,
|
TrainSetsModule,
|
||||||
|
|||||||
@@ -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>): 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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string>; wagons: Set<string> }
|
||||||
|
>();
|
||||||
|
const ordinalByGroupWagon = new Map<string, number>();
|
||||||
|
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<ExcelJS.Alignment> = { 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<Buffer> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
@@ -171,7 +171,7 @@ describe('wagon-plan.util', () => {
|
|||||||
expect(validateContainerPlacements([booking], plan, placements)).toEqual([]);
|
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 booking = makeContainerBooking('c20', [{ quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 35 }]);
|
||||||
const units = expandBookingContainerUnits([booking]);
|
const units = expandBookingContainerUnits([booking]);
|
||||||
const placements = units.map((unit, index) => ({
|
const placements = units.map((unit, index) => ({
|
||||||
@@ -182,11 +182,29 @@ describe('wagon-plan.util', () => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const violations = validate20ftContainerRules(units, placements, {
|
const violations = validate20ftContainerRules(units, placements, {
|
||||||
max20ftContainerWeightTons: 30,
|
maxContainerWeightTonsByLineId: { [units[0]!.bookingContainerId]: 30 },
|
||||||
max20ftPairWeightDiffTons: 10,
|
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', () => {
|
it('rejects 20ft pair when weight difference exceeds limit', () => {
|
||||||
@@ -204,7 +222,6 @@ describe('wagon-plan.util', () => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const violations = validate20ftContainerRules(units, placements, {
|
const violations = validate20ftContainerRules(units, placements, {
|
||||||
max20ftContainerWeightTons: 30,
|
|
||||||
max20ftPairWeightDiffTons: 10,
|
max20ftPairWeightDiffTons: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,12 +20,17 @@ export type TrainLimitConfig = {
|
|||||||
maxWeightTons?: number;
|
maxWeightTons?: number;
|
||||||
maxLengthMeters?: number;
|
maxLengthMeters?: number;
|
||||||
maxWagonsPerTrain?: number;
|
maxWagonsPerTrain?: number;
|
||||||
max20ftContainerWeightTons?: number;
|
|
||||||
max20ftPairWeightDiffTons?: number;
|
max20ftPairWeightDiffTons?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ContainerPlacementRules = {
|
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<string, number>;
|
||||||
max20ftPairWeightDiffTons?: number;
|
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(
|
export function validate20ftContainerRules(
|
||||||
units: ContainerUnitRow[],
|
units: ContainerUnitRow[],
|
||||||
placements: ContainerPlacementInput[],
|
placements: ContainerPlacementInput[],
|
||||||
rules?: ContainerPlacementRules,
|
rules?: ContainerPlacementRules,
|
||||||
): string[] {
|
): string[] {
|
||||||
const violations: string[] = [];
|
const violations: string[] = [];
|
||||||
const maxEach = rules?.max20ftContainerWeightTons;
|
const capacityByLine = rules?.maxContainerWeightTonsByLineId;
|
||||||
const maxDiff = rules?.max20ftPairWeightDiffTons;
|
const maxDiff = rules?.max20ftPairWeightDiffTons;
|
||||||
if (maxEach == null && maxDiff == null) return violations;
|
if (capacityByLine == null && maxDiff == null) return violations;
|
||||||
|
|
||||||
const placementByUnit = new Map(
|
const placementByUnit = new Map(
|
||||||
placements.map((p) => [`${p.bookingContainerId}:${p.unitIndex}`, p]),
|
placements.map((p) => [`${p.bookingContainerId}:${p.unitIndex}`, p]),
|
||||||
@@ -837,15 +848,16 @@ export function validate20ftContainerRules(
|
|||||||
const weightsBySlot = new Map<number, number[]>();
|
const weightsBySlot = new Map<number, number[]>();
|
||||||
|
|
||||||
for (const unit of units) {
|
for (const unit of units) {
|
||||||
const sizeFt = unit.sizeFt ?? (unit.containerTypeCode.includes('40') ? 40 : 20);
|
const maxEach = capacityByLine?.[unit.bookingContainerId];
|
||||||
if (sizeFt >= 40) continue;
|
|
||||||
|
|
||||||
if (maxEach != null && unit.grossWeightTons > maxEach) {
|
if (maxEach != null && unit.grossWeightTons > maxEach) {
|
||||||
violations.push(
|
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}`);
|
const placement = placementByUnit.get(`${unit.bookingContainerId}:${unit.unitIndex}`);
|
||||||
if (!placement?.sequenceNo) continue;
|
if (!placement?.sequenceNo) continue;
|
||||||
|
|
||||||
|
|||||||
@@ -472,6 +472,13 @@ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
"edr_freight_app:contracts:cancel",
|
"edr_freight_app:contracts:cancel",
|
||||||
"Cancel a contract (terminal)",
|
"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
|
// 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",
|
clearanceDutyAdvise: "edr_freight_app:contracts:clearance_duty_advise",
|
||||||
suspend: "edr_freight_app:contracts:suspend",
|
suspend: "edr_freight_app:contracts:suspend",
|
||||||
cancel: "edr_freight_app:contracts:cancel",
|
cancel: "edr_freight_app:contracts:cancel",
|
||||||
|
extend: "edr_freight_app:contracts:extend",
|
||||||
editDocument: "edr_freight_app:contracts:edit_document",
|
editDocument: "edr_freight_app:contracts:edit_document",
|
||||||
finalInvoiceRaise: "edr_freight_app:contracts:final_invoice_raise",
|
finalInvoiceRaise: "edr_freight_app:contracts:final_invoice_raise",
|
||||||
finalInvoiceConfirm: "edr_freight_app:contracts:final_invoice_confirm",
|
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
|
// Terminal kill switch, granted alongside suspend on the same desk that
|
||||||
// already rejects contracts and cancels bookings.
|
// already rejects contracts and cancels bookings.
|
||||||
FREIGHT_PERMS.contracts.cancel,
|
FREIGHT_PERMS.contracts.cancel,
|
||||||
|
// Validity extension of an expired contract, on customer request.
|
||||||
|
FREIGHT_PERMS.contracts.extend,
|
||||||
FREIGHT_PERMS.contracts.editDocument,
|
FREIGHT_PERMS.contracts.editDocument,
|
||||||
...BOOKING_DESK_NOTIFICATION_KEYS,
|
...BOOKING_DESK_NOTIFICATION_KEYS,
|
||||||
// Marketing follows up with the customer when a reviewer sends profile
|
// Marketing follows up with the customer when a reviewer sends profile
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
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 {
|
import {
|
||||||
Ban,
|
Ban,
|
||||||
|
CalendarClock,
|
||||||
|
CalendarPlus,
|
||||||
Check,
|
Check,
|
||||||
Eye,
|
Eye,
|
||||||
// FilePen, // ponytail: back with the "Edit contract articles" button
|
// FilePen, // ponytail: back with the "Edit contract articles" button
|
||||||
@@ -84,6 +94,9 @@ export function ContractActionsToolbar({
|
|||||||
const maySuspend = hasPermission(user, FREIGHT_PERMS.contracts.suspend);
|
const maySuspend = hasPermission(user, FREIGHT_PERMS.contracts.suspend);
|
||||||
// Cancel is its own key — it is terminal, so it is NOT implied by suspend.
|
// Cancel is its own key — it is terminal, so it is NOT implied by suspend.
|
||||||
const mayCancel = hasPermission(user, FREIGHT_PERMS.contracts.cancel);
|
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 [editorOpen, setEditorOpen] = useState(false);
|
||||||
const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept");
|
const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept");
|
||||||
@@ -98,6 +111,9 @@ export function ContractActionsToolbar({
|
|||||||
const [resumeNote, setResumeNote] = useState("");
|
const [resumeNote, setResumeNote] = useState("");
|
||||||
const [cancelOpen, setCancelOpen] = useState(false);
|
const [cancelOpen, setCancelOpen] = useState(false);
|
||||||
const [cancelReason, setCancelReason] = useState("");
|
const [cancelReason, setCancelReason] = useState("");
|
||||||
|
const [extendOpen, setExtendOpen] = useState(false);
|
||||||
|
const [extendDays, setExtendDays] = useState<number>(30);
|
||||||
|
const [extendNote, setExtendNote] = useState("");
|
||||||
|
|
||||||
// Shared by the suspended branch and the normal toolbar — both can cancel.
|
// Shared by the suspended branch and the normal toolbar — both can cancel.
|
||||||
const cancelModal = (
|
const cancelModal = (
|
||||||
@@ -183,7 +199,132 @@ export function ContractActionsToolbar({
|
|||||||
[validitySetting],
|
[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 (
|
||||||
|
<SectionCard icon={CalendarClock} title="Contract expired">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
This contract's validity ended
|
||||||
|
{currentEnd ? ` on ${currentEnd.toLocaleDateString()}` : ""}. New
|
||||||
|
bookings are blocked until it is extended.
|
||||||
|
</Text>
|
||||||
|
{requestedAt ? (
|
||||||
|
<>
|
||||||
|
<Text size="sm">
|
||||||
|
<b>Extension requested</b> by the customer on{" "}
|
||||||
|
{requestedAt.toLocaleDateString()}.
|
||||||
|
</Text>
|
||||||
|
{contract.latestExtensionRequestNote && (
|
||||||
|
<Text size="sm">
|
||||||
|
<b>Reason:</b> {contract.latestExtensionRequestNote}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{mayExtend ? (
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<CalendarPlus size={16} />}
|
||||||
|
onClick={() => setExtendOpen(true)}
|
||||||
|
>
|
||||||
|
Extend contract
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
You do not have permission to extend a contract.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
The customer has not requested an extension. A contract can only
|
||||||
|
be extended once they ask for it from the portal.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={extendOpen}
|
||||||
|
onClose={() => setExtendOpen(false)}
|
||||||
|
title="Extend this contract?"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm">
|
||||||
|
Contract <b>{contract.reference}</b> gets the days below added to
|
||||||
|
its validity, returns to <b>{restoredStatus}</b>, and the customer
|
||||||
|
is notified. Bookings under it are possible again immediately.
|
||||||
|
</Text>
|
||||||
|
<NumberInput
|
||||||
|
label="Days to add"
|
||||||
|
min={1}
|
||||||
|
max={3650}
|
||||||
|
step={1}
|
||||||
|
allowDecimal={false}
|
||||||
|
value={extendDays}
|
||||||
|
onChange={(v) => setExtendDays(typeof v === "number" ? v : Number(v) || 0)}
|
||||||
|
/>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
New validity end:{" "}
|
||||||
|
<b>{daysValid ? newEnd.toLocaleDateString() : "—"}</b>
|
||||||
|
</Text>
|
||||||
|
<Textarea
|
||||||
|
label="Note (optional)"
|
||||||
|
placeholder="Shown to the customer with the extension…"
|
||||||
|
autosize
|
||||||
|
minRows={2}
|
||||||
|
value={extendNote}
|
||||||
|
onChange={(e) => setExtendNote(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button variant="default" onClick={() => setExtendOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
disabled={!daysValid}
|
||||||
|
loading={mutations.extend.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
mutations.extend.mutate(
|
||||||
|
{ days: extendDays, note: extendNote.trim() || undefined },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setExtendOpen(false);
|
||||||
|
setExtendNote("");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Extend contract
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (["REJECTED", "CANCELLED", "CONTRACT_CLOSED"].includes(status)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -285,6 +285,7 @@ export const URL_CONSTANTS = {
|
|||||||
STAFF_CANCEL: (id: string) => `/contracts/${id}/staff/cancel`,
|
STAFF_CANCEL: (id: string) => `/contracts/${id}/staff/cancel`,
|
||||||
SUSPEND: (id: string) => `/contracts/${id}/suspend`,
|
SUSPEND: (id: string) => `/contracts/${id}/suspend`,
|
||||||
RESUME: (id: string) => `/contracts/${id}/resume`,
|
RESUME: (id: string) => `/contracts/${id}/resume`,
|
||||||
|
EXTEND: (id: string) => `/contracts/${id}/extend`,
|
||||||
APPROVE_STEP: (id: string, stepId: string) =>
|
APPROVE_STEP: (id: string, stepId: string) =>
|
||||||
`/contracts/${id}/approval-steps/${stepId}/approve`,
|
`/contracts/${id}/approval-steps/${stepId}/approve`,
|
||||||
REJECT_STEP: (id: string, stepId: string) =>
|
REJECT_STEP: (id: string, stepId: string) =>
|
||||||
|
|||||||
@@ -172,6 +172,14 @@ export function useContractMutations(contractId: string) {
|
|||||||
onError: (error) => toast.error(extractErrorMessage(error, "Failed to lift suspension")),
|
onError: (error) => toast.error(extractErrorMessage(error, "Failed to lift suspension")),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const extend = useMutation({
|
||||||
|
mutationFn: (payload: Freight.ExtendContractDto) =>
|
||||||
|
contractsService.extend(contractId, payload),
|
||||||
|
onSuccess: (data) =>
|
||||||
|
onSuccess(data, `Contract extended — it is back to ${data.status}`),
|
||||||
|
onError: (error) => toast.error(extractErrorMessage(error, "Failed to extend contract")),
|
||||||
|
});
|
||||||
|
|
||||||
const approveStep = useMutation({
|
const approveStep = useMutation({
|
||||||
// The server derives the required role from the step itself, so the client
|
// The server derives the required role from the step itself, so the client
|
||||||
// does not send one.
|
// does not send one.
|
||||||
@@ -280,6 +288,7 @@ export function useContractMutations(contractId: string) {
|
|||||||
cancelByStaff,
|
cancelByStaff,
|
||||||
suspend,
|
suspend,
|
||||||
resume,
|
resume,
|
||||||
|
extend,
|
||||||
approveStep,
|
approveStep,
|
||||||
rejectStep,
|
rejectStep,
|
||||||
generateContract,
|
generateContract,
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ export const FREIGHT_PERMS = {
|
|||||||
clearanceDjActions: "edr_freight_app:contracts:clearance_dj_actions",
|
clearanceDjActions: "edr_freight_app:contracts:clearance_dj_actions",
|
||||||
suspend: "edr_freight_app:contracts:suspend",
|
suspend: "edr_freight_app:contracts:suspend",
|
||||||
cancel: "edr_freight_app:contracts:cancel",
|
cancel: "edr_freight_app:contracts:cancel",
|
||||||
|
extend: "edr_freight_app:contracts:extend",
|
||||||
editDocument: "edr_freight_app:contracts:edit_document",
|
editDocument: "edr_freight_app:contracts:edit_document",
|
||||||
finalInvoiceRaise: "edr_freight_app:contracts:final_invoice_raise",
|
finalInvoiceRaise: "edr_freight_app:contracts:final_invoice_raise",
|
||||||
finalInvoiceConfirm: "edr_freight_app:contracts:final_invoice_confirm",
|
finalInvoiceConfirm: "edr_freight_app:contracts:final_invoice_confirm",
|
||||||
|
|||||||
@@ -303,6 +303,14 @@ export const contractsService = {
|
|||||||
resume: (id: string, note?: string) =>
|
resume: (id: string, note?: string) =>
|
||||||
postContract<Freight.IContract>(C.RESUME(id), { note }),
|
postContract<Freight.IContract>(C.RESUME(id), { note }),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add validity days to an EXPIRED contract the customer asked to extend; it
|
||||||
|
* returns to the status it held before it lapsed. The API refuses it while
|
||||||
|
* no customer request is pending.
|
||||||
|
*/
|
||||||
|
extend: (id: string, payload: Freight.ExtendContractDto) =>
|
||||||
|
postContract<Freight.IContract>(C.EXTEND(id), payload),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Approve the next pending step. The server resolves the step's required role
|
* Approve the next pending step. The server resolves the step's required role
|
||||||
* and authorizes against it — the client never declares its own role.
|
* and authorizes against it — the client never declares its own role.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Group,
|
Group,
|
||||||
Modal,
|
Modal,
|
||||||
Text,
|
Text,
|
||||||
|
Textarea,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
type ButtonProps,
|
type ButtonProps,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
@@ -15,6 +16,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
import { contractsService } from "@/services/contracts.service";
|
||||||
import { bookingDocNoun } from "@/pages/bookings/clearance/bookingNextAction";
|
import { bookingDocNoun } from "@/pages/bookings/clearance/bookingNextAction";
|
||||||
import { deriveContractCustomerAction } from "./deriveContractCustomerAction";
|
import { deriveContractCustomerAction } from "./deriveContractCustomerAction";
|
||||||
|
|
||||||
@@ -61,6 +63,18 @@ export function ContractCustomerAction({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action.type === "requestExtension") {
|
||||||
|
return (
|
||||||
|
<RequestExtensionButton
|
||||||
|
contract={action.contract}
|
||||||
|
label={action.label}
|
||||||
|
icon={action.icon}
|
||||||
|
size={size}
|
||||||
|
listStyle={listStyle}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const Icon = action.icon;
|
const Icon = action.icon;
|
||||||
const variant = action.primary ? "filled" : "light";
|
const variant = action.primary ? "filled" : "light";
|
||||||
|
|
||||||
@@ -226,6 +240,156 @@ export function InitiateBookingButton({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask EDR to extend an EXPIRED contract. Nothing changes on the contract until
|
||||||
|
* staff add validity days on their side — this only records the request (and
|
||||||
|
* an optional reason) and notifies the contract desk.
|
||||||
|
*/
|
||||||
|
export function RequestExtensionButton({
|
||||||
|
contract,
|
||||||
|
label = "Request extension",
|
||||||
|
icon: Icon,
|
||||||
|
size = "xs",
|
||||||
|
listStyle = false,
|
||||||
|
fullWidth = false,
|
||||||
|
}: {
|
||||||
|
contract: Freight.IContract;
|
||||||
|
label?: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
size?: ButtonProps["size"];
|
||||||
|
listStyle?: boolean;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
contractsService.requestExtension(contract.id, note.trim() || undefined),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: api.contracts.get.queryKey({ id: contract.id }),
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: api.contracts.list.queryKey() });
|
||||||
|
queryClient.invalidateQueries({ queryKey: api.contracts.listMy.queryKey() });
|
||||||
|
toast.success(
|
||||||
|
"Extension requested — EDR will review it and extend the contract.",
|
||||||
|
);
|
||||||
|
setConfirmOpen(false);
|
||||||
|
setNote("");
|
||||||
|
},
|
||||||
|
onError: (e: Error) => {
|
||||||
|
const data = (
|
||||||
|
e as { response?: { data?: { message?: string | string[] } } }
|
||||||
|
).response?.data;
|
||||||
|
const message = Array.isArray(data?.message)
|
||||||
|
? data.message.join(", ")
|
||||||
|
: data?.message;
|
||||||
|
toast.error(message || e.message || "Could not request the extension");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const validUntil = contract.contractValidUntil
|
||||||
|
? new Date(contract.contractValidUntil).toLocaleDateString()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal
|
||||||
|
opened={confirmOpen}
|
||||||
|
onClose={() => {
|
||||||
|
if (!mutation.isPending) setConfirmOpen(false);
|
||||||
|
}}
|
||||||
|
centered
|
||||||
|
radius="lg"
|
||||||
|
size="md"
|
||||||
|
closeOnClickOutside={!mutation.isPending}
|
||||||
|
closeOnEscape={!mutation.isPending}
|
||||||
|
withCloseButton={!mutation.isPending}
|
||||||
|
title={
|
||||||
|
<Group gap={10} wrap="nowrap">
|
||||||
|
<ThemeIcon variant="light" color="edr-green" radius="md" size={34}>
|
||||||
|
<Icon size={18} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fw={700}>Request a contract extension?</Text>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Contract{" "}
|
||||||
|
<Text span fw={700} c="#10202F">
|
||||||
|
{contract.reference}
|
||||||
|
</Text>{" "}
|
||||||
|
{validUntil ? `expired on ${validUntil}` : "has expired"}. EDR will
|
||||||
|
review your request and add validity days; the contract becomes active
|
||||||
|
again as soon as they do, and you will be notified.
|
||||||
|
</Text>
|
||||||
|
<Textarea
|
||||||
|
mt="md"
|
||||||
|
label="Reason (optional)"
|
||||||
|
placeholder="Why do you need this contract extended?"
|
||||||
|
autosize
|
||||||
|
minRows={2}
|
||||||
|
value={note}
|
||||||
|
onChange={(e) => setNote(e.currentTarget.value)}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end" gap="sm" mt="lg">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
radius="md"
|
||||||
|
onClick={() => setConfirmOpen(false)}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Icon size={16} />}
|
||||||
|
loading={mutation.isPending}
|
||||||
|
onClick={() => mutation.mutate()}
|
||||||
|
>
|
||||||
|
Send request
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Modal>
|
||||||
|
<Button
|
||||||
|
size={size}
|
||||||
|
radius="md"
|
||||||
|
h={listStyle ? 34 : undefined}
|
||||||
|
variant="filled"
|
||||||
|
color="edr-green"
|
||||||
|
fullWidth={fullWidth}
|
||||||
|
leftSection={<Icon size={15} />}
|
||||||
|
loading={mutation.isPending}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setConfirmOpen(true);
|
||||||
|
}}
|
||||||
|
styles={
|
||||||
|
listStyle
|
||||||
|
? {
|
||||||
|
root: {
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 13,
|
||||||
|
paddingInline: 14,
|
||||||
|
whiteSpace: "nowrap" as const,
|
||||||
|
boxShadow: "0 1px 2px rgba(14,163,113,0.25)",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
fw={listStyle ? undefined : 700}
|
||||||
|
fz={listStyle ? undefined : 13}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Action column cell: doc button + primary customer action. */
|
/** Action column cell: doc button + primary customer action. */
|
||||||
export function ContractCustomerActionCell({
|
export function ContractCustomerActionCell({
|
||||||
contract,
|
contract,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
|
CalendarPlus,
|
||||||
Eye,
|
Eye,
|
||||||
FileSignature,
|
FileSignature,
|
||||||
PackagePlus,
|
PackagePlus,
|
||||||
@@ -32,6 +33,14 @@ export type ContractCustomerAction =
|
|||||||
label: string;
|
label: string;
|
||||||
primary: boolean;
|
primary: boolean;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/** Ask EDR to extend an EXPIRED contract — mutation with a reason, not navigation. */
|
||||||
|
type: "requestExtension";
|
||||||
|
contract: Freight.IContract;
|
||||||
|
label: string;
|
||||||
|
primary: boolean;
|
||||||
|
icon: LucideIcon;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Single best customer action for a contract row (list / home). */
|
/** Single best customer action for a contract row (list / home). */
|
||||||
@@ -73,6 +82,27 @@ export function deriveContractCustomerAction(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lapsed: the customer's one move is to ask EDR for more time. Once asked,
|
||||||
|
// the row just views until staff extend it (the API refuses a second ask).
|
||||||
|
if (contract.status === "EXPIRED") {
|
||||||
|
if (!contract.extensionRequestedAt) {
|
||||||
|
return {
|
||||||
|
type: "requestExtension",
|
||||||
|
contract,
|
||||||
|
label: "Request extension",
|
||||||
|
primary: true,
|
||||||
|
icon: CalendarPlus,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "navigate",
|
||||||
|
label: "View",
|
||||||
|
to: `/contracts/${id}`,
|
||||||
|
primary: false,
|
||||||
|
icon: Eye,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Paying happens from the booking row/detail — contract rows never show
|
// Paying happens from the booking row/detail — contract rows never show
|
||||||
// "Pay now" (payable bookings fall through to the next action here).
|
// "Pay now" (payable bookings fall through to the next action here).
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ export const URL_CONSTANTS = {
|
|||||||
CONTRACT_SEND_SIGNING_OTP: (id: string) =>
|
CONTRACT_SEND_SIGNING_OTP: (id: string) =>
|
||||||
`/api/contracts/${id}/contract/send-signing-otp`,
|
`/api/contracts/${id}/contract/send-signing-otp`,
|
||||||
RENEW: (id: string) => `/api/contracts/${id}/renew`,
|
RENEW: (id: string) => `/api/contracts/${id}/renew`,
|
||||||
|
EXTENSION_REQUEST: (id: string) => `/api/contracts/${id}/extension-request`,
|
||||||
CANCEL: (id: string) => `/api/contracts/${id}/cancel`,
|
CANCEL: (id: string) => `/api/contracts/${id}/cancel`,
|
||||||
CLEARANCE: (id: string) => `/api/contracts/${id}/clearance`,
|
CLEARANCE: (id: string) => `/api/contracts/${id}/clearance`,
|
||||||
CLEARANCE_DOCUMENTS: (id: string) =>
|
CLEARANCE_DOCUMENTS: (id: string) =>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
|
CalendarPlus,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Download,
|
Download,
|
||||||
@@ -62,7 +63,10 @@ import {
|
|||||||
PaymentBadge,
|
PaymentBadge,
|
||||||
SchedulingCell,
|
SchedulingCell,
|
||||||
} from "@/pages/bookings/booking-display";
|
} from "@/pages/bookings/booking-display";
|
||||||
import { InitiateBookingButton } from "@/components/customer-actions/ContractCustomerAction";
|
import {
|
||||||
|
InitiateBookingButton,
|
||||||
|
RequestExtensionButton,
|
||||||
|
} from "@/components/customer-actions/ContractCustomerAction";
|
||||||
import { formatRateUnit } from "./new-contract-form/unit-rates";
|
import { formatRateUnit } from "./new-contract-form/unit-rates";
|
||||||
import { formatAmount } from "./new-shipment-form/total";
|
import { formatAmount } from "./new-shipment-form/total";
|
||||||
import { bookingDocNoun } from "@/pages/bookings/clearance/bookingNextAction";
|
import { bookingDocNoun } from "@/pages/bookings/clearance/bookingNextAction";
|
||||||
@@ -391,6 +395,11 @@ export default function ContractDetailPage() {
|
|||||||
// Self-clearance import/export (ONE_TIME or GENERAL): one-click bare booking
|
// Self-clearance import/export (ONE_TIME or GENERAL): one-click bare booking
|
||||||
// instance — the per-booking clearance runs first, so no window gate here.
|
// instance — the per-booking clearance runs first, so no window gate here.
|
||||||
const canInitiateBooking = bookingAction.kind === "initiate" && isContainer;
|
const canInitiateBooking = bookingAction.kind === "initiate" && isContainer;
|
||||||
|
// Lapsed contract: the customer asks for more time once; staff then add
|
||||||
|
// validity days and the contract comes back. The API refuses a second ask.
|
||||||
|
const extensionPending = Boolean(contract.extensionRequestedAt);
|
||||||
|
const canRequestExtension =
|
||||||
|
contract.status === "EXPIRED" && !extensionPending;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box style={{ padding: "28px 32px 40px" }}>
|
<Box style={{ padding: "28px 32px 40px" }}>
|
||||||
@@ -479,6 +488,13 @@ export default function ContractDetailPage() {
|
|||||||
size="md"
|
size="md"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{canRequestExtension && (
|
||||||
|
<RequestExtensionButton
|
||||||
|
contract={contract}
|
||||||
|
icon={CalendarPlus}
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{canBookShipment &&
|
{canBookShipment &&
|
||||||
(bookingWindowOpen ? (
|
(bookingWindowOpen ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -534,6 +550,28 @@ export default function ContractDetailPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
{contract.status === "EXPIRED" && (
|
||||||
|
<Alert
|
||||||
|
color={extensionPending ? "blue" : "red"}
|
||||||
|
radius="md"
|
||||||
|
title={
|
||||||
|
extensionPending
|
||||||
|
? "Extension requested — awaiting EDR"
|
||||||
|
: "Contract expired"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{extensionPending
|
||||||
|
? `You asked EDR to extend this contract on ${new Date(
|
||||||
|
contract.extensionRequestedAt!,
|
||||||
|
).toLocaleDateString()}. Once EDR adds validity days it becomes active again and you will be notified.`
|
||||||
|
: `This contract's validity ended${
|
||||||
|
contract.contractValidUntil
|
||||||
|
? ` on ${new Date(contract.contractValidUntil).toLocaleDateString()}`
|
||||||
|
: ""
|
||||||
|
}. New shipments cannot be booked under it. Request an extension and EDR will add validity days to make it active again.`}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{contract.status === "SUSPENDED" && (
|
{contract.status === "SUSPENDED" && (
|
||||||
<Alert color="orange" radius="md" title="Contract suspended by EDR">
|
<Alert color="orange" radius="md" title="Contract suspended by EDR">
|
||||||
{contract.latestSuspensionNote
|
{contract.latestSuspensionNote
|
||||||
|
|||||||
@@ -290,6 +290,19 @@ export const contractsService = {
|
|||||||
return data.data ?? data;
|
return data.data ?? data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask EDR to extend the validity of an EXPIRED contract. Staff then add days
|
||||||
|
* on their side and the contract becomes active again. One pending request
|
||||||
|
* at a time — the API rejects a second one.
|
||||||
|
*/
|
||||||
|
requestExtension: async (
|
||||||
|
id: string,
|
||||||
|
note?: string,
|
||||||
|
): Promise<Freight.IContract> => {
|
||||||
|
const { data } = await client.post(C.EXTENSION_REQUEST(id), { note });
|
||||||
|
return data.data ?? data;
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cancel own contract so a fresh one can be requested on the same lane. The
|
* Cancel own contract so a fresh one can be requested on the same lane. The
|
||||||
* API rejects it while any shipment on the contract is still live.
|
* API rejects it while any shipment on the contract is still live.
|
||||||
|
|||||||
@@ -844,6 +844,22 @@ export interface IContract extends BaseEntity {
|
|||||||
* SUSPENDED). Shown to both staff and customer.
|
* SUSPENDED). Shown to both staff and customer.
|
||||||
*/
|
*/
|
||||||
latestSuspensionNote?: string | null;
|
latestSuspensionNote?: string | null;
|
||||||
|
/**
|
||||||
|
* Status the contract held when it lapsed to EXPIRED; restored when staff
|
||||||
|
* extend it. Null on rows that expired before this was tracked.
|
||||||
|
*/
|
||||||
|
statusBeforeExpiry?: ContractStatus | null;
|
||||||
|
/**
|
||||||
|
* When the customer asked for this EXPIRED contract's validity to be
|
||||||
|
* extended. Null when never asked, and cleared again once staff extend it.
|
||||||
|
* Staff can only extend while it is set.
|
||||||
|
*/
|
||||||
|
extensionRequestedAt?: string | null;
|
||||||
|
/**
|
||||||
|
* Body of the latest EXTENSION_REQUESTED review note (detail response only,
|
||||||
|
* while the request is pending). Tells staff why the customer wants more time.
|
||||||
|
*/
|
||||||
|
latestExtensionRequestNote?: string | null;
|
||||||
/**
|
/**
|
||||||
* Bookings on this contract that are not in a terminal state (detail response
|
* Bookings on this contract that are not in a terminal state (detail response
|
||||||
* only). The portal's cancel action is blocked while this is > 0.
|
* only). The portal's cancel action is blocked while this is > 0.
|
||||||
@@ -1157,3 +1173,17 @@ export interface RenewContractDto {
|
|||||||
/** Reference of the prior contract being renewed. */
|
/** Reference of the prior contract being renewed. */
|
||||||
previousContractReference?: string;
|
previousContractReference?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Customer asks EDR to extend the validity of their EXPIRED contract. */
|
||||||
|
export interface RequestContractExtensionDto {
|
||||||
|
/** Why the customer needs more time (optional, shown to staff). */
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Staff extend an EXPIRED contract the customer asked to extend. */
|
||||||
|
export interface ExtendContractDto {
|
||||||
|
/** Days to add — from today once lapsed, else from the current end date. */
|
||||||
|
days: number;
|
||||||
|
/** Optional note recorded with the extension and shown to the customer. */
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user