feat: add hazardous goods declaration feature

- Introduced HazardDeclarationPanel component to display dangerous goods declaration details.
- Updated URL constants to include CLEARANCE_PROCEED endpoint for re-requesting operations.
- Enhanced permissions to include hazardous approval roles for contract approvals.
- Integrated HazardDeclarationPanel into ContractRequestDetailPage and ContractClearanceDetailPage.
- Added proceedToOperation method in bookings service for handling operation re-requests.
- Updated contract forms and schemas to include hazard class and UN number fields.
- Implemented validation for hazardous contracts in the contract creation flow.
- Added expiry notice functionality for contracts nearing validity end.
- Created tests for expiry notice calculations and labels.
- Updated UI components to reflect hazardous cargo information and validation errors.
This commit is contained in:
Marshal
2026-07-25 21:10:09 +00:00
parent fde5e6de4b
commit 9a1c8e5603
41 changed files with 1663 additions and 99 deletions

View File

@@ -22,6 +22,7 @@ import {
assertCanApproveContractStep,
assertFreightPermission,
canEditContractStep,
HAZARDOUS_APPROVAL_ROLES,
} from '../../common/freight-permission.util';
import {
FREIGHT_PERMS,
@@ -530,12 +531,26 @@ export class ContractTransitionService {
);
}
for (const rule of chain) {
await this.contractsRepository.createApprovalStep({
contractId: contract.id,
stepOrder: rule.stepOrder,
// Dangerous goods clear two dedicated hazardous desks BEFORE the commercial
// chain — if either refuses, the contract never reaches the approvers who
// would price and sign it. Steps are renumbered sequentially so the prefix
// and the configured chain form one ordered list.
const roles: Array<{ requiredRole: string; blocksRole: string | null }> = [
...(contract.isHazardous ? [...HAZARDOUS_APPROVAL_ROLES] : []).map(
(requiredRole) => ({ requiredRole, blocksRole: null }),
),
...chain.map((rule) => ({
requiredRole: rule.requiredRole,
blocksRole: rule.blocksRole ?? null,
})),
];
for (const [index, role] of roles.entries()) {
await this.contractsRepository.createApprovalStep({
contractId: contract.id,
stepOrder: index + 1,
requiredRole: role.requiredRole,
blocksRole: role.blocksRole,
status: 'PENDING',
});
}
@@ -1111,7 +1126,17 @@ export class ContractTransitionService {
options.signerUserId,
contract,
);
assertContractStatus(contract, ['CONTRACT_READY']);
// SIGNED_CUSTOMER is allowed only for the re-sign-to-add-a-stamp case that
// {@link sign} permits — otherwise the code would be useless on arrival.
const existing = await this.contractsRepository.findSignature(
contractId,
'CUSTOMER',
);
const addingMissingStamp = Boolean(existing) && !existing?.stampFileId;
assertContractStatus(
contract,
addingMissingStamp ? ['CONTRACT_READY', 'SIGNED_CUSTOMER'] : ['CONTRACT_READY'],
);
const signerContacts = await this.resolveSignerContacts(options.signerUserId);
await this.otpService.sendOtp(signerContacts);
@@ -1135,9 +1160,16 @@ export class ContractTransitionService {
options.signerUserId,
contract,
);
assertContractStatus(contract, ['CONTRACT_READY']);
const existing = await this.contractsRepository.findSignature(contractId, 'CUSTOMER');
if (existing) {
// Signing is one-shot, with one exception: a contract signed before the
// company stamp was required has to be sealed before EDR can counter-sign
// it, so the customer may sign again purely to attach the missing stamp.
const addingMissingStamp = Boolean(existing) && !existing?.stampFileId;
assertContractStatus(
contract,
addingMissingStamp ? ['CONTRACT_READY', 'SIGNED_CUSTOMER'] : ['CONTRACT_READY'],
);
if (existing && !addingMissingStamp) {
throw new BadRequestException('Customer has already signed this contract');
}
// Sudo-mode gate: a fresh, single-use OTP must be verified before the