Merge pull request #1339 from Tria-plc/freight_feature/usermanagement

feat: implement consolidation approval process for shared-wagon bookings
This commit is contained in:
marshal
2026-08-18 17:12:28 +03:00
committed by GitHub
3 changed files with 27 additions and 26 deletions

View File

@@ -11,9 +11,8 @@ import { Booking } from './entities/booking.entity';
* straight to Operations. * straight to Operations.
* *
* The invariants that matter: both halves are held and released TOGETHER (a * 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 * decision on one side of a shared wagon is meaningless without the other), and
* requester cannot approve their own pairing, and a decided pairing cannot be * a decided pairing cannot be decided twice.
* decided twice.
*/ */
describe('ConsolidationApprovalService', () => { describe('ConsolidationApprovalService', () => {
const PENDING = { const PENDING = {
@@ -154,13 +153,19 @@ describe('ConsolidationApprovalService', () => {
}); });
}); });
it('refuses to let the requester approve their own pairing', async () => { it('lets the requester approve their own pairing', async () => {
const { service, bookingsRepository } = makeService(); // 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( await service.approve('ap-1', 'gl-user');
service.approve('ap-1', 'gl-user'),
).rejects.toThrow(/must be approved by someone else/i); expect(approvals.decide).toHaveBeenCalledWith(
expect(bookingsRepository.update).not.toHaveBeenCalled(); 'ap-1',
ConsolidationApprovalStatus.Approved,
'gl-user',
undefined,
);
}); });
it('requires a reason to reject', async () => { it('requires a reason to reject', async () => {

View File

@@ -37,6 +37,10 @@ export const CONSOLIDATION_APPROVAL_PENDING = "CONSOLIDATION_APPROVAL_PENDING";
* decision on one is meaningless without the other. Every request is kept, * 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, * decided or not: the table is the audit trail of who approved which pairing,
* when, and why. * 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() @Injectable()
export class ConsolidationApprovalService { export class ConsolidationApprovalService {
@@ -114,7 +118,6 @@ export class ConsolidationApprovalService {
note?: string, note?: string,
): Promise<{ booking: Booking; partner: Booking }> { ): Promise<{ booking: Booking; partner: Booking }> {
const approval = await this.loadPending(approvalId); const approval = await this.loadPending(approvalId);
this.assertDifferentPerson(approval, decidedBy);
await this.dataSource.transaction(async () => { await this.dataSource.transaction(async () => {
const claimed = await this.approvals.decide( const claimed = await this.approvals.decide(
@@ -166,7 +169,6 @@ export class ConsolidationApprovalService {
); );
} }
const approval = await this.loadPending(approvalId); const approval = await this.loadPending(approvalId);
this.assertDifferentPerson(approval, decidedBy);
await this.dataSource.transaction(async () => { await this.dataSource.transaction(async () => {
const claimed = await this.approvals.decide( const claimed = await this.approvals.decide(
@@ -237,19 +239,4 @@ export class ConsolidationApprovalService {
} }
return approval; return approval;
} }
/**
* Makerchecker: 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.",
);
}
}
} }

View File

@@ -12,6 +12,7 @@ import {
Image as ImageIcon, Image as ImageIcon,
LayoutDashboard, LayoutDashboard,
LayoutGrid, LayoutGrid,
Link2,
MapPin, MapPin,
Network, Network,
Package, Package,
@@ -96,6 +97,14 @@ export const buildSidebarSections = (
icon: <FileText />, icon: <FileText />,
permission: FREIGHT_PERMS.bookings.view, 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: <Link2 />,
permission: FREIGHT_PERMS.bookings.approveConsolidation,
},
{ {
label: "Wagon cancellations", label: "Wagon cancellations",
href: "/dashboard/wagon-cancellations", href: "/dashboard/wagon-cancellations",