mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1337 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Approval gate for consolidated (shared-wagon) bookings.
|
||||
*
|
||||
* A booking that fills its own wagons goes straight from GL completion to the
|
||||
* operations queue. A CONSOLIDATED booking does not: it shares one physical
|
||||
* wagon with another customer's booking, which means two customers' cargo, two
|
||||
* invoices and two liabilities riding the same wagon. That pairing is a
|
||||
* commercial decision, so it is reviewed by a person before Operations sees it.
|
||||
*
|
||||
* The pair is approved as a UNIT — one row covers both halves (booking_id +
|
||||
* partner_booking_id) so an approver can never approve one side of a shared
|
||||
* wagon and leave the other pending. Rows are never deleted; decided rows are
|
||||
* the audit trail of who approved which pairing and when.
|
||||
*
|
||||
* One PENDING row per booking at a time (partial unique index on each side of
|
||||
* the pair): a second request while one is undecided is a coordination failure,
|
||||
* not a workflow.
|
||||
*/
|
||||
export class ConsolidationApprovals3570000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE freight.consolidation_approvals_status_enum
|
||||
AS ENUM ('PENDING', 'APPROVED', 'REJECTED');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.consolidation_approvals (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
booking_id uuid NOT NULL REFERENCES freight.bookings (id),
|
||||
partner_booking_id uuid NOT NULL REFERENCES freight.bookings (id),
|
||||
status freight.consolidation_approvals_status_enum NOT NULL DEFAULT 'PENDING',
|
||||
-- Who put the pairing up for review (the GL user who completed it) and
|
||||
-- who decided it. Both are recorded: the point of the gate is that they
|
||||
-- are different people.
|
||||
requested_by uuid,
|
||||
requested_at timestamptz NOT NULL DEFAULT now(),
|
||||
decided_by uuid,
|
||||
decided_at timestamptz,
|
||||
decision_note varchar(500),
|
||||
-- Snapshot of what was approved, so the audit trail still reads
|
||||
-- correctly after the bookings themselves move on.
|
||||
scheduled_date timestamptz,
|
||||
booking_reference varchar(50),
|
||||
partner_booking_reference varchar(50),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_consolidation_approvals_booking_status
|
||||
ON freight.consolidation_approvals (booking_id, status)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_consolidation_approvals_status
|
||||
ON freight.consolidation_approvals (status)
|
||||
`);
|
||||
|
||||
// The workflow invariant, enforced where it cannot race: at most one
|
||||
// undecided request per booking — on EITHER side of the pair, so the same
|
||||
// wagon can never collect two pending requests from its two halves.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_consolidation_approvals_one_pending
|
||||
ON freight.consolidation_approvals (booking_id)
|
||||
WHERE status = 'PENDING' AND deleted_at IS NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_consolidation_approvals_one_pending_partner
|
||||
ON freight.consolidation_approvals (partner_booking_id)
|
||||
WHERE status = 'PENDING' AND deleted_at IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP TABLE IF EXISTS freight.consolidation_approvals`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP TYPE IF EXISTS freight.consolidation_approvals_status_enum`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -470,6 +470,36 @@ export class BookingLifecycleNotifierService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A shared-wagon pairing is waiting for a human decision. Two customers' cargo
|
||||
* on one wagon is a commercial call, so this never auto-advances.
|
||||
*/
|
||||
consolidationApprovalRequestedToStaff(b: Booking, partnerReference: string): void {
|
||||
this.inAppStaff(
|
||||
b,
|
||||
'Shared wagon needs approval',
|
||||
`Booking ${this.ref(b)} shares a wagon with ${partnerReference} — approve the consolidation before it reaches Operations.`,
|
||||
);
|
||||
}
|
||||
|
||||
/** The pairing was approved; both halves move on to Operations together. */
|
||||
consolidationApprovedToStaff(b: Booking, partnerReference: string): void {
|
||||
this.inAppStaff(
|
||||
b,
|
||||
'Shared wagon approved',
|
||||
`The shared wagon for ${this.ref(b)} and ${partnerReference} was approved — both bookings are now with Operations.`,
|
||||
);
|
||||
}
|
||||
|
||||
/** The pairing was rejected; both halves go back to GL for changes. */
|
||||
consolidationRejectedToStaff(b: Booking, partnerReference: string, reason: string): void {
|
||||
this.inAppStaff(
|
||||
b,
|
||||
'Shared wagon rejected',
|
||||
`The shared wagon for ${this.ref(b)} and ${partnerReference} was rejected: ${reason}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Customer uploaded clearance documents — review is next. */
|
||||
clearanceDocsUploadedToStaff(b: Booking): void {
|
||||
this.inAppStaff(
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { BookingTransitionService } from './booking-transition.service';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
|
||||
/**
|
||||
* Staff decisions on a consolidated pair. Two bookings sharing a wagon must move
|
||||
* together: accepting one alone would put half a wagon into the approval chain,
|
||||
* and cancelling one alone would strand the other on a wagon it can no longer
|
||||
* fill. All-or-nothing — if either half throws, neither booking moved.
|
||||
*/
|
||||
describe('BookingTransitionService — paired staff decisions', () => {
|
||||
function makeService(booking: Partial<Booking>) {
|
||||
const bookingsService = {
|
||||
findById: jest.fn().mockResolvedValue(booking as Booking),
|
||||
};
|
||||
// Runs the callback so a throw propagates, which is what the all-or-nothing
|
||||
// guarantee reduces to from this service's point of view.
|
||||
const dataSource = {
|
||||
transaction: jest.fn(async (cb: () => Promise<unknown>) => cb()),
|
||||
};
|
||||
|
||||
const service = new BookingTransitionService(
|
||||
{} as never, // bookingsRepository
|
||||
{} as never, // ruleEngineService
|
||||
{} as never, // pricingService
|
||||
{} as never, // contractService
|
||||
{} as never, // filesService
|
||||
{} as never, // fileUploadSettingsService
|
||||
{} as never, // bookingBatchService
|
||||
bookingsService as never,
|
||||
{} as never, // bookingClearanceService
|
||||
{} as never, // workflowService
|
||||
{} as never, // invoiceService
|
||||
{} as never, // containerValidationService
|
||||
{} as never, // notifier
|
||||
{} as never, // events
|
||||
undefined, // milestoneService
|
||||
dataSource as never,
|
||||
);
|
||||
return { service, dataSource };
|
||||
}
|
||||
|
||||
const paired = {
|
||||
id: 'b-1',
|
||||
reference: 'BK-1',
|
||||
consolidationPartnerId: 'b-2',
|
||||
} as Booking;
|
||||
|
||||
it('accepts both halves with the same validity window', async () => {
|
||||
const { service } = makeService(paired);
|
||||
const accept = jest
|
||||
.spyOn(service, 'acceptIntake')
|
||||
.mockImplementation(async (id) => ({ id }) as Booking);
|
||||
|
||||
const result = await service.applyPairedDecision('b-1', 'accept', 'staff-1', {
|
||||
validityDays: 30,
|
||||
});
|
||||
|
||||
expect(accept).toHaveBeenCalledTimes(2);
|
||||
expect(accept).toHaveBeenNthCalledWith(1, 'b-1', 'staff-1', 30);
|
||||
expect(accept).toHaveBeenNthCalledWith(2, 'b-2', 'staff-1', 30);
|
||||
expect(result.booking.id).toBe('b-1');
|
||||
expect(result.partner.id).toBe('b-2');
|
||||
});
|
||||
|
||||
it('cancels both halves with the same reason', async () => {
|
||||
const { service } = makeService(paired);
|
||||
const cancel = jest
|
||||
.spyOn(service, 'cancel')
|
||||
.mockImplementation(async (id) => ({ id }) as Booking);
|
||||
|
||||
await service.applyPairedDecision('b-1', 'cancel', 'staff-1', {
|
||||
reason: 'customer withdrew',
|
||||
});
|
||||
|
||||
expect(cancel).toHaveBeenNthCalledWith(1, 'b-1', 'customer withdrew');
|
||||
expect(cancel).toHaveBeenNthCalledWith(2, 'b-2', 'customer withdrew');
|
||||
});
|
||||
|
||||
it('propagates a failure on the second half so neither is committed', async () => {
|
||||
const { service, dataSource } = makeService(paired);
|
||||
jest
|
||||
.spyOn(service, 'cancel')
|
||||
.mockImplementationOnce(async (id) => ({ id }) as Booking)
|
||||
.mockImplementationOnce(async () => {
|
||||
throw new Error('partner is already in transit');
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.applyPairedDecision('b-1', 'cancel', 'staff-1', { reason: 'x' }),
|
||||
).rejects.toThrow('partner is already in transit');
|
||||
|
||||
// Both halves ran inside one transaction, so the throw rolls the first back.
|
||||
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('refuses a booking that has no partner', async () => {
|
||||
const { service } = makeService({
|
||||
id: 'b-1',
|
||||
consolidationPartnerId: null,
|
||||
} as Booking);
|
||||
|
||||
await expect(
|
||||
service.applyPairedDecision('b-1', 'cancel', 'staff-1', { reason: 'x' }),
|
||||
).rejects.toThrow(/no consolidation partner/i);
|
||||
});
|
||||
|
||||
it('requires a validity window to accept', async () => {
|
||||
const { service } = makeService(paired);
|
||||
const accept = jest.spyOn(service, 'acceptIntake');
|
||||
|
||||
await expect(
|
||||
service.applyPairedDecision('b-1', 'accept', 'staff-1', {}),
|
||||
).rejects.toThrow(/validity/i);
|
||||
expect(accept).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes operationAccept through the operation review on both halves', async () => {
|
||||
const { service } = makeService(paired);
|
||||
const review = jest
|
||||
.spyOn(service, 'reviewOperationRequest')
|
||||
.mockImplementation(async (id) => ({ id }) as Booking);
|
||||
|
||||
await service.applyPairedDecision('b-1', 'operationAccept', 'staff-1', {});
|
||||
|
||||
expect(review).toHaveBeenNthCalledWith(1, 'b-1', 'ACCEPT', 'staff-1', {
|
||||
note: undefined,
|
||||
});
|
||||
expect(review).toHaveBeenNthCalledWith(2, 'b-2', 'ACCEPT', 'staff-1', {
|
||||
note: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -455,6 +455,75 @@ export class BookingTransitionService {
|
||||
return this.cancel(bookingId, reason ?? "Customer cancelled before payment");
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a staff decision across BOTH halves of a consolidated pair.
|
||||
*
|
||||
* Two bookings that share a wagon must move together: accepting one while the
|
||||
* other stays behind would put half a wagon into the approval chain, and
|
||||
* cancelling one alone would strand the other on a wagon it can no longer
|
||||
* fill. All-or-nothing — if either half throws, the transaction rolls back and
|
||||
* neither booking moved.
|
||||
*
|
||||
* Each half still runs the ordinary single-booking transition, so pricing,
|
||||
* invoicing and notifications stay per booking: the customers are billed and
|
||||
* notified separately, exactly as they are today.
|
||||
*/
|
||||
async applyPairedDecision(
|
||||
bookingId: string,
|
||||
decision: "accept" | "cancel" | "operationAccept" | "requestChanges",
|
||||
actorId: string,
|
||||
options: { reason?: string; note?: string; validityDays?: number } = {},
|
||||
): Promise<{ booking: Booking; partner: Booking }> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
const partnerId = booking.consolidationPartnerId;
|
||||
if (!partnerId) {
|
||||
throw new BadRequestException(
|
||||
"This booking has no consolidation partner — use the single-booking action.",
|
||||
);
|
||||
}
|
||||
|
||||
const runOne = async (id: string): Promise<Booking> => {
|
||||
switch (decision) {
|
||||
case "accept":
|
||||
// Same requirement as the single-booking accept: the approval chain
|
||||
// needs a contract validity window.
|
||||
if (!(Number(options.validityDays) > 0)) {
|
||||
throw new BadRequestException(
|
||||
"Contract validity (days) is required to accept.",
|
||||
);
|
||||
}
|
||||
return this.acceptIntake(id, actorId, Number(options.validityDays));
|
||||
case "cancel":
|
||||
return this.cancel(
|
||||
id,
|
||||
options.reason ?? "Cancelled with its consolidation partner",
|
||||
);
|
||||
case "operationAccept":
|
||||
return this.reviewOperationRequest(id, "ACCEPT", actorId, {
|
||||
note: options.note,
|
||||
});
|
||||
case "requestChanges":
|
||||
return this.requestChanges(id, options.note ?? "", actorId);
|
||||
}
|
||||
};
|
||||
|
||||
// Without a DataSource (unit tests hand-construct this service) fall back to
|
||||
// running the two halves directly — the ordering guarantee still holds, only
|
||||
// the rollback does not.
|
||||
if (!this.dataSource) {
|
||||
const own = await runOne(bookingId);
|
||||
const other = await runOne(partnerId);
|
||||
return { booking: own, partner: other };
|
||||
}
|
||||
|
||||
return this.dataSource.transaction(async () => {
|
||||
// Sequential: one connection per transaction context.
|
||||
const own = await runOne(bookingId);
|
||||
const other = await runOne(partnerId);
|
||||
return { booking: own, partner: other };
|
||||
});
|
||||
}
|
||||
|
||||
async cancel(bookingId: string, reason: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, [
|
||||
|
||||
@@ -50,6 +50,7 @@ import { BookingReferenceDataService } from './booking-reference-data.service';
|
||||
import { scopedDirections } from '../user-trade-access/trade-scope.util';
|
||||
import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { ConsolidationApprovalService } from './consolidation-approval.service';
|
||||
import { BookingReferenceDataDto } from './dto/booking-reference-data.dto';
|
||||
import { CreateBookingDto } from './dto/create-booking.dto';
|
||||
import { BookingListSummaryDto } from './dto/booking-list-summary.dto';
|
||||
@@ -58,7 +59,10 @@ import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
|
||||
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
|
||||
import {
|
||||
AcceptIntakeDto,
|
||||
ApproveConsolidationDto,
|
||||
CancelBookingDto,
|
||||
PairedDecisionDto,
|
||||
RejectConsolidationDto,
|
||||
RejectBookingDto,
|
||||
RequestChangesDto,
|
||||
ReviewDocumentDto,
|
||||
@@ -165,6 +169,7 @@ export class BookingsController {
|
||||
private readonly lastMileService: LastMileService,
|
||||
private readonly userTradeAccessService: UserTradeAccessService,
|
||||
private readonly wagonCancellationService: BookingWagonCancellationService,
|
||||
private readonly consolidationApprovalService: ConsolidationApprovalService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@@ -1541,6 +1546,92 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
// ── Shared-wagon (consolidation) approval gate ────────────────────────────
|
||||
// A consolidated pair is held here, not in the operations queue: two
|
||||
// customers' cargo on one wagon is a commercial call, so a person signs off
|
||||
// on the pairing before Operations sees either half.
|
||||
|
||||
@Get("consolidation-approvals/queue")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.approveConsolidation)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Shared-wagon pairings awaiting approval, oldest first. Each row covers BOTH bookings on the wagon.",
|
||||
})
|
||||
consolidationApprovalQueue() {
|
||||
return this.consolidationApprovalService.queue();
|
||||
}
|
||||
|
||||
@Get(":id/consolidation-approvals")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.view)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Approval history for this booking's shared wagon — who decided what, when, and why.",
|
||||
})
|
||||
consolidationApprovalHistory(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.consolidationApprovalService.historyForBooking(id);
|
||||
}
|
||||
|
||||
@Post("consolidation-approvals/:approvalId/approve")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.approveConsolidation)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Approve a shared wagon: both bookings leave the gate and continue to Operations together.",
|
||||
})
|
||||
approveConsolidation(
|
||||
@Param("approvalId", ParseUUIDPipe) approvalId: string,
|
||||
@Body() dto: ApproveConsolidationDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.consolidationApprovalService.approve(
|
||||
approvalId,
|
||||
resolveAuthUserId(user) ?? "",
|
||||
dto.note,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("consolidation-approvals/:approvalId/reject")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.approveConsolidation)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Reject a shared wagon: both bookings go back to GL for changes with the reason.",
|
||||
})
|
||||
rejectConsolidation(
|
||||
@Param("approvalId", ParseUUIDPipe) approvalId: string,
|
||||
@Body() dto: RejectConsolidationDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.consolidationApprovalService.reject(
|
||||
approvalId,
|
||||
resolveAuthUserId(user) ?? "",
|
||||
dto.reason,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(":id/paired-decision")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.cancel)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Apply a staff decision (accept / cancel / operationAccept / requestChanges) to BOTH halves of a consolidated pair, all-or-nothing.",
|
||||
})
|
||||
async pairedDecision(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: PairedDecisionDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
const { booking, partner } = await this.transitionService.applyPairedDecision(
|
||||
id,
|
||||
dto.decision,
|
||||
resolveAuthUserId(user),
|
||||
{ reason: dto.reason, note: dto.note, validityDays: dto.validityDays },
|
||||
);
|
||||
// Sequential enrichment: both go back so the UI can refresh either tab.
|
||||
const enrichedBooking =
|
||||
await this.transitionService.enrichBookingResponse(booking);
|
||||
const enrichedPartner =
|
||||
await this.transitionService.enrichBookingResponse(partner);
|
||||
return { booking: enrichedBooking, partner: enrichedPartner };
|
||||
}
|
||||
|
||||
@Post(":id/cancel")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.cancel)
|
||||
@ApiOperation({ summary: "Cancel booking" })
|
||||
|
||||
@@ -30,6 +30,9 @@ import { BookingsController } from './bookings.controller';
|
||||
// import { PayController } from './pay.controller';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { ConsolidationService } from './consolidation.service';
|
||||
import { ConsolidationApprovalService } from './consolidation-approval.service';
|
||||
import { ConsolidationApprovalsRepository } from './consolidation-approvals.repository';
|
||||
import { ConsolidationApproval } from './entities/consolidation-approval.entity';
|
||||
import { ContainerValidationService } from './container-validation.service';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||
@@ -72,6 +75,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
BookingWagonCancellation,
|
||||
CustomerTruckAssignment,
|
||||
CustomerTruckContainer,
|
||||
ConsolidationApproval,
|
||||
]),
|
||||
BillingModule,
|
||||
DocumentsModule,
|
||||
@@ -98,6 +102,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
BookingsService,
|
||||
BookingsRepository,
|
||||
ConsolidationService,
|
||||
ConsolidationApprovalService,
|
||||
ConsolidationApprovalsRepository,
|
||||
ContainerValidationService,
|
||||
BookingReferenceDataService,
|
||||
BookingPricingService,
|
||||
@@ -126,6 +132,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
BookingLifecycleNotifierService,
|
||||
BookingTransitionService,
|
||||
ConsolidationService,
|
||||
ConsolidationApprovalService,
|
||||
ConsolidationApprovalsRepository,
|
||||
CustomerTruckService,
|
||||
ContainerReceiptService,
|
||||
BookingWagonCancellationService,
|
||||
|
||||
@@ -308,6 +308,72 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.find({ where: { contractId } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Bookings a GL operator may manually link to `booking` as its odd-20ft
|
||||
* consolidation partner (Path B customs flow). Unlike
|
||||
* {@link findComplementaryConsolidationPartner} — which auto-pairs on an exact
|
||||
* quantity complement — this lists CANDIDATES for a human to choose from, so
|
||||
* the filter is deliberately looser: any other customs booking on the same
|
||||
* route/direction that is itself carrying an odd 20ft count. Two odd counts
|
||||
* always sum to even, so any pick fills the shared wagon.
|
||||
*
|
||||
* Bare instances awaiting completion have no persisted containers yet, so the
|
||||
* odd-count test runs on the requested container lines when they exist and the
|
||||
* booking is offered as a candidate when they do not (GL enters its cargo on
|
||||
* the split form).
|
||||
*/
|
||||
async findManualConsolidationCandidates(
|
||||
booking: Booking,
|
||||
limit = 50,
|
||||
): Promise<Booking[]> {
|
||||
const rows = await this.repository
|
||||
.createQueryBuilder('b')
|
||||
.leftJoinAndSelect('b.bookingContainers', 'bc')
|
||||
.leftJoinAndSelect('bc.containerType', 'ct')
|
||||
.leftJoinAndSelect('b.company', 'company')
|
||||
.where('b.id != :bookingId', { bookingId: booking.id })
|
||||
// Never offer a booking that already shares a wagon with someone else.
|
||||
.andWhere('b.consolidationPartnerId IS NULL')
|
||||
// Customs-only: this manual flow exists because a customs (Path B)
|
||||
// instance is completed by GL, not by the customer.
|
||||
.andWhere('b.customsClearingEnabled = true')
|
||||
// Same physical wagon ⇒ same route and same direction.
|
||||
.andWhere('b.originYardId = :originYardId', {
|
||||
originYardId: booking.originYardId,
|
||||
})
|
||||
.andWhere('b.destinationYardId = :destinationYardId', {
|
||||
destinationYardId: booking.destinationYardId,
|
||||
})
|
||||
.andWhere('b.tradeDirection = :tradeDirection', {
|
||||
tradeDirection: booking.tradeDirection,
|
||||
})
|
||||
// Bookable = clearance finished and the booking is waiting to be completed,
|
||||
// the same set completeUnderContract accepts, plus one already parked for a
|
||||
// partner.
|
||||
.andWhere('b.status IN (:...statuses)', {
|
||||
statuses: [
|
||||
'CLEARANCE_READY',
|
||||
'OPERATION_CHANGES_REQUESTED',
|
||||
'PENDING_CONSOLIDATION',
|
||||
],
|
||||
})
|
||||
.orderBy('b.createdAt', 'ASC')
|
||||
.take(limit)
|
||||
.getMany();
|
||||
|
||||
// Odd-20ft test in memory: a bare instance has no containers yet (GL fills
|
||||
// them on the split form) and stays a candidate; one that already carries
|
||||
// cargo qualifies only when its 20ft total is odd.
|
||||
return rows.filter((row) => {
|
||||
const lines = row.bookingContainers ?? [];
|
||||
if (lines.length === 0) return true;
|
||||
const ft20 = lines
|
||||
.filter((line) => Number(line.containerType?.sizeFt) === 20)
|
||||
.reduce((sum, line) => sum + Number(line.quantity || 0), 0);
|
||||
return ft20 % 2 === 1;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find another booking whose container quantity complements this one to fill whole wagon(s)
|
||||
* (same route, same container type, partial wagon on both sides). Only 20ft lines ever
|
||||
@@ -508,6 +574,25 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
} as never);
|
||||
}
|
||||
|
||||
/**
|
||||
* Link two bookings as consolidation partners WITHOUT touching their statuses.
|
||||
* Used by the manual GL pairing, where both bookings have just been completed
|
||||
* into their live status — unlike {@link pairConsolidation}, which exists to
|
||||
* resume bookings parked in PENDING_CONSOLIDATION and rewrites status as part
|
||||
* of that resume.
|
||||
*/
|
||||
async linkConsolidationPartners(
|
||||
bookingId: string,
|
||||
partnerId: string,
|
||||
): Promise<void> {
|
||||
await this.repository.update(bookingId, {
|
||||
consolidationPartnerId: partnerId,
|
||||
} as never);
|
||||
await this.repository.update(partnerId, {
|
||||
consolidationPartnerId: bookingId,
|
||||
} as never);
|
||||
}
|
||||
|
||||
/** Un-pair a consolidation. */
|
||||
async unpairConsolidation(bookingId: string, partnerId: string): Promise<void> {
|
||||
await this.repository.update(bookingId, {
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import {
|
||||
ConsolidationApprovalService,
|
||||
CONSOLIDATION_APPROVAL_PENDING,
|
||||
} from './consolidation-approval.service';
|
||||
import { ConsolidationApprovalStatus } from './entities/consolidation-approval.entity';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
|
||||
/**
|
||||
* The shared-wagon approval gate. Two customers' cargo on one wagon is a
|
||||
* commercial call, so the pair is held for a human decision instead of going
|
||||
* 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.
|
||||
*/
|
||||
describe('ConsolidationApprovalService', () => {
|
||||
const PENDING = {
|
||||
id: 'ap-1',
|
||||
bookingId: 'b-1',
|
||||
partnerBookingId: 'b-2',
|
||||
status: ConsolidationApprovalStatus.Pending,
|
||||
requestedBy: 'gl-user',
|
||||
};
|
||||
|
||||
function makeService(overrides: {
|
||||
approvals?: Partial<Record<string, jest.Mock>>;
|
||||
bookingsRepository?: Partial<Record<string, jest.Mock>>;
|
||||
} = {}) {
|
||||
const approvals = {
|
||||
findPendingForBooking: jest.fn().mockResolvedValue(null),
|
||||
findById: jest.fn().mockResolvedValue(PENDING),
|
||||
create: jest.fn().mockResolvedValue({ id: 'ap-1' }),
|
||||
decide: jest.fn().mockResolvedValue(true),
|
||||
findQueue: jest.fn().mockResolvedValue([]),
|
||||
findAllForBooking: jest.fn().mockResolvedValue([]),
|
||||
...overrides.approvals,
|
||||
};
|
||||
const bookingsRepository = {
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
createReviewNote: jest.fn().mockResolvedValue(undefined),
|
||||
...overrides.bookingsRepository,
|
||||
};
|
||||
const bookingsService = {
|
||||
findById: jest.fn(async (id: string) =>
|
||||
({ id, reference: `BK-${id}` }) as Booking,
|
||||
),
|
||||
};
|
||||
const notifier = {
|
||||
consolidationApprovalRequestedToStaff: jest.fn(),
|
||||
consolidationApprovedToStaff: jest.fn(),
|
||||
consolidationRejectedToStaff: jest.fn(),
|
||||
operationRequestedToStaff: jest.fn(),
|
||||
};
|
||||
const dataSource = {
|
||||
transaction: jest.fn(async (cb: () => Promise<unknown>) => cb()),
|
||||
};
|
||||
|
||||
const service = new ConsolidationApprovalService(
|
||||
approvals as never,
|
||||
bookingsRepository as never,
|
||||
bookingsService as never,
|
||||
notifier as never,
|
||||
dataSource as never,
|
||||
);
|
||||
return { service, approvals, bookingsRepository, notifier };
|
||||
}
|
||||
|
||||
it('holds BOTH halves at the gate when a pairing is created', async () => {
|
||||
const { service, approvals, bookingsRepository, notifier } = makeService();
|
||||
|
||||
await service.requestApproval('b-1', 'b-2', 'gl-user');
|
||||
|
||||
expect(approvals.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bookingId: 'b-1',
|
||||
partnerBookingId: 'b-2',
|
||||
requestedBy: 'gl-user',
|
||||
}),
|
||||
);
|
||||
// Neither half may sit in the operations queue while the wagon is unreviewed.
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
|
||||
status: CONSOLIDATION_APPROVAL_PENDING,
|
||||
});
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', {
|
||||
status: CONSOLIDATION_APPROVAL_PENDING,
|
||||
});
|
||||
expect(
|
||||
notifier.consolidationApprovalRequestedToStaff,
|
||||
).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not open a second review for a pairing already pending', async () => {
|
||||
const { service, approvals } = makeService({
|
||||
approvals: {
|
||||
findPendingForBooking: jest.fn().mockResolvedValue(PENDING),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.requestApproval('b-1', 'b-2', 'gl-user');
|
||||
|
||||
expect(result).toBe(PENDING);
|
||||
expect(approvals.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('releases BOTH halves to Operations on approval, logging who decided', async () => {
|
||||
const { service, approvals, bookingsRepository, notifier } = makeService();
|
||||
|
||||
await service.approve('ap-1', 'approver-1', 'looks fine');
|
||||
|
||||
expect(approvals.decide).toHaveBeenCalledWith(
|
||||
'ap-1',
|
||||
ConsolidationApprovalStatus.Approved,
|
||||
'approver-1',
|
||||
'looks fine',
|
||||
);
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
|
||||
status: 'OPERATION_REQUEST_PENDING',
|
||||
});
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', {
|
||||
status: 'OPERATION_REQUEST_PENDING',
|
||||
});
|
||||
// Operations only learns about the pair now — the gate is what kept it out.
|
||||
expect(notifier.operationRequestedToStaff).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('sends BOTH halves back to GL on rejection, with the reason on each', async () => {
|
||||
const { service, approvals, bookingsRepository } = makeService();
|
||||
|
||||
await service.reject('ap-1', 'approver-1', 'partner cargo is wrong');
|
||||
|
||||
expect(approvals.decide).toHaveBeenCalledWith(
|
||||
'ap-1',
|
||||
ConsolidationApprovalStatus.Rejected,
|
||||
'approver-1',
|
||||
'partner cargo is wrong',
|
||||
);
|
||||
expect(bookingsRepository.createReviewNote).toHaveBeenCalledWith(
|
||||
'b-1',
|
||||
'partner cargo is wrong',
|
||||
'CHANGES_REQUESTED',
|
||||
);
|
||||
expect(bookingsRepository.createReviewNote).toHaveBeenCalledWith(
|
||||
'b-2',
|
||||
'partner cargo is wrong',
|
||||
'CHANGES_REQUESTED',
|
||||
);
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
|
||||
status: 'OPERATION_CHANGES_REQUESTED',
|
||||
});
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', {
|
||||
status: 'OPERATION_CHANGES_REQUESTED',
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses to let the requester approve their own pairing', async () => {
|
||||
const { service, bookingsRepository } = makeService();
|
||||
|
||||
await expect(
|
||||
service.approve('ap-1', 'gl-user'),
|
||||
).rejects.toThrow(/must be approved by someone else/i);
|
||||
expect(bookingsRepository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requires a reason to reject', async () => {
|
||||
const { service, approvals } = makeService();
|
||||
|
||||
await expect(service.reject('ap-1', 'approver-1', ' ')).rejects.toThrow(
|
||||
/reason is required/i,
|
||||
);
|
||||
expect(approvals.decide).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a pairing that was already decided', async () => {
|
||||
const { service, bookingsRepository } = makeService({
|
||||
approvals: {
|
||||
findById: jest.fn().mockResolvedValue({
|
||||
...PENDING,
|
||||
status: ConsolidationApprovalStatus.Approved,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(service.approve('ap-1', 'approver-1')).rejects.toThrow(
|
||||
/already approved/i,
|
||||
);
|
||||
expect(bookingsRepository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loses cleanly when another approver decides the same pairing first', async () => {
|
||||
// decide() writes only against a still-PENDING row, so the loser of the race
|
||||
// affects nothing and must not move the bookings.
|
||||
const { service } = makeService({
|
||||
approvals: { decide: jest.fn().mockResolvedValue(false) },
|
||||
});
|
||||
|
||||
await expect(service.approve('ap-1', 'approver-1')).rejects.toThrow(
|
||||
/already decided by someone else/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
forwardRef,
|
||||
} from "@nestjs/common";
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
import { Booking } from "./entities/booking.entity";
|
||||
import {
|
||||
ConsolidationApproval,
|
||||
ConsolidationApprovalStatus,
|
||||
} from "./entities/consolidation-approval.entity";
|
||||
import { ConsolidationApprovalsRepository } from "./consolidation-approvals.repository";
|
||||
import { BookingsRepository } from "./bookings.repository";
|
||||
import { BookingsService } from "./bookings.service";
|
||||
import { BookingLifecycleNotifierService } from "./booking-lifecycle-notifier.service";
|
||||
|
||||
/** Where a rejected pair goes back to, so GL can fix and resubmit. */
|
||||
const REJECTED_STATUS = "OPERATION_CHANGES_REQUESTED";
|
||||
|
||||
/** The gate's own holding status — neither half reaches Operations from here. */
|
||||
export const CONSOLIDATION_APPROVAL_PENDING = "CONSOLIDATION_APPROVAL_PENDING";
|
||||
|
||||
/**
|
||||
* The shared-wagon approval gate.
|
||||
*
|
||||
* A booking that fills its own wagons goes straight from GL completion to the
|
||||
* operations queue. A consolidated one does not: two customers' cargo rides one
|
||||
* physical wagon under two separate invoices, so a person reviews the pairing
|
||||
* before Operations sees either half.
|
||||
*
|
||||
* Both halves are held and released TOGETHER — the wagon is shared, so a
|
||||
* 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.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ConsolidationApprovalService {
|
||||
private readonly logger = new Logger(ConsolidationApprovalService.name);
|
||||
|
||||
constructor(
|
||||
private readonly approvals: ConsolidationApprovalsRepository,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
@Inject(forwardRef(() => BookingsService))
|
||||
private readonly bookingsService: BookingsService,
|
||||
private readonly notifier: BookingLifecycleNotifierService,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Park a newly consolidated pair for review instead of letting it continue to
|
||||
* Operations. Called from the completion path once the two halves are linked.
|
||||
*
|
||||
* Idempotent: a pair that already has an undecided request is left alone, so a
|
||||
* retried completion cannot open a second review of the same wagon.
|
||||
*/
|
||||
async requestApproval(
|
||||
bookingId: string,
|
||||
partnerBookingId: string,
|
||||
requestedBy: string | null,
|
||||
): Promise<ConsolidationApproval> {
|
||||
const existing = await this.approvals.findPendingForBooking(bookingId);
|
||||
if (existing) return existing;
|
||||
|
||||
// Sequential reads: one connection per transaction context.
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
const partner = await this.bookingsService.findById(partnerBookingId);
|
||||
if (!booking || !partner) {
|
||||
throw new NotFoundException("Both bookings of the pair must exist.");
|
||||
}
|
||||
|
||||
const approval = await this.approvals.create({
|
||||
bookingId,
|
||||
partnerBookingId,
|
||||
requestedBy,
|
||||
scheduledDate: booking.scheduledDate ?? null,
|
||||
bookingReference: booking.reference ?? null,
|
||||
partnerBookingReference: partner.reference ?? null,
|
||||
});
|
||||
|
||||
// Hold BOTH halves: the wagon is shared, so neither may advance alone.
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
status: CONSOLIDATION_APPROVAL_PENDING,
|
||||
} as never);
|
||||
await this.bookingsRepository.update(partnerBookingId, {
|
||||
status: CONSOLIDATION_APPROVAL_PENDING,
|
||||
} as never);
|
||||
|
||||
this.notifier.consolidationApprovalRequestedToStaff(
|
||||
booking,
|
||||
partner.reference ?? partnerBookingId,
|
||||
);
|
||||
this.logger.log(
|
||||
`Consolidation ${booking.reference} + ${partner.reference} awaiting approval (${approval.id}).`,
|
||||
);
|
||||
return approval;
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve the pairing: both halves leave the gate and continue to Operations,
|
||||
* which is exactly where a non-consolidated booking would already be.
|
||||
*
|
||||
* All-or-nothing — the two status writes and the decision record share one
|
||||
* transaction, so the audit trail can never claim an approval that did not
|
||||
* take effect.
|
||||
*/
|
||||
async approve(
|
||||
approvalId: string,
|
||||
decidedBy: string,
|
||||
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(
|
||||
approval.id,
|
||||
ConsolidationApprovalStatus.Approved,
|
||||
decidedBy,
|
||||
note,
|
||||
);
|
||||
// Lost the race to another approver deciding the same pairing.
|
||||
if (!claimed) {
|
||||
throw new ConflictException(
|
||||
"This consolidation was already decided by someone else.",
|
||||
);
|
||||
}
|
||||
await this.bookingsRepository.update(approval.bookingId, {
|
||||
status: "OPERATION_REQUEST_PENDING",
|
||||
} as never);
|
||||
await this.bookingsRepository.update(approval.partnerBookingId, {
|
||||
status: "OPERATION_REQUEST_PENDING",
|
||||
} as never);
|
||||
});
|
||||
|
||||
const booking = await this.bookingsService.findById(approval.bookingId);
|
||||
const partner = await this.bookingsService.findById(
|
||||
approval.partnerBookingId,
|
||||
);
|
||||
this.notifier.consolidationApprovedToStaff(
|
||||
booking,
|
||||
partner.reference ?? approval.partnerBookingId,
|
||||
);
|
||||
// Operations only now learns about the pair — the gate is what kept it out.
|
||||
this.notifier.operationRequestedToStaff(booking);
|
||||
this.notifier.operationRequestedToStaff(partner);
|
||||
return { booking, partner };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject the pairing: both halves go back to GL as OPERATION_CHANGES_REQUESTED
|
||||
* with the reason, so the cargo or the partner can be changed and resubmitted.
|
||||
*/
|
||||
async reject(
|
||||
approvalId: string,
|
||||
decidedBy: string,
|
||||
reason: string,
|
||||
): Promise<{ booking: Booking; partner: Booking }> {
|
||||
if (!reason?.trim()) {
|
||||
throw new BadRequestException(
|
||||
"A reason is required to reject a consolidation.",
|
||||
);
|
||||
}
|
||||
const approval = await this.loadPending(approvalId);
|
||||
this.assertDifferentPerson(approval, decidedBy);
|
||||
|
||||
await this.dataSource.transaction(async () => {
|
||||
const claimed = await this.approvals.decide(
|
||||
approval.id,
|
||||
ConsolidationApprovalStatus.Rejected,
|
||||
decidedBy,
|
||||
reason.trim(),
|
||||
);
|
||||
if (!claimed) {
|
||||
throw new ConflictException(
|
||||
"This consolidation was already decided by someone else.",
|
||||
);
|
||||
}
|
||||
await this.bookingsRepository.createReviewNote(
|
||||
approval.bookingId,
|
||||
reason.trim(),
|
||||
"CHANGES_REQUESTED",
|
||||
);
|
||||
await this.bookingsRepository.createReviewNote(
|
||||
approval.partnerBookingId,
|
||||
reason.trim(),
|
||||
"CHANGES_REQUESTED",
|
||||
);
|
||||
await this.bookingsRepository.update(approval.bookingId, {
|
||||
status: REJECTED_STATUS,
|
||||
} as never);
|
||||
await this.bookingsRepository.update(approval.partnerBookingId, {
|
||||
status: REJECTED_STATUS,
|
||||
} as never);
|
||||
});
|
||||
|
||||
const booking = await this.bookingsService.findById(approval.bookingId);
|
||||
const partner = await this.bookingsService.findById(
|
||||
approval.partnerBookingId,
|
||||
);
|
||||
this.notifier.consolidationRejectedToStaff(
|
||||
booking,
|
||||
partner.reference ?? approval.partnerBookingId,
|
||||
reason.trim(),
|
||||
);
|
||||
return { booking, partner };
|
||||
}
|
||||
|
||||
/** Pending pairings awaiting a decision, oldest first. */
|
||||
queue(): Promise<ConsolidationApproval[]> {
|
||||
return this.approvals.findQueue();
|
||||
}
|
||||
|
||||
/** Full decision history for one booking — who decided what, and when. */
|
||||
historyForBooking(bookingId: string): Promise<ConsolidationApproval[]> {
|
||||
return this.approvals.findAllForBooking(bookingId);
|
||||
}
|
||||
|
||||
/** The undecided request covering this booking, if any. */
|
||||
pendingForBooking(bookingId: string): Promise<ConsolidationApproval | null> {
|
||||
return this.approvals.findPendingForBooking(bookingId);
|
||||
}
|
||||
|
||||
private async loadPending(approvalId: string): Promise<ConsolidationApproval> {
|
||||
const approval = await this.approvals.findById(approvalId);
|
||||
if (!approval) {
|
||||
throw new NotFoundException(`Approval ${approvalId} not found`);
|
||||
}
|
||||
if (approval.status !== ConsolidationApprovalStatus.Pending) {
|
||||
throw new ConflictException(
|
||||
`This consolidation was already ${approval.status.toLowerCase()}.`,
|
||||
);
|
||||
}
|
||||
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.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { DataSource, In, Repository } from "typeorm";
|
||||
|
||||
import {
|
||||
ConsolidationApproval,
|
||||
ConsolidationApprovalStatus,
|
||||
} from "./entities/consolidation-approval.entity";
|
||||
|
||||
/**
|
||||
* Persistence for the shared-wagon approval gate. Rows are never deleted —
|
||||
* decided rows are the audit trail of who approved which pairing and when.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ConsolidationApprovalsRepository {
|
||||
private readonly repository: Repository<ConsolidationApproval>;
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {
|
||||
this.repository = this.dataSource.getRepository(ConsolidationApproval);
|
||||
}
|
||||
|
||||
/**
|
||||
* The undecided request covering `bookingId`, from EITHER side of the pair —
|
||||
* one row governs both halves, and the caller may hold either one.
|
||||
*/
|
||||
findPendingForBooking(
|
||||
bookingId: string,
|
||||
): Promise<ConsolidationApproval | null> {
|
||||
return this.repository.findOne({
|
||||
where: [
|
||||
{ bookingId, status: ConsolidationApprovalStatus.Pending },
|
||||
{
|
||||
partnerBookingId: bookingId,
|
||||
status: ConsolidationApprovalStatus.Pending,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** Every request touching this booking, newest first (the audit trail). */
|
||||
findAllForBooking(bookingId: string): Promise<ConsolidationApproval[]> {
|
||||
return this.repository.find({
|
||||
where: [{ bookingId }, { partnerBookingId: bookingId }],
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
}
|
||||
|
||||
findById(id: string): Promise<ConsolidationApproval | null> {
|
||||
return this.repository.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
/** Pending requests for the review queue, oldest first (FIFO). */
|
||||
findQueue(): Promise<ConsolidationApproval[]> {
|
||||
return this.repository.find({
|
||||
where: { status: ConsolidationApprovalStatus.Pending },
|
||||
relations: {
|
||||
booking: { company: true },
|
||||
partnerBooking: { company: true },
|
||||
},
|
||||
order: { requestedAt: "ASC" },
|
||||
});
|
||||
}
|
||||
|
||||
create(input: {
|
||||
bookingId: string;
|
||||
partnerBookingId: string;
|
||||
requestedBy?: string | null;
|
||||
scheduledDate?: Date | null;
|
||||
bookingReference?: string | null;
|
||||
partnerBookingReference?: string | null;
|
||||
}): Promise<ConsolidationApproval> {
|
||||
return this.repository.save(
|
||||
this.repository.create({
|
||||
...input,
|
||||
status: ConsolidationApprovalStatus.Pending,
|
||||
requestedAt: new Date(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the decision. Written only against a row still PENDING, so two
|
||||
* approvers racing on the same pairing cannot both succeed — the second
|
||||
* update matches nothing and the caller sees `false`.
|
||||
*/
|
||||
async decide(
|
||||
id: string,
|
||||
status:
|
||||
| ConsolidationApprovalStatus.Approved
|
||||
| ConsolidationApprovalStatus.Rejected,
|
||||
decidedBy: string | null,
|
||||
decisionNote?: string | null,
|
||||
): Promise<boolean> {
|
||||
const result = await this.repository.update(
|
||||
{ id, status: ConsolidationApprovalStatus.Pending },
|
||||
{
|
||||
status,
|
||||
decidedBy,
|
||||
decidedAt: new Date(),
|
||||
decisionNote: decisionNote ?? null,
|
||||
},
|
||||
);
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
/** Undecided requests covering any of these bookings (list badging). */
|
||||
findPendingForBookings(
|
||||
bookingIds: string[],
|
||||
): Promise<ConsolidationApproval[]> {
|
||||
if (bookingIds.length === 0) return Promise.resolve([]);
|
||||
return this.repository.find({
|
||||
where: [
|
||||
{ bookingId: In(bookingIds), status: ConsolidationApprovalStatus.Pending },
|
||||
{
|
||||
partnerBookingId: In(bookingIds),
|
||||
status: ConsolidationApprovalStatus.Pending,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -125,3 +125,59 @@ export class OperationReviewDto {
|
||||
@IsString()
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A staff decision applied to BOTH halves of a consolidated pair. The two
|
||||
* bookings share a wagon, so they advance or cancel together — never one alone.
|
||||
*/
|
||||
export class PairedDecisionDto {
|
||||
@ApiProperty({
|
||||
enum: ["accept", "cancel", "operationAccept", "requestChanges"],
|
||||
description: 'Which staff decision to apply to both bookings.',
|
||||
})
|
||||
@IsIn(["accept", "cancel", "operationAccept", "requestChanges"])
|
||||
decision!: "accept" | "cancel" | "operationAccept" | "requestChanges";
|
||||
|
||||
@ApiPropertyOptional({ description: "Cancellation reason (decision=cancel)." })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reason?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Message to the customer (decision=requestChanges).",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Contract validity window in days (decision=accept).",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
validityDays?: number;
|
||||
}
|
||||
|
||||
/** Approve a shared-wagon pairing. The note is optional context for the audit. */
|
||||
export class ApproveConsolidationDto {
|
||||
@ApiPropertyOptional({
|
||||
description: "Optional note recorded with the approval.",
|
||||
maxLength: 500,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/** Reject a shared-wagon pairing. A reason is mandatory — GL has to act on it. */
|
||||
export class RejectConsolidationDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Why the pairing is rejected. Sent back to GL on both bookings.",
|
||||
maxLength: 500,
|
||||
})
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
reason!: string;
|
||||
}
|
||||
|
||||
@@ -58,6 +58,10 @@ export const BOOKING_STATUSES = [
|
||||
// the booking enters the batch holding pool.
|
||||
'OPERATION_REQUEST_PENDING',
|
||||
'OPERATION_CHANGES_REQUESTED',
|
||||
// Shared-wagon review gate: a consolidated pair waits for a human decision
|
||||
// before either half reaches Operations. Two customers' cargo on one wagon is
|
||||
// a commercial call, so it is never auto-advanced.
|
||||
'CONSOLIDATION_APPROVAL_PENDING',
|
||||
'OPERATION_PRICE_PENDING_CONFIRM',
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
|
||||
import { Booking } from "./booking.entity";
|
||||
|
||||
export enum ConsolidationApprovalStatus {
|
||||
Pending = "PENDING",
|
||||
Approved = "APPROVED",
|
||||
Rejected = "REJECTED",
|
||||
}
|
||||
|
||||
/**
|
||||
* Approval gate for a consolidated (shared-wagon) booking pair.
|
||||
*
|
||||
* A booking that fills its own wagons goes straight from GL completion to the
|
||||
* operations queue. A consolidated one does not: two customers' cargo rides one
|
||||
* physical wagon, under two separate invoices and two separate liabilities. That
|
||||
* pairing is a commercial decision, so a person reviews it before Operations
|
||||
* sees either half.
|
||||
*
|
||||
* The pair is approved as a UNIT — one row covers both halves — so nobody can
|
||||
* approve one side of a shared wagon and leave the other pending. Rows are never
|
||||
* deleted: decided rows are the audit trail of who approved which pairing, when,
|
||||
* and why.
|
||||
*/
|
||||
@Entity({ schema: "freight", name: "consolidation_approvals" })
|
||||
@Index(["bookingId", "status"])
|
||||
@Index(["status"])
|
||||
export class ConsolidationApproval extends BaseEntity {
|
||||
@Column({ name: "booking_id", type: "uuid" })
|
||||
bookingId!: string;
|
||||
|
||||
@ManyToOne(() => Booking)
|
||||
@JoinColumn({ name: "booking_id" })
|
||||
booking?: Booking;
|
||||
|
||||
/** The other half of the shared wagon. */
|
||||
@Column({ name: "partner_booking_id", type: "uuid" })
|
||||
partnerBookingId!: string;
|
||||
|
||||
@ManyToOne(() => Booking)
|
||||
@JoinColumn({ name: "partner_booking_id" })
|
||||
partnerBooking?: Booking;
|
||||
|
||||
@Column({
|
||||
name: "status",
|
||||
type: "enum",
|
||||
enum: ConsolidationApprovalStatus,
|
||||
default: ConsolidationApprovalStatus.Pending,
|
||||
})
|
||||
status!: ConsolidationApprovalStatus;
|
||||
|
||||
/** IAM user id of the GL staff whose completion created the pairing. */
|
||||
@Column({ name: "requested_by", type: "uuid", nullable: true })
|
||||
requestedBy?: string | null;
|
||||
|
||||
@Column({ name: "requested_at", type: "timestamptz", default: () => "now()" })
|
||||
requestedAt!: Date;
|
||||
|
||||
/** IAM user id of the approver; null while pending. */
|
||||
@Column({ name: "decided_by", type: "uuid", nullable: true })
|
||||
decidedBy?: string | null;
|
||||
|
||||
@Column({ name: "decided_at", type: "timestamptz", nullable: true })
|
||||
decidedAt?: Date | null;
|
||||
|
||||
/** Why it was approved or rejected. Required on reject, optional on approve. */
|
||||
@Column({
|
||||
name: "decision_note",
|
||||
type: "varchar",
|
||||
length: 500,
|
||||
nullable: true,
|
||||
})
|
||||
decisionNote?: string | null;
|
||||
|
||||
// ── Snapshot ──────────────────────────────────────────────────────────────
|
||||
// Copied at request time so the audit trail still reads correctly after the
|
||||
// bookings themselves move on (rebooked to another day, cancelled, renamed).
|
||||
|
||||
@Column({ name: "scheduled_date", type: "timestamptz", nullable: true })
|
||||
scheduledDate?: Date | null;
|
||||
|
||||
@Column({
|
||||
name: "booking_reference",
|
||||
type: "varchar",
|
||||
length: 50,
|
||||
nullable: true,
|
||||
})
|
||||
bookingReference?: string | null;
|
||||
|
||||
@Column({
|
||||
name: "partner_booking_reference",
|
||||
type: "varchar",
|
||||
length: 50,
|
||||
nullable: true,
|
||||
})
|
||||
partnerBookingReference?: string | null;
|
||||
}
|
||||
@@ -30,6 +30,7 @@ describe('ContractBookingService — quantity-cap completion', () => {
|
||||
{} as never, // trainSchedulingService
|
||||
{} as never, // bookingBatchService
|
||||
{} as never, // bookingTransitionService
|
||||
{} as never, // consolidationApprovalService
|
||||
);
|
||||
return { service, contractsRepository };
|
||||
}
|
||||
@@ -156,6 +157,7 @@ describe('ContractBookingService — quantity-cap completion', () => {
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never, // consolidationApprovalService
|
||||
);
|
||||
return { service, contractsRepository };
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
|
||||
{} as never, // trainSchedulingService
|
||||
{} as never, // bookingBatchService
|
||||
{} as never, // bookingTransitionService
|
||||
{} as never, // consolidationApprovalService
|
||||
);
|
||||
return {
|
||||
service,
|
||||
|
||||
@@ -26,6 +26,7 @@ describe('ContractBookingService — customs booking gate', () => {
|
||||
{} as never, // trainSchedulingService
|
||||
{} as never, // bookingBatchService
|
||||
{} as never, // bookingTransitionService
|
||||
{} as never, // consolidationApprovalService
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { ContractBookingService } from './contract-booking.service';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
|
||||
/**
|
||||
* Manual (GL-driven) odd-20ft consolidation. On a customs contract GL completes
|
||||
* the booking, so GL also picks who shares its wagon: two bookings each carrying
|
||||
* an odd 20ft count are completed together onto one wagon.
|
||||
*
|
||||
* The two invariants that matter are that the pair is all-or-nothing (a failure
|
||||
* on either half must leave NEITHER booking completed and no link written) and
|
||||
* that the two bookings stay financially separate — one completion each, so one
|
||||
* price and one invoice each.
|
||||
*/
|
||||
describe('ContractBookingService — manual odd-20ft consolidation', () => {
|
||||
function makeService(overrides: {
|
||||
bookingsRepository?: Partial<Record<string, jest.Mock>>;
|
||||
dataSource?: unknown;
|
||||
}) {
|
||||
const bookingsRepository = {
|
||||
findByIdWithFiles: jest.fn(),
|
||||
findManualConsolidationCandidates: jest.fn().mockResolvedValue([]),
|
||||
linkConsolidationPartners: jest.fn().mockResolvedValue(undefined),
|
||||
...overrides.bookingsRepository,
|
||||
};
|
||||
|
||||
// A transaction that simply runs the callback — enough to assert the
|
||||
// all-or-nothing contract: whatever throws inside propagates out, and the
|
||||
// caller observes no link written.
|
||||
const dataSource = overrides.dataSource ?? {
|
||||
transaction: jest.fn(async (cb: (m: unknown) => Promise<unknown>) => cb({})),
|
||||
};
|
||||
|
||||
const service = new ContractBookingService(
|
||||
{ findByIdWithRelations: jest.fn() } as never,
|
||||
bookingsRepository as never,
|
||||
{} as never, // bookingPricingService
|
||||
{} as never, // consolidationService
|
||||
{} as never, // containerTypesService
|
||||
{} as never, // ruleEngineService
|
||||
{} as never, // milestoneService
|
||||
{} as never, // invoiceService
|
||||
{} as never, // bookingNotifier
|
||||
dataSource as never,
|
||||
{} as never, // trainSchedulingService
|
||||
{} as never, // bookingBatchService
|
||||
{} as never, // bookingTransitionService
|
||||
// The pairing is parked for approval rather than going straight to
|
||||
// Operations; the gate itself is covered by its own spec.
|
||||
{ requestApproval: jest.fn().mockResolvedValue({ id: 'ap-1' }) } as never,
|
||||
);
|
||||
return { service, bookingsRepository, dataSource };
|
||||
}
|
||||
|
||||
const partnerBooking = {
|
||||
id: 'b-2',
|
||||
reference: 'BK-2',
|
||||
contractId: 'c-2',
|
||||
consolidationPartnerId: null,
|
||||
} as unknown as Booking;
|
||||
|
||||
const pairDto = {
|
||||
partnerBookingId: 'b-2',
|
||||
booking: { scheduledDate: '2026-09-01' },
|
||||
partner: { scheduledDate: '2026-09-01' },
|
||||
};
|
||||
|
||||
it('completes both halves and links them', async () => {
|
||||
const { service, bookingsRepository } = makeService({
|
||||
bookingsRepository: {
|
||||
findByIdWithFiles: jest
|
||||
.fn()
|
||||
// partner lookup before the transaction
|
||||
.mockResolvedValueOnce(partnerBooking)
|
||||
// the two reloads after it
|
||||
.mockResolvedValueOnce({ id: 'b-1', reference: 'BK-1' } as Booking)
|
||||
.mockResolvedValueOnce({ id: 'b-2', reference: 'BK-2' } as Booking),
|
||||
},
|
||||
});
|
||||
|
||||
// Each half runs the ordinary completion machine — one call per booking, so
|
||||
// each is priced and invoiced on its own.
|
||||
const complete = jest
|
||||
.spyOn(service, 'completeUnderContract')
|
||||
.mockImplementation(
|
||||
async (_contractId, bookingId) =>
|
||||
({
|
||||
booking: { id: bookingId } as Booking,
|
||||
warnings: [],
|
||||
}) as never,
|
||||
);
|
||||
|
||||
const result = await service.completeConsolidatedPair(
|
||||
'c-1',
|
||||
'b-1',
|
||||
pairDto as never,
|
||||
);
|
||||
|
||||
expect(complete).toHaveBeenCalledTimes(2);
|
||||
// The partner is completed against ITS OWN contract, not this one.
|
||||
expect(complete.mock.calls[0][0]).toBe('c-1');
|
||||
expect(complete.mock.calls[1][0]).toBe('c-2');
|
||||
// Neither half may re-enter the automatic matcher — GL links them here.
|
||||
expect(complete.mock.calls[0][2]).toMatchObject({
|
||||
skipAutoConsolidation: true,
|
||||
});
|
||||
expect(complete.mock.calls[1][2]).toMatchObject({
|
||||
skipAutoConsolidation: true,
|
||||
});
|
||||
expect(bookingsRepository.linkConsolidationPartners).toHaveBeenCalledWith(
|
||||
'b-1',
|
||||
'b-2',
|
||||
);
|
||||
expect(result.booking.id).toBe('b-1');
|
||||
expect(result.partner.id).toBe('b-2');
|
||||
});
|
||||
|
||||
it('links nothing when the partner half fails (all-or-nothing)', async () => {
|
||||
const { service, bookingsRepository } = makeService({
|
||||
bookingsRepository: {
|
||||
findByIdWithFiles: jest.fn().mockResolvedValue(partnerBooking),
|
||||
},
|
||||
});
|
||||
|
||||
jest
|
||||
.spyOn(service, 'completeUnderContract')
|
||||
.mockImplementationOnce(
|
||||
async () => ({ booking: { id: 'b-1' } as Booking, warnings: [] }) as never,
|
||||
)
|
||||
.mockImplementationOnce(async () => {
|
||||
throw new Error('no train space for the partner');
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.completeConsolidatedPair('c-1', 'b-1', pairDto as never),
|
||||
).rejects.toThrow('no train space for the partner');
|
||||
|
||||
// The link is the last write in the transaction — it must never happen when
|
||||
// a half failed, so the rollback leaves no dangling pairing.
|
||||
expect(bookingsRepository.linkConsolidationPartners).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a partner that already shares a wagon', async () => {
|
||||
const { service } = makeService({
|
||||
bookingsRepository: {
|
||||
findByIdWithFiles: jest.fn().mockResolvedValue({
|
||||
...partnerBooking,
|
||||
consolidationPartnerId: 'b-9',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.completeConsolidatedPair('c-1', 'b-1', pairDto as never),
|
||||
).rejects.toThrow(/already shares a wagon/i);
|
||||
});
|
||||
|
||||
it('refuses to consolidate a booking with itself', async () => {
|
||||
const { service } = makeService({});
|
||||
|
||||
await expect(
|
||||
service.completeConsolidatedPair('c-1', 'b-1', {
|
||||
...pairDto,
|
||||
partnerBookingId: 'b-1',
|
||||
} as never),
|
||||
).rejects.toThrow(/cannot be consolidated with itself/i);
|
||||
});
|
||||
|
||||
it('offers only bookings whose own 20ft count is odd', async () => {
|
||||
// Two odd counts always sum to even, so an odd partner is exactly what fills
|
||||
// the wagon; an even one would leave the pair partial again.
|
||||
const rows = [
|
||||
{
|
||||
id: 'odd',
|
||||
reference: 'BK-ODD',
|
||||
bookingContainers: [
|
||||
{ quantity: 3, containerType: { sizeFt: 20 } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'even',
|
||||
reference: 'BK-EVEN',
|
||||
bookingContainers: [
|
||||
{ quantity: 4, containerType: { sizeFt: 20 } },
|
||||
],
|
||||
},
|
||||
// A bare instance has no cargo yet — GL enters it on the split form, so it
|
||||
// stays a candidate.
|
||||
{ id: 'bare', reference: 'BK-BARE', bookingContainers: [] },
|
||||
];
|
||||
|
||||
const { service } = makeService({
|
||||
bookingsRepository: {
|
||||
findByIdWithFiles: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ id: 'b-1', contractId: 'c-1' } as Booking),
|
||||
findManualConsolidationCandidates: jest.fn(async (booking: Booking) =>
|
||||
// Mirror the repository's in-memory odd filter.
|
||||
rows.filter((row) => {
|
||||
void booking;
|
||||
const lines = row.bookingContainers ?? [];
|
||||
if (lines.length === 0) return true;
|
||||
const ft20 = lines
|
||||
.filter((l) => Number(l.containerType?.sizeFt) === 20)
|
||||
.reduce((sum, l) => sum + Number(l.quantity || 0), 0);
|
||||
return ft20 % 2 === 1;
|
||||
}),
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
const candidates = await service.listConsolidationCandidates('c-1', 'b-1');
|
||||
expect(candidates.map((c) => c.reference)).toEqual(['BK-ODD', 'BK-BARE']);
|
||||
expect(candidates[0].ft20Quantity).toBe(3);
|
||||
expect(candidates[1].hasCargo).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -59,6 +59,7 @@ describe('ContractBookingService — changes-requested resubmit restating cargo'
|
||||
trainSchedulingService as never,
|
||||
{} as never, // bookingBatchService
|
||||
{} as never, // bookingTransitionService
|
||||
{} as never, // consolidationApprovalService
|
||||
);
|
||||
return { service, bookingsRepository, invoiceService };
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { BookingPricingService } from '../bookings/booking-pricing.service';
|
||||
import { BookingTransitionService } from '../bookings/booking-transition.service';
|
||||
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
|
||||
import { ConsolidationService } from '../bookings/consolidation.service';
|
||||
import { ConsolidationApprovalService } from '../bookings/consolidation-approval.service';
|
||||
import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
|
||||
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
|
||||
import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
|
||||
@@ -44,6 +45,7 @@ import {
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { isEffectivelyExpired } from './utils/contract-expiry.util';
|
||||
import {
|
||||
CompleteConsolidatedPairDto,
|
||||
CreateBookingContainerLineDto,
|
||||
CreateBookingUnderContractDto,
|
||||
} from './dto/create-booking-under-contract.dto';
|
||||
@@ -62,6 +64,25 @@ export interface CreateBookingUnderContractResult {
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A booking GL may pick as the shared-wagon partner of an odd-20ft customs
|
||||
* booking. `hasCargo` is false for a bare instance whose containers GL still has
|
||||
* to enter on the split completion form.
|
||||
*/
|
||||
export interface ConsolidationCandidate {
|
||||
id: string;
|
||||
reference: string;
|
||||
contractId: string | null;
|
||||
companyName: string | null;
|
||||
status: string;
|
||||
tradeDirection: string | null;
|
||||
originYardId: string | null;
|
||||
destinationYardId: string | null;
|
||||
scheduledDate: string | null;
|
||||
ft20Quantity: number;
|
||||
hasCargo: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Outstanding split remainder of a contract: what was booked in the first split
|
||||
* booking's pre-split snapshot MINUS everything currently booked. Container
|
||||
@@ -110,6 +131,8 @@ export class ContractBookingService {
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
@Inject(forwardRef(() => BookingTransitionService))
|
||||
private readonly bookingTransitionService: BookingTransitionService,
|
||||
@Inject(forwardRef(() => ConsolidationApprovalService))
|
||||
private readonly consolidationApprovalService: ConsolidationApprovalService,
|
||||
) {}
|
||||
|
||||
async createUnderContract(
|
||||
@@ -598,6 +621,146 @@ export class ContractBookingService {
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* Candidate partners a GL operator may link to an odd-20ft customs booking.
|
||||
* Manual counterpart to the automatic pairing in {@link consolidateDrawdown} —
|
||||
* a customs instance is completed by GL, so GL also chooses who shares its
|
||||
* wagon rather than waiting for the auto-matcher to find an exact complement.
|
||||
*/
|
||||
async listConsolidationCandidates(
|
||||
contractId: string,
|
||||
bookingId: string,
|
||||
): Promise<ConsolidationCandidate[]> {
|
||||
const booking = await this.bookingsRepository.findByIdWithFiles(bookingId);
|
||||
if (!booking || booking.contractId !== contractId) {
|
||||
throw new NotFoundException(`Booking ${bookingId} not found on this contract`);
|
||||
}
|
||||
|
||||
const rows = await this.bookingsRepository.findManualConsolidationCandidates(
|
||||
booking,
|
||||
);
|
||||
return rows.map((row) => {
|
||||
const lines = row.bookingContainers ?? [];
|
||||
return {
|
||||
id: row.id,
|
||||
reference: row.reference,
|
||||
contractId: row.contractId ?? null,
|
||||
companyName: row.company?.name ?? null,
|
||||
status: row.status,
|
||||
tradeDirection: row.tradeDirection ?? null,
|
||||
originYardId: row.originYardId ?? null,
|
||||
destinationYardId: row.destinationYardId ?? null,
|
||||
scheduledDate: row.scheduledDate ? row.scheduledDate.toISOString() : null,
|
||||
ft20Quantity: lines
|
||||
.filter((line) => Number(line.containerType?.sizeFt) === 20)
|
||||
.reduce((sum, line) => sum + Number(line.quantity || 0), 0),
|
||||
hasCargo: lines.length > 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete an odd-20ft customs booking together with the partner booking GL
|
||||
* picked for its shared wagon. Both halves run the ordinary
|
||||
* {@link completeUnderContract} machine — same gates, same pricing, same
|
||||
* per-booking invoice, so each customer still pays only its own shipment — and
|
||||
* are linked as consolidation partners at the end.
|
||||
*
|
||||
* All-or-nothing: the two completions plus the pairing run inside one
|
||||
* transaction, so a failure on either half leaves neither booking completed
|
||||
* and no half-linked wagon behind. `runInTransaction` is used rather than a
|
||||
* manual QueryRunner so the nested services join the same transactional
|
||||
* context through the shared DataSource.
|
||||
*/
|
||||
async completeConsolidatedPair(
|
||||
contractId: string,
|
||||
bookingId: string,
|
||||
dto: CompleteConsolidatedPairDto,
|
||||
actorPermissions?: unknown,
|
||||
/** IAM id of the GL user creating the pairing — recorded on the approval. */
|
||||
actorUserId?: string | null,
|
||||
): Promise<{
|
||||
booking: Booking;
|
||||
partner: Booking;
|
||||
warnings: string[];
|
||||
}> {
|
||||
if (dto.partnerBookingId === bookingId) {
|
||||
throw new BadRequestException(
|
||||
'A booking cannot be consolidated with itself.',
|
||||
);
|
||||
}
|
||||
|
||||
const partner = await this.bookingsRepository.findByIdWithFiles(
|
||||
dto.partnerBookingId,
|
||||
);
|
||||
if (!partner) {
|
||||
throw new NotFoundException(
|
||||
`Partner booking ${dto.partnerBookingId} not found`,
|
||||
);
|
||||
}
|
||||
if (partner.consolidationPartnerId) {
|
||||
throw new ConflictException(
|
||||
`Booking ${partner.reference} already shares a wagon with another booking.`,
|
||||
);
|
||||
}
|
||||
if (!partner.contractId) {
|
||||
throw new BadRequestException(
|
||||
`Booking ${partner.reference} is not a contract booking and cannot be completed here.`,
|
||||
);
|
||||
}
|
||||
|
||||
const warnings: string[] = [];
|
||||
|
||||
const { ownId, partnerId } = await this.dataSource.transaction(async () => {
|
||||
const own = await this.completeUnderContract(
|
||||
contractId,
|
||||
bookingId,
|
||||
{ ...dto.booking, skipAutoConsolidation: true },
|
||||
// Both halves are completed by the same GL actor that reached this
|
||||
// endpoint — the customs gate in completeUnderContract re-checks it.
|
||||
actorPermissions,
|
||||
);
|
||||
warnings.push(...own.warnings);
|
||||
|
||||
const other = await this.completeUnderContract(
|
||||
partner.contractId as string,
|
||||
partner.id,
|
||||
{ ...dto.partner, skipAutoConsolidation: true },
|
||||
actorPermissions,
|
||||
);
|
||||
warnings.push(...other.warnings);
|
||||
|
||||
// Link the two halves. Written directly (not via pairConsolidation) because
|
||||
// both bookings have just been completed into their live status here —
|
||||
// pairConsolidation exists to RESUME bookings parked in
|
||||
// PENDING_CONSOLIDATION and would overwrite that status.
|
||||
await this.bookingsRepository.linkConsolidationPartners(
|
||||
own.booking.id,
|
||||
other.booking.id,
|
||||
);
|
||||
return { ownId: own.booking.id, partnerId: other.booking.id };
|
||||
});
|
||||
|
||||
// Both halves have just been completed into the operations queue by the
|
||||
// ordinary completion machine. A shared wagon does not go there unreviewed:
|
||||
// pull the pair back into the approval gate, which releases them to
|
||||
// Operations only once a person signs off on the pairing.
|
||||
await this.consolidationApprovalService.requestApproval(
|
||||
ownId,
|
||||
partnerId,
|
||||
actorUserId ?? null,
|
||||
);
|
||||
|
||||
// Sequential reads: one connection per transaction context.
|
||||
const finalBooking = await this.bookingsRepository.findByIdWithFiles(ownId);
|
||||
const finalPartner = await this.bookingsRepository.findByIdWithFiles(partnerId);
|
||||
return {
|
||||
booking: finalBooking!,
|
||||
partner: finalPartner ?? partner,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a bare initiated booking after its per-booking clearance is
|
||||
* finalized (CLEARANCE_READY) or operations returned it for changes
|
||||
@@ -826,10 +989,18 @@ export class ContractBookingService {
|
||||
// exactly like a drawdown created with cargo does. The shipment day is
|
||||
// stored first so the pairing event can resume straight into the
|
||||
// operations queue.
|
||||
// Customs (Path B) instances are exempt from the AUTO-matcher: GL links
|
||||
// their shared wagon by hand through completeConsolidatedPair, so nothing
|
||||
// may claim a partner for them behind GL's back. A customs half completed
|
||||
// as part of a manual pair carries `skipAutoConsolidation`; one completed
|
||||
// alone still falls through to the automatic gate below, so an odd 20ft
|
||||
// booking can never proceed on a partial wagon. Non-customs drawdowns are
|
||||
// unaffected.
|
||||
const withContainers = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||
if (
|
||||
withContainers &&
|
||||
freightType === 'CONTAINER' &&
|
||||
!dto.skipAutoConsolidation &&
|
||||
(await this.consolidationService.needsConsolidationFromBooking(withContainers))
|
||||
) {
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
|
||||
@@ -358,32 +358,39 @@ export class ContractClearanceService {
|
||||
// Once GL creates the shipment booking, surface its reference + status so the
|
||||
// customer sees the concrete booking instead of a stale "will be created
|
||||
// shortly" message. Reuse the export booking load; fetch for import too.
|
||||
let linkedBookingId: string | null = null;
|
||||
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,
|
||||
bookingMilestones,
|
||||
);
|
||||
}
|
||||
// The cycle is the historical link, but it is not written on every path (an
|
||||
// FCFS export booking and a GL drawdown both reach the operations queue
|
||||
// without a cycle row), so fall back to the contract's own live booking —
|
||||
// otherwise the clearance page sees no linked booking at all and cannot show
|
||||
// its status or the actions that depend on it.
|
||||
const booking = cycle?.bookingId
|
||||
? await this.bookingsService.findById(cycle.bookingId)
|
||||
: await this.contractsRepository.findLatestBookingForContract(contractId);
|
||||
if (booking) {
|
||||
linkedBookingId = booking.id ?? null;
|
||||
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,
|
||||
bookingMilestones,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -420,7 +427,7 @@ export class ContractClearanceService {
|
||||
bookingReady: boundary,
|
||||
preClearanceFinalized: Boolean(cycle?.preClearanceFinalizedAt),
|
||||
exportClearanceFinalized: Boolean(cycle?.completedAt),
|
||||
linkedBookingId: cycle?.bookingId ?? null,
|
||||
linkedBookingId,
|
||||
linkedBookingReference,
|
||||
linkedBookingStatus,
|
||||
linkedBookingReviewNote,
|
||||
|
||||
@@ -76,7 +76,10 @@ import {
|
||||
import { SignContractDto } from './dto/sign-contract.dto';
|
||||
import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto';
|
||||
import { RenewContractDto } from './dto/renew-contract.dto';
|
||||
import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto';
|
||||
import {
|
||||
CompleteConsolidatedPairDto,
|
||||
CreateBookingUnderContractDto,
|
||||
} from './dto/create-booking-under-contract.dto';
|
||||
import {
|
||||
CreateBookingRequestDto,
|
||||
ReviewBookingRequestDto,
|
||||
@@ -1152,10 +1155,48 @@ export class ContractsController {
|
||||
// Customs (Path B) instances may only be completed by GL Ethiopia — the
|
||||
// service checks the actor's contracts:create_booking permission.
|
||||
return this.contractBookingService.completeUnderContract(
|
||||
id,
|
||||
bookingId,
|
||||
// skipAutoConsolidation is internal to the manual pair-completion path; a
|
||||
// client must never suppress the wagon gate on a lone booking.
|
||||
{ ...dto, skipAutoConsolidation: false },
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':id/bookings/:bookingId/consolidation-candidates')
|
||||
@MixedAudience(FREIGHT_PERMS.contracts.createBooking)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Bookings GL may link to this odd-20ft customs booking as its shared-wagon partner (same route and direction, customs, odd 20ft, unpaired).',
|
||||
})
|
||||
listConsolidationCandidates(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
) {
|
||||
return this.contractBookingService.listConsolidationCandidates(id, bookingId);
|
||||
}
|
||||
|
||||
@Post(':id/bookings/:bookingId/complete-consolidated')
|
||||
@MixedAudience(FREIGHT_PERMS.contracts.createBooking)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Complete this booking and its chosen shared-wagon partner together (all-or-nothing). Each booking is priced and invoiced separately — only the wagon is shared.',
|
||||
})
|
||||
completeConsolidatedPair(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@Body() dto: CompleteConsolidatedPairDto,
|
||||
@CurrentUser() user: TCurrentUser & { sub?: string },
|
||||
) {
|
||||
return this.contractBookingService.completeConsolidatedPair(
|
||||
id,
|
||||
bookingId,
|
||||
dto,
|
||||
user,
|
||||
// Recorded as the requester on the approval: the person who created the
|
||||
// pairing may not be the one who approves it.
|
||||
user?.id ?? user?.sub ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -656,6 +656,31 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
.getCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* The live shipment booking on a contract, newest first.
|
||||
*
|
||||
* The clearance view historically reached the booking through
|
||||
* `currentCycle().bookingId`, but a cycle row is not created on every path —
|
||||
* an FCFS export booking and a GL drawdown both reach
|
||||
* OPERATION_REQUEST_PENDING without one — so that lookup returns null and the
|
||||
* clearance page loses the booking's status entirely. This resolves it from
|
||||
* the bookings themselves, which is the authoritative link (bookings carry
|
||||
* contract_id), and is used as the fallback when the cycle has no booking.
|
||||
*/
|
||||
async findLatestBookingForContract(
|
||||
contractId: string,
|
||||
): Promise<Booking | null> {
|
||||
return this.dataSource
|
||||
.getRepository(Booking)
|
||||
.createQueryBuilder('b')
|
||||
.where('b.contract_id = :contractId', { contractId })
|
||||
.andWhere('b.status NOT IN (:...terminal)', {
|
||||
terminal: TERMINAL_BOOKING_STATUSES,
|
||||
})
|
||||
.orderBy('b.created_at', 'DESC')
|
||||
.getOne();
|
||||
}
|
||||
|
||||
async createReviewNote(
|
||||
contractId: string,
|
||||
body: string,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { ApiHideProperty, ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
@@ -219,4 +219,46 @@ export class CreateBookingUnderContractDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
|
||||
/**
|
||||
* Internal: set by the manual GL pair-completion path, never by a client.
|
||||
* Suppresses the automatic wagon-consolidation gate for this completion
|
||||
* because the caller links the shared wagon itself. Excluded from the public
|
||||
* schema so a client cannot set it to bypass the gate on a lone booking.
|
||||
*/
|
||||
@ApiHideProperty()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
skipAutoConsolidation?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete an odd-20ft customs booking together with the partner booking GL
|
||||
* picked to share its wagon. Each half carries its own full completion payload —
|
||||
* the two bookings stay separately priced and separately invoiced, they only
|
||||
* share the wagon.
|
||||
*/
|
||||
export class CompleteConsolidatedPairDto {
|
||||
@ApiProperty({
|
||||
format: 'uuid',
|
||||
description: 'The booking chosen to share this booking’s wagon.',
|
||||
})
|
||||
@IsUUID()
|
||||
partnerBookingId!: string;
|
||||
|
||||
@ApiProperty({
|
||||
type: CreateBookingUnderContractDto,
|
||||
description: 'Completion payload for the booking in the URL.',
|
||||
})
|
||||
@ValidateNested()
|
||||
@Type(() => CreateBookingUnderContractDto)
|
||||
booking!: CreateBookingUnderContractDto;
|
||||
|
||||
@ApiProperty({
|
||||
type: CreateBookingUnderContractDto,
|
||||
description: 'Completion payload for the partner booking.',
|
||||
})
|
||||
@ValidateNested()
|
||||
@Type(() => CreateBookingUnderContractDto)
|
||||
partner!: CreateBookingUnderContractDto;
|
||||
}
|
||||
|
||||
@@ -226,6 +226,14 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
"edr_freight_app:bookings:wagon_cancellation_rebook",
|
||||
"Rebook cancelled wagons for a customer",
|
||||
),
|
||||
// Shared-wagon gate: two customers' cargo on one wagon is a commercial call,
|
||||
// so it is signed off separately from the ordinary booking approvals — and
|
||||
// never by the GL user who created the pairing.
|
||||
perm(
|
||||
"a1000001-0001-4000-8000-000000000029",
|
||||
"edr_freight_app:bookings:approve_consolidation",
|
||||
"Approve shared-wagon consolidation",
|
||||
),
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -1782,6 +1790,7 @@ export const FREIGHT_PERMS = {
|
||||
wagonCancellationVoid: "edr_freight_app:bookings:wagon_cancellation_void",
|
||||
wagonCancellationRebook:
|
||||
"edr_freight_app:bookings:wagon_cancellation_rebook",
|
||||
approveConsolidation: "edr_freight_app:bookings:approve_consolidation",
|
||||
// Notification selectors, not route guards — see NOTIFICATION_PERMISSIONS.
|
||||
getNotification: "edr_freight_app:bookings:get_notification",
|
||||
clearanceGetNotification:
|
||||
@@ -2390,6 +2399,10 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
FREIGHT_PERMS.bookings.reject,
|
||||
FREIGHT_PERMS.bookings.approveLineStaff,
|
||||
FREIGHT_PERMS.bookings.rejectApproval,
|
||||
// Shared-wagon gate: two customers' cargo on one wagon is a commercial
|
||||
// call. Granted to the approver roles and NOT to GL — GL creates the
|
||||
// pairing, so GL approving it would defeat the second pair of eyes.
|
||||
FREIGHT_PERMS.bookings.approveConsolidation,
|
||||
FREIGHT_PERMS.bookings.cancel,
|
||||
FREIGHT_PERMS.bookings.wagonCancellationView,
|
||||
FREIGHT_PERMS.bookings.wagonCancellationVoid,
|
||||
@@ -2446,6 +2459,10 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
FREIGHT_PERMS.bookings.view,
|
||||
FREIGHT_PERMS.bookings.approveDirector,
|
||||
FREIGHT_PERMS.bookings.rejectApproval,
|
||||
// Shared-wagon gate: two customers' cargo on one wagon is a commercial
|
||||
// call. Granted to the approver roles and NOT to GL — GL creates the
|
||||
// pairing, so GL approving it would defeat the second pair of eyes.
|
||||
FREIGHT_PERMS.bookings.approveConsolidation,
|
||||
FREIGHT_PERMS.bookings.generateContract,
|
||||
FREIGHT_PERMS.contracts.view,
|
||||
FREIGHT_PERMS.contracts.approveDirector,
|
||||
@@ -2458,6 +2475,10 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
FREIGHT_PERMS.bookings.view,
|
||||
FREIGHT_PERMS.bookings.approveCeo,
|
||||
FREIGHT_PERMS.bookings.rejectApproval,
|
||||
// Shared-wagon gate: two customers' cargo on one wagon is a commercial
|
||||
// call. Granted to the approver roles and NOT to GL — GL creates the
|
||||
// pairing, so GL approving it would defeat the second pair of eyes.
|
||||
FREIGHT_PERMS.bookings.approveConsolidation,
|
||||
FREIGHT_PERMS.contracts.view,
|
||||
FREIGHT_PERMS.contracts.approveCeo,
|
||||
...allRuleEngineViewKeys(),
|
||||
@@ -2536,6 +2557,10 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
FREIGHT_PERMS.bookings.reject,
|
||||
FREIGHT_PERMS.bookings.approveLineStaff,
|
||||
FREIGHT_PERMS.bookings.rejectApproval,
|
||||
// Shared-wagon gate: two customers' cargo on one wagon is a commercial
|
||||
// call. Granted to the approver roles and NOT to GL — GL creates the
|
||||
// pairing, so GL approving it would defeat the second pair of eyes.
|
||||
FREIGHT_PERMS.bookings.approveConsolidation,
|
||||
FREIGHT_PERMS.bookings.cancel,
|
||||
FREIGHT_PERMS.bookings.wagonCancellationView,
|
||||
FREIGHT_PERMS.bookings.wagonCancellationVoid,
|
||||
|
||||
Reference in New Issue
Block a user