mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
417 lines
14 KiB
TypeScript
417 lines
14 KiB
TypeScript
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 (contract–booking 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;
|
||
}
|
||
}
|