mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1339 from Tria-plc/freight_feature/usermanagement
feat: implement consolidation approval process for shared-wagon bookings
This commit is contained in:
@@ -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 () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user