mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 02:58:11 +00:00
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:
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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.`;
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user