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

@@ -0,0 +1,47 @@
import { ForbiddenException } from '@nestjs/common';
import {
assertCanApproveContractStep,
canEditContractStep,
} from './freight-permission.util';
import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
const userWith = (...keys: string[]) => ({
permissions: keys.map((key) => ({ key })),
});
describe('hazardous contract approval steps', () => {
it('rejects an approver who only holds ordinary contract-approve permissions', () => {
// The blanket "any contract approve permission" fallback must NOT reach
// dangerous goods — that is the whole point of the dedicated desks.
const lineStaff = userWith(FREIGHT_PERMS.contracts.approveLineStaff);
expect(() =>
assertCanApproveContractStep(lineStaff, 'HAZARDOUS_APPROVAL_ONE'),
).toThrow(ForbiddenException);
expect(canEditContractStep(lineStaff, 'HAZARDOUS_APPROVAL_ONE')).toBe(false);
});
it('accepts only the matching hazardous permission', () => {
const first = userWith(FREIGHT_PERMS.contracts.hazardousApprovalOne);
expect(() =>
assertCanApproveContractStep(first, 'HAZARDOUS_APPROVAL_ONE'),
).not.toThrow();
// Holding step one does not confer step two.
expect(() =>
assertCanApproveContractStep(first, 'HAZARDOUS_APPROVAL_TWO'),
).toThrow(ForbiddenException);
});
it('does not let a hazardous approver stand in for the commercial chain', () => {
const hazardOnly = userWith(
FREIGHT_PERMS.contracts.hazardousApprovalOne,
FREIGHT_PERMS.contracts.hazardousApprovalTwo,
);
expect(() => assertCanApproveContractStep(hazardOnly, 'CEO')).toThrow(
ForbiddenException,
);
});
});

View File

@@ -151,6 +151,24 @@ const APPROVE_ROLE_PERMISSION: Record<string, string> = {
CEO: FREIGHT_PERMS.bookings.approveCeo,
};
/**
* Approval-chain roles synthesized for hazardous contracts (see
* `instantiateApprovalSteps`). Unlike the legacy roles below they are NOT
* position types — they authorize purely on their own dedicated permission, and
* they deliberately opt out of the blanket "holds any contract-approve
* permission" fallback so a normal approver cannot sign off dangerous goods.
*/
export const HAZARDOUS_APPROVAL_ROLE_PERMISSION: Record<string, string> = {
HAZARDOUS_APPROVAL_ONE: FREIGHT_PERMS.contracts.hazardousApprovalOne,
HAZARDOUS_APPROVAL_TWO: FREIGHT_PERMS.contracts.hazardousApprovalTwo,
};
/** The two hazardous steps, in the order they are prepended to the chain. */
export const HAZARDOUS_APPROVAL_ROLES = [
'HAZARDOUS_APPROVAL_ONE',
'HAZARDOUS_APPROVAL_TWO',
] as const;
const CONTRACT_APPROVE_ROLE_PERMISSION: Record<string, string> = {
LINE_STAFF: FREIGHT_PERMS.contracts.approveLineStaff,
DIRECTOR: FREIGHT_PERMS.contracts.approveDirector,
@@ -183,6 +201,16 @@ export function assertCanApproveContractStep(
): void {
if (isFreightApprovalAdmin(user)) return;
// Hazardous steps are permission-only and strict — no legacy alias, no
// blanket approve fallback.
const hazardousPermission = HAZARDOUS_APPROVAL_ROLE_PERMISSION[requiredRole];
if (hazardousPermission) {
if (hasFreightPermission(user, hazardousPermission)) return;
throw new ForbiddenException(
`Missing permission: ${hazardousPermission}`,
);
}
const positionTypes = collectPositionTypeKeys(user);
if (positionTypes.includes(requiredRole)) return;
@@ -219,6 +247,11 @@ export function canEditContractStep(
): boolean {
if (isFreightApprovalAdmin(user)) return true;
const hazardousPermission = HAZARDOUS_APPROVAL_ROLE_PERMISSION[requiredRole];
if (hazardousPermission) {
return hasFreightPermission(user, hazardousPermission);
}
const positionTypes = collectPositionTypeKeys(user);
if (positionTypes.includes(requiredRole)) return true;

View File

@@ -1,4 +1,5 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { hazardClassLabel } from '@edr/types';
import { ContractsRepository } from '../modules/contracts/contracts.repository';
import {
@@ -143,6 +144,11 @@ export class ContractDocumentViewModelBuilder {
const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER');
const hasStaff = signatures.some((s) => s.role === 'STAFF');
// Signed before company stamps were required — the customer has to sign
// again to attach one, otherwise EDR can never counter-sign the contract.
const customerStampMissing = signatures.some(
(s) => s.role === 'CUSTOMER' && !s.stampImageUrl,
);
const hasContractFile = Boolean(
contract.files?.some((f) => f.code === 'contract'),
);
@@ -185,7 +191,9 @@ export class ContractDocumentViewModelBuilder {
// Cast: contract signers (CUSTOMER|STAFF|DIRECTOR|CEO) widen the booking
// view-model's narrower CUSTOMER|STAFF role union.
signatures: signatures as unknown as ContractViewModel['signatures'],
canSignCustomer: contract.status === 'CONTRACT_READY' && !hasCustomer,
canSignCustomer:
(contract.status === 'CONTRACT_READY' && !hasCustomer) ||
(contract.status === 'SIGNED_CUSTOMER' && customerStampMissing),
canSignStaff:
contract.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff,
hasContractDocument: hasContractFile,
@@ -295,7 +303,16 @@ export class ContractDocumentViewModelBuilder {
cargoDescription: this.valueOrDash(cargoName),
totalWeightVgm: '—',
equipmentReturn: this.valueOrDash(contract.equipmentReturn),
hazardousLabel: contract.isHazardous ? 'Yes' : 'No',
// A hazardous contract names the declared class + UN number on the
// schedule — the flag alone is not a dangerous-goods declaration.
hazardousLabel: contract.isHazardous
? [
hazardClassLabel(contract.hazardClass) ?? 'Yes',
contract.unNumber ? `UN ${contract.unNumber}` : null,
]
.filter(Boolean)
.join(' · ')
: 'No',
firstMilePickupAddress: this.valueOrDash(contract.firstMilePickupAddress),
lastMileDeliveryAddress: this.valueOrDash(contract.lastMileDeliveryAddress),
};

View File

@@ -0,0 +1,34 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Hazardous contracts now declare WHAT the dangerous good is, not just that it
* exists: the UN/ADR class (CLASS_1..CLASS_9) and the shipment's UN number. Both
* are captured in the portal alongside the hazard documents and reviewed by the
* two hazardous approval desks.
*
* Nullable — non-hazardous contracts leave both null, and contracts created
* before this change have no declaration to backfill.
*/
export class AddContractHazardDeclaration2920000000000
implements MigrationInterface
{
name = 'AddContractHazardDeclaration2920000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.contracts ADD COLUMN IF NOT EXISTS hazard_class varchar(16);`,
);
await queryRunner.query(
`ALTER TABLE freight.contracts ADD COLUMN IF NOT EXISTS un_number varchar(16);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.contracts DROP COLUMN IF EXISTS un_number;`,
);
await queryRunner.query(
`ALTER TABLE freight.contracts DROP COLUMN IF EXISTS hazard_class;`,
);
}
}

View File

@@ -0,0 +1,76 @@
import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service';
import type { Booking } from './entities/booking.entity';
/**
* Who hears "Operations wants changes" depends on who owns the booking. A
* customs (Path B) booking is created BY GL Ethiopia on the customer's behalf —
* the customer can neither edit nor resubmit it, so the note has to reach the GL
* who made it, not the portal.
*/
describe('BookingLifecycleNotifierService — operation changes requested', () => {
const booking = (over: Partial<Booking> = {}): Booking =>
({
id: 'b-1',
reference: 'BKG-0001',
companyId: 'co-1',
contractId: 'ctr-1',
createdByRole: 'CUSTOMER',
company: { email: 'customer@example.com' },
...over,
}) as Booking;
let notifications: { directSend: jest.Mock };
let inbox: { notify: jest.Mock };
let service: BookingLifecycleNotifierService;
const flush = () => new Promise((resolve) => setImmediate(resolve));
beforeEach(() => {
notifications = { directSend: jest.fn().mockResolvedValue(undefined) };
inbox = { notify: jest.fn().mockResolvedValue(undefined) };
service = new BookingLifecycleNotifierService(
notifications as never,
inbox as never,
{ query: jest.fn().mockResolvedValue([{ phone: '+251900000000' }]) } as never,
);
});
it('sends a GL-created booking back to the GL who created it, not the customer', async () => {
service.operationChangesRequested(
booking({ createdByRole: 'GL_ET', createdByUserId: 'gl-user-1' }),
'Cargo weight does not match the declaration',
);
await flush();
expect(inbox.notify).toHaveBeenCalledTimes(1);
const sent = inbox.notify.mock.calls[0][0];
expect(sent.recipients).toEqual({ userIds: ['gl-user-1'] });
expect(sent.audience).toBe('BACKOFFICE');
expect(sent.body).toContain('Cargo weight does not match the declaration');
// Deep-links the clearance page GL works from, not the portal booking.
expect(sent.link).toBe('/dashboard/contracts/clearance/ctr-1');
// The customer is not told to fix something they cannot touch.
expect(notifications.directSend).not.toHaveBeenCalled();
});
it('still tells the customer when the booking is their own', async () => {
service.operationChangesRequested(booking(), 'Please attach the packing list');
await flush();
const sent = inbox.notify.mock.calls[0][0];
expect(sent.recipients).toEqual({ companyId: 'co-1' });
expect(sent.audience).toBe('PORTAL');
expect(sent.link).toBe('/bookings/b-1');
expect(notifications.directSend).toHaveBeenCalled();
});
it('falls back to the customer when the GL creator is unknown (legacy rows)', async () => {
service.operationChangesRequested(
booking({ createdByRole: 'GL_ET', createdByUserId: null }),
'Fix the declaration',
);
await flush();
expect(inbox.notify.mock.calls[0][0].recipients).toEqual({ companyId: 'co-1' });
});
});

View File

@@ -179,8 +179,35 @@ export class BookingLifecycleNotifierService {
});
}
/** Operations returned the operation request for changes. */
/**
* Operations returned the operation request for changes.
*
* A customs (Path B) booking was created BY GL Ethiopia on the customer's
* behalf — the customer cannot edit or resubmit it, so telling them to "update
* from the portal" is a dead end. Those go to the GL who created it, linking
* the contract clearance page they work from. Everything else (customer-made
* bookings) keeps the portal message.
*/
operationChangesRequested(b: Booking, note: string): void {
if (b.createdByRole === 'GL_ET' && b.createdByUserId) {
const msg =
`Operations returned booking ${b.reference} for changes: ${note}. ` +
`Address it on the contract clearance page and resubmit to Operations.`;
this.logger.log(`OPERATION CHANGES REQUESTED (to GL) — ${this.ref(b)}`);
void this.inbox.notify({
recipients: { userIds: [b.createdByUserId] },
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.BOOKING_STATUS,
title: `Booking ${b.reference} needs changes`,
body: msg,
link: b.contractId
? `/dashboard/contracts/clearance/${b.contractId}`
: `/dashboard/bookings/${b.id}/clearance`,
data: { bookingId: b.id, reference: b.reference, note },
});
return;
}
const msg =
`Your operation request for booking ${b.reference} needs changes: ${note}. ` +
`Please update and resubmit from the portal.`;

View File

@@ -33,41 +33,86 @@ import {
BookingReferenceYardDto,
} from "./dto/booking-reference-data.dto";
/**
* Reference cargo tree: top-level groups, each carrying its selectable
* commodities.
*
* `cargo_types` is an arbitrary-depth tree (Bulk → Steel Billet → S1 → …), but
* only a LEAF is a real commodity — an intermediate node is a container for
* finer types, and booking against it would be ambiguous. So each group's
* `children` are all of its leaf descendants, flattened, whatever the depth.
* Deep leaves carry their path below the group ("Steel Billet → S1") so a
* generically-named leaf still reads unambiguously in a dropdown.
*
* A group with no active descendants is its own leaf and is emitted as its
* single child — otherwise it is selectable as a group but offers no commodity,
* which dead-ends every form that requires one.
*/
export function buildCargoTypeTree(
rows: CargoType[],
): BookingReferenceCargoTypeGroupDto[] {
const active = rows.filter((r) => r.isActive);
const parents = active
.filter((r) => !r.parentGroupId)
.sort(
(a, b) => a.displayOrder - b.displayOrder || a.code.localeCompare(b.code),
);
const byOrder = (a: CargoType, b: CargoType) =>
a.displayOrder - b.displayOrder || a.code.localeCompare(b.code);
const childrenOf = new Map<string, CargoType[]>();
for (const row of active) {
if (!row.parentGroupId) continue;
const siblings = childrenOf.get(row.parentGroupId) ?? [];
siblings.push(row);
childrenOf.set(row.parentGroupId, siblings);
}
for (const siblings of childrenOf.values()) siblings.sort(byOrder);
const parents = active.filter((r) => !r.parentGroupId).sort(byOrder);
/** Depth-first leaf walk; `trail` is the path below the group. */
const collectLeaves = (
node: CargoType,
trail: string[],
seen: Set<string>,
): BookingReferenceCargoTypeChildDto[] => {
// Admin-entered parent pointers could in principle cycle — never loop.
if (seen.has(node.id)) return [];
seen.add(node.id);
const kids = childrenOf.get(node.id) ?? [];
if (kids.length === 0) {
return [
{
id: node.id,
name: [...trail, node.cargoTypeName].join(" → "),
code: node.code,
unit_of_measure: node.unitOfMeasure ?? null,
},
];
}
const nextTrail = [...trail, node.cargoTypeName];
return kids.flatMap((kid) => collectLeaves(kid, nextTrail, seen));
};
return parents.map((parent) => {
const children = active
.filter((r) => r.parentGroupId === parent.id)
.sort(
(a, b) =>
a.displayOrder - b.displayOrder || a.code.localeCompare(b.code),
)
.map(
(child): BookingReferenceCargoTypeChildDto => ({
id: child.id,
name: child.cargoTypeName,
code: child.code,
unit_of_measure: child.unitOfMeasure ?? null,
}),
);
const kids = childrenOf.get(parent.id) ?? [];
const children =
kids.length === 0
? // The group itself is the commodity.
[
{
id: parent.id,
name: parent.cargoTypeName,
code: parent.code,
unit_of_measure: parent.unitOfMeasure ?? null,
},
]
: kids.flatMap((kid) => collectLeaves(kid, [], new Set<string>()));
const group: BookingReferenceCargoTypeGroupDto = {
return {
id: parent.id,
name: parent.cargoTypeName,
code: parent.code,
children,
};
if (children.length > 0) {
group.children = children;
}
return group;
});
}

View File

@@ -0,0 +1,72 @@
import { buildCargoTypeTree } from './booking-reference-data.service';
import type { CargoType } from '../rule-engine/entities/cargo-type.entity';
const node = (
id: string,
name: string,
parentGroupId: string | null,
isActive = true,
): CargoType =>
({
id,
cargoTypeName: name,
code: name.toUpperCase().replace(/\s+/g, '_'),
parentGroupId,
displayOrder: 0,
isActive,
unitOfMeasure: 'PER_TON',
}) as unknown as CargoType;
describe('buildCargoTypeTree', () => {
// Bulk ──┬─ Wheat (leaf, depth 2)
// └─ Steel Billet ──┬─ S1 (leaf, depth 3)
// └─ S2 ─ S2a (leaf, depth 4)
const rows = [
node('bulk', 'Bulk', null),
node('wheat', 'Wheat', 'bulk'),
node('steel', 'Steel Billet', 'bulk'),
node('s1', 'S1', 'steel'),
node('s2', 'S2', 'steel'),
node('s2a', 'S2a', 's2'),
node('general', 'General Cargo', null),
];
it('offers only leaves as commodities, at any depth', () => {
const [bulk] = buildCargoTypeTree(rows);
// Leaves stay grouped under their branch (siblings ordered by
// displayOrder then code — STEEL_BILLET before WHEAT here).
expect(bulk.children?.map((c) => c.id)).toEqual(['s1', 's2a', 'wheat']);
// "Steel Billet" is a container for finer types, never bookable itself.
expect(bulk.children?.some((c) => c.id === 'steel')).toBe(false);
});
it('labels deep leaves with their path below the group', () => {
const [bulk] = buildCargoTypeTree(rows);
const byId = new Map(bulk.children?.map((c) => [c.id, c.name]));
expect(byId.get('wheat')).toBe('Wheat');
expect(byId.get('s1')).toBe('Steel Billet → S1');
expect(byId.get('s2a')).toBe('Steel Billet → S2 → S2a');
});
it('emits a childless group as its own commodity', () => {
const general = buildCargoTypeTree(rows).find((g) => g.id === 'general');
expect(general?.children).toEqual([
expect.objectContaining({ id: 'general', name: 'General Cargo' }),
]);
});
it('skips inactive nodes and their descendants', () => {
const withRetired = [
...rows,
node('retired', 'Retired', 'bulk', false),
node('retiredKid', 'Retired Kid', 'retired', false),
];
const [bulk] = buildCargoTypeTree(withRetired);
expect(bulk.children?.map((c) => c.id)).not.toContain('retired');
expect(bulk.children?.map((c) => c.id)).not.toContain('retiredKid');
});
});

View File

@@ -84,6 +84,14 @@ export interface ContractClearanceView {
/** Reference + status of the GL-created shipment booking, once it exists. */
linkedBookingReference?: string | null;
linkedBookingStatus?: string | null;
/**
* Operations' latest "needs changes" note on that booking. GL created the
* booking, so GL is the one who has to act on it — surfaced here because the
* clearance page is where GL works, not the portal.
*/
linkedBookingReviewNote?: string | null;
/** Shipment day the booking currently holds — the default when GL resubmits. */
linkedBookingScheduledDate?: string | null;
dutyAdvice?: {
amount: number;
currency: string;
@@ -301,11 +309,24 @@ export class ContractClearanceService {
// shortly" message. Reuse the export booking load; fetch for import too.
let linkedBookingReference: string | null = null;
let linkedBookingStatus: string | null = null;
let linkedBookingReviewNote: string | null = null;
let linkedBookingScheduledDate: string | null = null;
if (cycle?.bookingId) {
const booking = await this.bookingsService.findById(cycle.bookingId);
if (booking) {
linkedBookingReference = booking.reference ?? null;
linkedBookingStatus = booking.status ?? null;
linkedBookingScheduledDate = booking.scheduledDate
? new Date(booking.scheduledDate).toISOString()
: null;
// Newest changes-requested note (reviewNotes ride along on findById).
linkedBookingReviewNote =
[...(booking.reviewNotes ?? [])]
.filter((n) => n.type === 'CHANGES_REQUESTED')
.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
)[0]?.note ?? null;
if (contract.tradeDirection === 'EXPORT') {
nextAction = this.workflowService.computeNextActionForBooking(
booking,
@@ -349,6 +370,8 @@ export class ContractClearanceService {
linkedBookingId: cycle?.bookingId ?? null,
linkedBookingReference,
linkedBookingStatus,
linkedBookingReviewNote,
linkedBookingScheduledDate,
dutyAdvice,
workflowFiles,
t1,

View File

@@ -0,0 +1,63 @@
import { ContractExpiryService } from './contract-expiry.service';
import type { Contract } from './entities/contract.entity';
/**
* The reminder must warn each customer once, ten days out, and must never let a
* notification failure escape into the scheduler (that would also take out the
* expiry sweep sharing this service).
*/
describe('ContractExpiryService — expiry reminder', () => {
const contract = (over: Partial<Contract> = {}): Contract =>
({
id: 'c-1',
reference: 'CTR-2026-00042',
companyId: 'co-1',
contractValidUntil: new Date('2026-08-10T00:00:00.000Z'),
status: 'CONTRACT_ACTIVE',
...over,
}) as Contract;
let repo: { expireLapsedContracts: jest.Mock; findExpiringInDays: jest.Mock };
let inbox: { notify: jest.Mock };
let service: ContractExpiryService;
beforeEach(() => {
repo = {
expireLapsedContracts: jest.fn().mockResolvedValue(0),
findExpiringInDays: jest.fn().mockResolvedValue([]),
};
inbox = { notify: jest.fn().mockResolvedValue(undefined) };
service = new ContractExpiryService(repo as never, inbox as never);
});
it('asks for the contracts lapsing ten days out', async () => {
await service.remindExpiringContracts();
expect(repo.findExpiringInDays).toHaveBeenCalledWith(10);
});
it('notifies the owning company once, deep-linking the contract list', async () => {
repo.findExpiringInDays.mockResolvedValue([contract()]);
await service.remindExpiringContracts();
expect(inbox.notify).toHaveBeenCalledTimes(1);
const sent = inbox.notify.mock.calls[0][0];
expect(sent.recipients).toEqual({ companyId: 'co-1' });
expect(sent.title).toContain('CTR-2026-00042');
expect(sent.title).toContain('10 days');
expect(sent.link).toBe('/contracts');
expect(sent.data).toMatchObject({ contractId: 'c-1', action: 'CONTRACT_EXPIRING' });
});
it('skips a contract with no owning company (nobody to notify)', async () => {
repo.findExpiringInDays.mockResolvedValue([contract({ companyId: null })]);
await service.remindExpiringContracts();
expect(inbox.notify).not.toHaveBeenCalled();
});
it('swallows a notification failure instead of throwing into the scheduler', async () => {
repo.findExpiringInDays.mockResolvedValue([contract()]);
inbox.notify.mockRejectedValue(new Error('inbox down'));
await expect(service.remindExpiringContracts()).resolves.toBeUndefined();
});
});

View File

@@ -5,6 +5,13 @@ import { NotificationAudience, NotificationType } from '@edr/types';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { ContractsRepository } from './contracts.repository';
/**
* How many days before a contract lapses the customer is reminded. Mirrored by
* the portal contract list (EXPIRY_NOTICE_DAYS in contract-ui.tsx), which shows
* the same countdown on the row.
*/
const EXPIRY_NOTICE_DAYS = 10;
/** Nightly sweep that flips contracts past contractValidUntil to EXPIRED. */
@Injectable()
export class ContractExpiryService {
@@ -15,6 +22,51 @@ export class ContractExpiryService {
private readonly inbox: NotificationInboxService,
) {}
/**
* Warn every customer whose contract lapses in ~10 days, once. The repository
* window is a rolling 24h slice, so a contract is picked up by exactly one
* daily run — no reminded-flag column needed.
*
* ponytail: a missed run (API down over the slice) skips that contract's
* reminder; the portal list still shows its countdown for the whole window.
*/
@Cron(CronExpression.EVERY_DAY_AT_2AM, { name: 'contract-expiry-reminder' })
async remindExpiringContracts(): Promise<void> {
try {
const expiring =
await this.contractsRepository.findExpiringInDays(EXPIRY_NOTICE_DAYS);
let notified = 0;
for (const contract of expiring) {
if (!contract.companyId || !contract.contractValidUntil) continue;
const endsOn = contract.contractValidUntil.toLocaleDateString('en-GB');
await this.inbox.notify({
recipients: { companyId: contract.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.CONTRACT_STATUS,
title: `Contract ${contract.reference} expires in ${EXPIRY_NOTICE_DAYS} days`,
body:
`Your contract ${contract.reference} is valid until ${endsOn}. ` +
'After that date it stops accepting new bookings — contact EDR if ' +
'you need it renewed.',
link: '/contracts',
data: { contractId: contract.id, action: 'CONTRACT_EXPIRING' },
});
notified += 1;
}
this.logger.log(
`Contract expiry reminder: ${notified} customer(s) warned of a contract ` +
`lapsing in ${EXPIRY_NOTICE_DAYS} days`,
);
} catch (err) {
// Never throws into the scheduler — a failed reminder must not stop the
// expiry sweep from running.
this.logger.error(
`Contract expiry reminder failed: ${(err as Error).message}`,
(err as Error).stack,
);
}
}
@Cron(CronExpression.EVERY_DAY_AT_1AM, { name: 'contract-expiry-sweep' })
async expireLapsedContracts(): Promise<void> {
try {

View File

@@ -0,0 +1,131 @@
import { BadRequestException, ConflictException } from '@nestjs/common';
import { ContractTransitionService } from './contract-transition.service';
/**
* Signing is one-shot. The single exception: a contract signed before company
* stamps were required must be re-signable so the customer can attach one —
* otherwise counterSign's both-stamps gate strands it forever. These specs pin
* that exception open and pin everything else shut.
*/
describe('customer re-sign to attach a missing stamp', () => {
const contractReady = { id: 'c-1', reference: 'CTR-1', status: 'CONTRACT_READY' };
const signedNoStamp = { id: 'c-1', reference: 'CTR-1', status: 'SIGNED_CUSTOMER' };
const build = (contract: unknown, existingSignature: unknown) => {
const applied: unknown[] = [];
const service = Object.create(
ContractTransitionService.prototype,
) as ContractTransitionService;
Object.assign(service, {
contractsService: {
findById: jest.fn().mockResolvedValue(contract),
assertCustomerCanAccessContract: jest.fn().mockResolvedValue(undefined),
},
contractsRepository: {
findSignature: jest.fn().mockResolvedValue(existingSignature),
update: jest.fn().mockResolvedValue(undefined),
},
otpService: {
verifyOtpForAction: jest.fn().mockResolvedValue(undefined),
sendOtp: jest.fn().mockResolvedValue(undefined),
},
notifier: { customerSignedToStaff: jest.fn() },
resolveSignerContacts: jest.fn().mockResolvedValue({ phone: '+251900000000' }),
applySignature: jest.fn((...args: unknown[]) => {
applied.push(args);
return Promise.resolve();
}),
regenerateContractPdf: jest.fn().mockResolvedValue(undefined),
});
return { service, applied };
};
const dto = {
role: 'CUSTOMER' as const,
signerDisplayName: 'C. Customer',
signatureImageBase64: 'data:image/png;base64,AAAA',
stampImageBase64: 'data:image/png;base64,BBBB',
otp: '123456',
};
it('lets a customer sign again when their signature has no stamp', async () => {
const { service, applied } = build(signedNoStamp, {
id: 's-1',
role: 'CUSTOMER',
stampFileId: null,
});
await expect(service.sign('c-1', dto, { signerUserId: 'u-1' })).resolves.toBeDefined();
expect(applied).toHaveLength(1);
});
it('still refuses a second signature once a stamp is on file', async () => {
const { service } = build(signedNoStamp, {
id: 's-1',
role: 'CUSTOMER',
stampFileId: 'file-1',
});
// Stamped already → not the re-sign case, so the status guard rejects
// SIGNED_CUSTOMER before the already-signed check is reached.
await expect(service.sign('c-1', dto, { signerUserId: 'u-1' })).rejects.toBeInstanceOf(
ConflictException,
);
});
it('refuses a second signature on a still-ready contract', async () => {
const { service } = build(contractReady, {
id: 's-1',
role: 'CUSTOMER',
stampFileId: 'file-1',
});
await expect(service.sign('c-1', dto, { signerUserId: 'u-1' })).rejects.toThrow(
/already signed/i,
);
});
it('signs normally when nothing is on file yet', async () => {
const { service, applied } = build(contractReady, null);
await expect(service.sign('c-1', dto, { signerUserId: 'u-1' })).resolves.toBeDefined();
expect(applied).toHaveLength(1);
});
it('sends a signing OTP for the stamp re-sign', async () => {
const { service } = build(signedNoStamp, {
id: 's-1',
role: 'CUSTOMER',
stampFileId: null,
});
await expect(
service.sendSigningOtp('c-1', { signerUserId: 'u-1' }),
).resolves.toEqual(expect.objectContaining({ sentTo: expect.any(String) }));
});
it('refuses a signing OTP once the contract is signed and stamped', async () => {
const { service } = build(signedNoStamp, {
id: 's-1',
role: 'CUSTOMER',
stampFileId: 'file-1',
});
await expect(
service.sendSigningOtp('c-1', { signerUserId: 'u-1' }),
).rejects.toBeInstanceOf(ConflictException);
});
it('requires the OTP on the re-sign path too', async () => {
const { service } = build(signedNoStamp, {
id: 's-1',
role: 'CUSTOMER',
stampFileId: null,
});
await expect(
service.sign('c-1', { ...dto, otp: undefined }, { signerUserId: 'u-1' }),
).rejects.toBeInstanceOf(BadRequestException);
});
});

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

View File

@@ -107,6 +107,30 @@ export class ContractsRepository extends BaseRepository<Contract> {
return result.affected ?? 0;
}
/**
* Live contracts whose validity ends between `days` and `days + 1` days from
* now — the slice the daily expiry-reminder cron warns about. The window is
* rolling and exactly 24h wide, so consecutive daily runs tile it without
* gaps or overlaps: each contract is picked up by exactly one run and the
* customer is notified once, with no "already reminded" flag to store.
*/
async findExpiringInDays(days: number): Promise<Contract[]> {
const now = Date.now();
return this.repository
.createQueryBuilder('contract')
.where('contract.deleted_at IS NULL')
.andWhere('contract.status NOT IN (:...terminal)', {
terminal: TERMINAL_CONTRACT_STATUSES,
})
.andWhere('contract.contract_valid_until >= :from', {
from: new Date(now + days * 86_400_000),
})
.andWhere('contract.contract_valid_until < :to', {
to: new Date(now + (days + 1) * 86_400_000),
})
.getMany();
}
/** Find a contract by ID with all child collections, service type, company and files. */
async findByIdWithRelations(id: string): Promise<Contract | null> {
if (!id) return null;

View File

@@ -343,6 +343,10 @@ export class ContractsService {
lastMileDeliveryLat: dto.lastMileDeliveryLat ?? null,
lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null,
isHazardous: dto.isHazardous ?? false,
// Hazard class / UN number only exist on a hazardous contract — a stale
// pair from an earlier draft must never survive the flag being turned off.
hazardClass: dto.isHazardous ? (dto.hazardClass ?? null) : null,
unNumber: dto.isHazardous ? (dto.unNumber ?? null) : null,
isReefer: dto.isReefer ?? false,
contractType: dto.contractType ?? null,
status: 'DRAFT',
@@ -515,6 +519,13 @@ export class ContractsService {
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
isHazardous: dto.isHazardous ?? existing.isHazardous,
isReefer: dto.isReefer ?? existing.isReefer,
// Same rule as create: clearing the flag clears the declaration with it.
hazardClass: (dto.isHazardous ?? existing.isHazardous)
? (dto.hazardClass ?? existing.hazardClass ?? null)
: null,
unNumber: (dto.isHazardous ?? existing.isHazardous)
? (dto.unNumber ?? existing.unNumber ?? null)
: null,
equipmentReturn: dto.equipmentReturn ?? existing.equipmentReturn,
firstMilePickupAddress: dto.firstMilePickupAddress ?? existing.firstMilePickupAddress,
firstMilePickupLat: dto.firstMilePickupLat ?? existing.firstMilePickupLat,

View File

@@ -17,6 +17,8 @@ import {
ValidateNested,
} from 'class-validator';
import { HAZARD_CLASS_VALUES } from '@edr/types';
import { CONTRACT_KINDS } from '../entities/contract.entity';
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const;
@@ -228,6 +230,28 @@ export class CreateContractDto {
@Transform(({ value }) => value === 'true' || value === true)
isHazardous?: boolean;
@ApiPropertyOptional({
enum: HAZARD_CLASS_VALUES,
description: 'UN/ADR dangerous-goods class. Required when isHazardous.',
})
@ValidateIf((o: CreateContractDto) => o.isHazardous === true)
@IsIn(HAZARD_CLASS_VALUES, {
message: `hazardClass must be one of: ${HAZARD_CLASS_VALUES.join(', ')}`,
})
hazardClass?: string;
@ApiPropertyOptional({
description: 'UN number of the dangerous good. Required when isHazardous.',
})
@ValidateIf((o: CreateContractDto) => o.isHazardous === true)
@IsString()
@MinLength(1)
@MaxLength(16)
@Transform(({ value }) =>
typeof value === 'string' ? value.trim().toUpperCase() : value,
)
unNumber?: string;
@ApiPropertyOptional({ default: false, description: 'Sets contracts.is_reefer' })
@IsOptional()
@IsBoolean()

View File

@@ -188,6 +188,14 @@ export class Contract extends BaseEntity {
@Column({ name: 'is_hazardous', type: 'boolean', default: false })
isHazardous!: boolean;
/** UN/ADR dangerous-goods class (CLASS_1..CLASS_9); null unless hazardous. */
@Column({ name: 'hazard_class', type: 'varchar', length: 16, nullable: true })
hazardClass?: string | null;
/** UN number of the dangerous good; null unless hazardous. */
@Column({ name: 'un_number', type: 'varchar', length: 16, nullable: true })
unNumber?: string | null;
@Column({ name: 'is_reefer', type: 'boolean', default: false })
isReefer!: boolean;

View File

@@ -0,0 +1,80 @@
import { ConflictException } from '@nestjs/common';
import type { DataSource } from 'typeorm';
import { RoutesService } from './routes.service';
import type { RoutesRepository } from './routes.repository';
type StopSeq = Array<{ yardId: string; sequenceNo: number }>;
/** DataSource stub whose Route repository returns the given existing routes. */
const serviceWith = (
existing: Array<{ id: string; milestones: StopSeq }>,
): RoutesService => {
const dataSource = {
getRepository: () => ({ find: async () => existing }),
} as unknown as DataSource;
return new RoutesService(dataSource, {} as RoutesRepository);
};
const assertNotDuplicate = (
service: RoutesService,
yardIds: string[],
excludeRouteId?: string,
): Promise<void> =>
(
service as unknown as {
assertNotDuplicate: (
m: Array<{ yardId: string }>,
id?: string,
) => Promise<void>;
}
).assertNotDuplicate(
yardIds.map((yardId) => ({ yardId })),
excludeRouteId,
);
describe('RoutesService duplicate guard', () => {
const addisAdamaDire: StopSeq = [
{ yardId: 'addis', sequenceNo: 1 },
{ yardId: 'adama', sequenceNo: 2 },
{ yardId: 'dire', sequenceNo: 3 },
];
it('rejects an identical stop sequence', async () => {
const service = serviceWith([{ id: 'r1', milestones: addisAdamaDire }]);
await expect(
assertNotDuplicate(service, ['addis', 'adama', 'dire']),
).rejects.toBeInstanceOf(ConflictException);
});
it('allows the same endpoints with a different corridor', async () => {
// Same origin + destination, but skipping Adama is a genuinely other route.
const service = serviceWith([{ id: 'r1', milestones: addisAdamaDire }]);
await expect(
assertNotDuplicate(service, ['addis', 'dire']),
).resolves.toBeUndefined();
});
it('does not flag the route being edited against itself', async () => {
const service = serviceWith([{ id: 'r1', milestones: addisAdamaDire }]);
await expect(
assertNotDuplicate(service, ['addis', 'adama', 'dire'], 'r1'),
).resolves.toBeUndefined();
});
it('compares stops by sequence, not storage order', async () => {
const shuffled: StopSeq = [
{ yardId: 'dire', sequenceNo: 3 },
{ yardId: 'addis', sequenceNo: 1 },
{ yardId: 'adama', sequenceNo: 2 },
];
const service = serviceWith([{ id: 'r1', milestones: shuffled }]);
await expect(
assertNotDuplicate(service, ['addis', 'adama', 'dire']),
).rejects.toBeInstanceOf(ConflictException);
});
});

View File

@@ -5,7 +5,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { TrainScheduleStatus } from '@edr/types';
import { DataSource, In } from 'typeorm';
import { DataSource, In, Not } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { Yard } from '../rule-engine/entities/yard.entity';
@@ -15,7 +15,7 @@ import { CreateRouteDto } from './dto/create-route.dto';
import { FilterRoutesDto } from './dto/filter-routes.dto';
import { UpdateRouteDto } from './dto/update-route.dto';
import { RouteMilestone } from './entities/route-milestone.entity';
import { formatRouteLabel, Route } from './entities/route.entity';
import { formatRouteLabel, Route, type RouteStatus } from './entities/route.entity';
import { RoutesRepository } from './routes.repository';
/** Order-insensitive key: distances are symmetric. */
@@ -88,6 +88,7 @@ export class RoutesService {
async create(dto: CreateRouteDto): Promise<Route> {
const validated = await this.validateMilestones(dto.milestones);
await this.assertNotDuplicate(validated.milestones);
const route = await this.dataSource.transaction(async (manager) => {
const savedRoute = await manager.getRepository(Route).save(
@@ -123,6 +124,11 @@ export class RoutesService {
? await this.validateMilestones(dto.milestones)
: null;
// An edit can collide with another route just as easily as a create can.
if (milestoneInput) {
await this.assertNotDuplicate(milestoneInput.milestones, id);
}
// Milestones or endpoints are about to be rewritten — reject if any
// non-terminal schedule still references this route, otherwise its stop list
// and distances would silently shift under a live plan. Status-only /
@@ -187,6 +193,51 @@ export class RoutesService {
return this.findById(id);
}
/**
* A route IS its ordered stop list — "Addis → Adama → Dire Dawa" and
* "Addis → Dire Dawa" share endpoints but are different corridors. So the
* duplicate test compares the full yard sequence, not just origin/destination.
*
* Decommissioned routes (STOP_WORKING) are ignored: replacing a retired
* corridor with a fresh one is exactly what an admin does after deactivating,
* and there is no reactivate action to fall back on.
*/
private async assertNotDuplicate(
milestones: Array<{ yardId: string }>,
excludeRouteId?: string,
): Promise<void> {
const signature = milestones.map((m) => m.yardId).join('>');
const candidates = await this.dataSource.getRepository(Route).find({
where: {
originYardId: milestones[0].yardId,
destinationYardId: milestones[milestones.length - 1].yardId,
status: Not<RouteStatus>('STOP_WORKING'),
},
relations: {
originYard: true,
destinationYard: true,
milestones: { yard: true },
},
});
const duplicate = candidates.find((route) => {
if (route.id === excludeRouteId) return false;
const stops = [...(route.milestones ?? [])]
.sort((a, b) => a.sequenceNo - b.sequenceNo)
.map((m) => m.yardId)
.join('>');
return stops === signature;
});
if (duplicate) {
throw new ConflictException(
`This route already exists: ${formatRouteLabel(duplicate)}. ` +
'Edit the existing route instead of creating a duplicate.',
);
}
}
private async validateMilestones(milestones: Array<{ yardId: string }>) {
if (milestones.length < 2) {
throw new BadRequestException('A route requires at least two yards');

View File

@@ -99,6 +99,10 @@ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [
perm('a3000001-0001-4000-8000-00000000000e', 'edr_freight_app:contracts:clearance_et_actions', 'GL Ethiopia phased clearance actions'),
perm('a3000001-0001-4000-8000-00000000000f', 'edr_freight_app:contracts:clearance_dj_actions', 'GL Djibouti phased clearance actions'),
perm('a3000001-0001-4000-8000-000000000010', 'edr_freight_app:contracts:clearance_duty_advise', 'Advise contract duty/tax'),
// Hazardous contracts get two extra approval steps ahead of the normal chain.
// Each has its own permission so the two desks are genuinely separate people.
perm('a3000001-0001-4000-8000-000000000019', 'edr_freight_app:contracts:hazardous_approval_one', 'Hazardous approval — first review'),
perm('a3000001-0001-4000-8000-00000000001a', 'edr_freight_app:contracts:hazardous_approval_two', 'Hazardous approval — second review'),
];
// Existing per-slug view ids are kept as-is: position-type grants reference
@@ -423,6 +427,8 @@ export const FREIGHT_PERMS = {
approveLineStaff: 'edr_freight_app:contracts:approve_line_staff',
approveDirector: 'edr_freight_app:contracts:approve_director',
approveCeo: 'edr_freight_app:contracts:approve_ceo',
hazardousApprovalOne: 'edr_freight_app:contracts:hazardous_approval_one',
hazardousApprovalTwo: 'edr_freight_app:contracts:hazardous_approval_two',
generateContract: 'edr_freight_app:contracts:generate_contract',
signStaff: {
bulk: 'edr_freight_app:contracts:sign_staff:bulk',