mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 08:48:11 +00:00
feat: implement consolidation approval process for shared-wagon bookings
- Add migration for consolidation approvals table and status enum - Create ConsolidationApprovalService to handle approval logic - Implement repository for managing consolidation approvals - Add entity for consolidation approval with necessary fields - Develop frontend components for displaying and managing consolidation approvals - Create tests for consolidation approval service to ensure correct behavior
This commit is contained in:
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,9 @@ describe('ContractBookingService — manual odd-20ft consolidation', () => {
|
||||
{} 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 };
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -130,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(
|
||||
@@ -674,6 +677,8 @@ export class ContractBookingService {
|
||||
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;
|
||||
@@ -736,6 +741,16 @@ export class ContractBookingService {
|
||||
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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1194,6 +1194,9 @@ export class ContractsController {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user