Files
edr-platform/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.ts
2026-08-23 05:00:43 +00:00

417 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
BadRequestException,
ConflictException,
ForbiddenException,
Inject,
Injectable,
Logger,
NotFoundException,
forwardRef,
} from "@nestjs/common";
import { DataSource, In } 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";
import { YardScopeService } from "../rule-engine/services/yard-scope.service";
import { Contract } from "../contracts/entities/contract.entity";
/** 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";
/** An approval row with the requester's and decider's names resolved. */
export type ConsolidationApprovalView = ConsolidationApproval & {
requestedByName: string | null;
decidedByName: string | null;
/** Contract the booking half was created under — reviewers work by contract. */
contractReference: string | null;
partnerContractReference: string | null;
};
/**
* 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.
*
* No maker-checker separation: whoever holds the approve permission may decide a
* pairing, including the GL user who created it. The record of who requested and
* who decided is still kept either way.
*/
@Injectable()
export class ConsolidationApprovalService {
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,
private readonly yardScope: YardScopeService,
) {}
/**
* 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,
user?: unknown,
): Promise<{ booking: Booking; partner: Booking }> {
// A pairing that was rejected can still be approved later — the reviewer
// changed their mind, or GL argued the case. Only an already-approved one
// is final, since both halves have moved on to Operations by then.
const approval = await this.loadDecidable(approvalId, [
ConsolidationApprovalStatus.Pending,
ConsolidationApprovalStatus.Rejected,
]);
await this.assertInScope(approval, user);
await this.dataSource.transaction(async () => {
const claimed = await this.approvals.decide(
approval.id,
ConsolidationApprovalStatus.Approved,
decidedBy,
note,
[
ConsolidationApprovalStatus.Pending,
ConsolidationApprovalStatus.Rejected,
],
);
// 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,
user?: unknown,
): Promise<{ booking: Booking; partner: Booking }> {
if (!reason?.trim()) {
throw new BadRequestException(
"A reason is required to reject a consolidation.",
);
}
const approval = await this.loadDecidable(approvalId, [
ConsolidationApprovalStatus.Pending,
]);
await this.assertInScope(approval, user);
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 };
}
/**
* One page of the review queue, or of its history: pending pairings first,
* then the decided ones, each carrying the display name of whoever requested
* and whoever decided it — the stored ids tell a reviewer nothing.
*
* `user` narrows the whole thing to the caller's yards: a Mojo desk sees the
* pairings that start or end at Mojo, a desk mapped to Mojo AND Adama sees
* both yards' pairings. The counts behind the tabs are narrowed the same way,
* so a badge never promises rows the caller cannot open.
*/
async queue(options?: {
status?: ConsolidationApprovalStatus;
page?: number;
pageSize?: number;
/** The `/auth/me` caller. Omit only for internal, unscoped reads. */
user?: unknown;
}): Promise<{
items: ConsolidationApprovalView[];
total: number;
/** Counts per status within the caller's scope — the tab badges. */
counts: Record<ConsolidationApprovalStatus, number>;
meta: {
page: number;
pageSize: number;
total: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
};
}> {
const page = Math.max(1, options?.page ?? 1);
const pageSize = Math.min(100, Math.max(1, options?.pageSize ?? 10));
const yardIds = await this.scopedYardIds(options?.user);
const { items: rows, total } = await this.approvals.findQueuePage({
status: options?.status,
yardIds,
page,
pageSize,
});
const counts = await this.approvals.countByStatus(yardIds);
const names = await this.bookingsRepository.resolveStaffNames(
rows.flatMap((r) => [r.requestedBy, r.decidedBy]),
);
const contractRefs = await this.contractReferences(rows);
const refOf = (contractId?: string | null) =>
contractId ? (contractRefs.get(contractId) ?? null) : null;
const items = rows.map((row) => ({
...row,
requestedByName: row.requestedBy
? (names.get(row.requestedBy) ?? null)
: null,
decidedByName: row.decidedBy ? (names.get(row.decidedBy) ?? null) : null,
contractReference: refOf(row.booking?.contractId),
partnerContractReference: refOf(row.partnerBooking?.contractId),
}));
const totalPages = Math.ceil(total / pageSize);
return {
items,
total,
counts,
meta: {
page,
pageSize,
total,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
};
}
/**
* Contract id → reference for the bookings on this page.
*
* Booking has no contract relation (contractbooking split), so the
* references are batch-loaded by id rather than joined — one query per page,
* not one per row.
*/
private async contractReferences(
rows: ConsolidationApproval[],
): Promise<Map<string, string>> {
const ids = [
...new Set(
rows
.flatMap((r) => [r.booking?.contractId, r.partnerBooking?.contractId])
.filter((id): id is string => !!id),
),
];
if (!ids.length) return new Map();
const contracts = await this.dataSource.getRepository(Contract).find({
where: { id: In(ids) },
select: { id: true, reference: true },
});
return new Map(contracts.map((c) => [c.id, c.reference]));
}
/**
* Yard ids the caller may see, or undefined for unrestricted.
*
* Scope comes from the desk they are logged in as: `yard_positions` maps a
* position to its yards, so a Mojo CEO resolves to [Mojo]. A super admin, a
* `yards:view_all` holder, and a desk with NO yard mapping all resolve to
* unrestricted — the mapping narrows access, it never grants it.
*
* Called with no user only from internal paths, which are unscoped.
*/
private async scopedYardIds(user: unknown): Promise<string[] | undefined> {
if (!user) return undefined;
const scope = await this.yardScope.getScopedYardIds(user as never);
return scope ?? undefined;
}
/** 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);
}
/**
* Refuse a decision on a pairing outside the caller's yards.
*
* Hiding the row from the list is not enough on its own: the id is guessable
* from a shared link, and deciding a pairing moves two other yards' bookings.
* Same rule as the list — either half's origin or destination is enough.
*/
private async assertInScope(
approval: ConsolidationApproval,
user: unknown,
): Promise<void> {
const yardIds = await this.scopedYardIds(user);
if (!yardIds) return;
const booking = await this.bookingsService.findById(approval.bookingId);
const partner = await this.bookingsService.findById(
approval.partnerBookingId,
);
const touches = (b: Booking | null | undefined) =>
!!b &&
(yardIds.includes(b.originYardId) ||
yardIds.includes(b.destinationYardId));
if (!touches(booking) && !touches(partner)) {
throw new ForbiddenException(
"This shared wagon is outside your assigned yards.",
);
}
}
/** Load a row and refuse it unless it is in one of the decidable states. */
private async loadDecidable(
approvalId: string,
allowed: ConsolidationApprovalStatus[],
): Promise<ConsolidationApproval> {
const approval = await this.approvals.findById(approvalId);
if (!approval) {
throw new NotFoundException(`Approval ${approvalId} not found`);
}
if (!allowed.includes(approval.status)) {
throw new ConflictException(
`This consolidation was already ${approval.status.toLowerCase()}.`,
);
}
return approval;
}
}