From db64a1878b43c87df2a88899c5366dc0c306d147 Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 18 Aug 2026 13:59:15 +0000 Subject: [PATCH] feat: implement consolidation approval process for shared-wagon bookings - Add migration for consolidation approvals table and status enum - Create ConsolidationApprovalService to handle approval logic - Implement repository for managing consolidation approvals - Add entity for consolidation approval with necessary fields - Develop frontend components for displaying and managing consolidation approvals - Create tests for consolidation approval service to ensure correct behavior --- .../consolidation-approval.service.spec.ts | 23 +++++++++++-------- .../consolidation-approval.service.ts | 21 ++++------------- .../components/layout/sidebar-sections.tsx | 9 ++++++++ 3 files changed, 27 insertions(+), 26 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.spec.ts index d418fbd3c..5f121f630 100644 --- a/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.spec.ts @@ -11,9 +11,8 @@ import { Booking } from './entities/booking.entity'; * straight to Operations. * * The invariants that matter: both halves are held and released TOGETHER (a - * decision on one side of a shared wagon is meaningless without the other), the - * requester cannot approve their own pairing, and a decided pairing cannot be - * decided twice. + * decision on one side of a shared wagon is meaningless without the other), and + * a decided pairing cannot be decided twice. */ describe('ConsolidationApprovalService', () => { const PENDING = { @@ -154,13 +153,19 @@ describe('ConsolidationApprovalService', () => { }); }); - it('refuses to let the requester approve their own pairing', async () => { - const { service, bookingsRepository } = makeService(); + it('lets the requester approve their own pairing', async () => { + // No maker-checker separation: the permission alone decides who may approve, + // and the audit trail still records requester and approver separately. + const { service, approvals } = makeService(); - await expect( - service.approve('ap-1', 'gl-user'), - ).rejects.toThrow(/must be approved by someone else/i); - expect(bookingsRepository.update).not.toHaveBeenCalled(); + await service.approve('ap-1', 'gl-user'); + + expect(approvals.decide).toHaveBeenCalledWith( + 'ap-1', + ConsolidationApprovalStatus.Approved, + 'gl-user', + undefined, + ); }); it('requires a reason to reject', async () => { diff --git a/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.ts b/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.ts index ed8bdfe7a..ab4f4891d 100644 --- a/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.ts @@ -37,6 +37,10 @@ export const CONSOLIDATION_APPROVAL_PENDING = "CONSOLIDATION_APPROVAL_PENDING"; * decision on one is meaningless without the other. Every request is kept, * decided or not: the table is the audit trail of who approved which pairing, * when, and why. + * + * No maker-checker separation: whoever holds the approve permission may decide a + * pairing, including the GL user who created it. The record of who requested and + * who decided is still kept either way. */ @Injectable() export class ConsolidationApprovalService { @@ -114,7 +118,6 @@ export class ConsolidationApprovalService { note?: string, ): Promise<{ booking: Booking; partner: Booking }> { const approval = await this.loadPending(approvalId); - this.assertDifferentPerson(approval, decidedBy); await this.dataSource.transaction(async () => { const claimed = await this.approvals.decide( @@ -166,7 +169,6 @@ export class ConsolidationApprovalService { ); } const approval = await this.loadPending(approvalId); - this.assertDifferentPerson(approval, decidedBy); await this.dataSource.transaction(async () => { const claimed = await this.approvals.decide( @@ -237,19 +239,4 @@ export class ConsolidationApprovalService { } return approval; } - - /** - * Maker–checker: the point of the gate is a second pair of eyes, so the GL - * user who created the pairing cannot also approve it. - */ - private assertDifferentPerson( - approval: ConsolidationApproval, - decidedBy: string, - ): void { - if (approval.requestedBy && approval.requestedBy === decidedBy) { - throw new BadRequestException( - "You created this consolidation — it must be approved by someone else.", - ); - } - } } diff --git a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx index ba5637821..ff8fa940f 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx @@ -12,6 +12,7 @@ import { Image as ImageIcon, LayoutDashboard, LayoutGrid, + Link2, MapPin, Network, Package, @@ -95,6 +96,14 @@ export const buildSidebarSections = ( icon: , permission: FREIGHT_PERMS.bookings.view, }, + // Shared-wagon gate: a consolidated pair waits for a human decision + // before either half reaches Operations. + { + label: "Shared wagon approvals", + href: "/dashboard/consolidation-approvals", + icon: , + permission: FREIGHT_PERMS.bookings.approveConsolidation, + }, { label: "Wagon cancellations", href: "/dashboard/wagon-cancellations",