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

Freight feature/usermanagement
This commit is contained in:
marshal
2026-08-18 16:19:34 +03:00
committed by GitHub
53 changed files with 4019 additions and 218 deletions

View File

@@ -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(

View File

@@ -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,
});
});
});

View File

@@ -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, [

View File

@@ -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" })

View File

@@ -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,

View File

@@ -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, {

View File

@@ -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,
);
});
});

View File

@@ -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;
}
/**
* 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

@@ -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,
},
],
});
}
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;
}