mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-06 18:15:03 +00:00
add goverment booking
This commit is contained in:
@@ -112,15 +112,9 @@ export class BookingContractService {
|
||||
const templateKey = this.templateResolver.resolve(booking);
|
||||
const summary = this.buildContractSummary(booking);
|
||||
|
||||
// PDF rendering (Puppeteer/Chromium) is best-effort and must NOT block the contract
|
||||
// from becoming ready — the document is (re)rendered lazily on view/download.
|
||||
try {
|
||||
await this.upsertContractPdf(bookingId, booking.reference, templateKey);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Contract PDF deferred for ${booking.reference}: ${err}. It will render on view/download once Chromium is available.`,
|
||||
);
|
||||
}
|
||||
// No eager PDF render here: streamContract re-renders the document on every
|
||||
// view/download, so rendering now only adds a Chromium launch (seconds, or a
|
||||
// 60s asset-load hang) inside the staff-accept request.
|
||||
|
||||
const now = new Date();
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
@@ -132,6 +126,22 @@ export class BookingContractService {
|
||||
return updated!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Government bookings skip the whole customer contract flow (approve →
|
||||
* CONTRACT_READY → sign chain): their contract is stamped server-side at
|
||||
* creation/expedite WITHOUT touching booking status — the booking is already
|
||||
* PAID/allocatable and the contract can be signed at any time. Idempotent.
|
||||
*/
|
||||
async generateContractForGovernment(bookingId: string): Promise<void> {
|
||||
const booking = await this.requireBooking(bookingId);
|
||||
if (!booking.isGovernment || booking.contractGeneratedAt) return;
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
contractSummary: this.buildContractSummary(booking),
|
||||
contractTemplateKey: this.templateResolver.resolve(booking),
|
||||
contractGeneratedAt: new Date(),
|
||||
} as never);
|
||||
}
|
||||
|
||||
async streamContract(bookingId: string) {
|
||||
const booking = await this.requireBooking(bookingId);
|
||||
const templateKey =
|
||||
@@ -152,8 +162,13 @@ export class BookingContractService {
|
||||
const booking = await this.requireBooking(bookingId);
|
||||
const role = dto.role as ContractSignerRole;
|
||||
|
||||
// Government contracts are order-free and status-free: either party may
|
||||
// sign at any time (each once) — the booking is already expedited past the
|
||||
// customer contract flow, so no status gate applies.
|
||||
if (role === 'CUSTOMER') {
|
||||
assertBookingStatus(booking, ['CONTRACT_READY']);
|
||||
if (!booking.isGovernment) {
|
||||
assertBookingStatus(booking, ['CONTRACT_READY']);
|
||||
}
|
||||
const existing = await this.bookingsRepository.findContractSignature(
|
||||
bookingId,
|
||||
'CUSTOMER',
|
||||
@@ -162,7 +177,9 @@ export class BookingContractService {
|
||||
throw new BadRequestException('Customer has already signed this contract');
|
||||
}
|
||||
} else {
|
||||
assertBookingStatus(booking, ['SIGNED_CUSTOMER']);
|
||||
if (!booking.isGovernment) {
|
||||
assertBookingStatus(booking, ['SIGNED_CUSTOMER']);
|
||||
}
|
||||
const existing = await this.bookingsRepository.findContractSignature(
|
||||
bookingId,
|
||||
'STAFF',
|
||||
@@ -235,20 +252,30 @@ export class BookingContractService {
|
||||
);
|
||||
|
||||
if (role === 'CUSTOMER') {
|
||||
updates.status = 'SIGNED_CUSTOMER';
|
||||
updates.customerSignedAt = now;
|
||||
// Government bookings keep their operational status (PAID) — a signature
|
||||
// must never pull them back into the customer workflow.
|
||||
if (!booking.isGovernment) updates.status = 'SIGNED_CUSTOMER';
|
||||
} else {
|
||||
updates.fullyExecutedAt = now;
|
||||
updates.marketingApprovedAt = now;
|
||||
updates.marketingApprovedById = options.signerUserId ?? null;
|
||||
updates.lockedAt = now;
|
||||
updates.status = clearanceCode ? 'AWAITING_DOCUMENTS' : 'FULLY_EXECUTED';
|
||||
if (!booking.isGovernment) {
|
||||
updates.lockedAt = now;
|
||||
updates.status = clearanceCode ? 'AWAITING_DOCUMENTS' : 'FULLY_EXECUTED';
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, updates as never);
|
||||
// Only the non-clearance (legacy/domestic) path enters the batch pipeline now;
|
||||
// clearance bookings enter operations after the GL document gate.
|
||||
if (role === 'STAFF' && !clearanceCode && updated?.trainScheduleId) {
|
||||
// clearance bookings enter operations after the GL document gate. Government
|
||||
// bookings are already in the pool from expedite — signing changes nothing.
|
||||
if (
|
||||
role === 'STAFF' &&
|
||||
!booking.isGovernment &&
|
||||
!clearanceCode &&
|
||||
updated?.trainScheduleId
|
||||
) {
|
||||
this.bookingBatchService.enqueueScheduleProcessing(updated.trainScheduleId);
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -32,6 +32,8 @@ import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { Contract } from '../contracts/entities/contract.entity';
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { BookingContractService } from './booking-contract.service';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { ConsolidationService } from './consolidation.service';
|
||||
import { VehiclesService } from '../vehicles/vehicles.service';
|
||||
@@ -103,6 +105,10 @@ export class BookingsService {
|
||||
private readonly vehiclesService: VehiclesService,
|
||||
private readonly pdfRender: PdfRenderService,
|
||||
private readonly events: EventEmitter2,
|
||||
@Inject(forwardRef(() => BookingContractService))
|
||||
private readonly bookingContractService: BookingContractService,
|
||||
@Inject(forwardRef(() => BookingBatchService))
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
) {}
|
||||
|
||||
async assignCustomerTruck(
|
||||
@@ -982,6 +988,21 @@ export class BookingsService {
|
||||
warnings.push(...consolidation.messages);
|
||||
}
|
||||
|
||||
// Government bookings pass every customer step at creation: the server
|
||||
// expedites them to PAID/Eligible, generates the contract (signable at any
|
||||
// time) and queues priority placement. Best-effort — the booking row is
|
||||
// already inserted, so a late failure must not 500 the whole create; the
|
||||
// idempotent expedite endpoint remains the retry path.
|
||||
if (isGovernment) {
|
||||
try {
|
||||
full = await this.governmentExpedite(booking.id, userId ?? 'system');
|
||||
} catch (err) {
|
||||
warnings.push(
|
||||
`Government expedite incomplete — retry via the expedite action: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { booking: full, warnings };
|
||||
}
|
||||
|
||||
@@ -1830,13 +1851,22 @@ export class BookingsService {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Staff expedite: mark a government booking PAID and ready for scheduling (no commercial hold). */
|
||||
/**
|
||||
* Expedite a government booking past every customer step: PAID + Eligible
|
||||
* (no commercial hold, no payment), contract generated server-side (signable
|
||||
* at any time), and the (route, day) fill kicked immediately so it grabs a
|
||||
* seat on any open train — government-first, preempting commercial cargo if
|
||||
* the day is full. Runs automatically at creation; the endpoint remains as a
|
||||
* no-op-safe retry for older bookings.
|
||||
*/
|
||||
async governmentExpedite(id: string, staffUserId: string): Promise<Booking> {
|
||||
const booking = await this.findById(id);
|
||||
if (!booking.isGovernment) {
|
||||
throw new BadRequestException('Only government bookings can be expedited');
|
||||
}
|
||||
const blocked = ['PAID', 'IN_TRANSIT', 'ARRIVED', 'COMPLETED', 'CANCELLED', 'REJECTED'];
|
||||
// Idempotent: create() already expedites — a repeat call changes nothing.
|
||||
if (booking.status === 'PAID') return booking;
|
||||
const blocked = ['IN_TRANSIT', 'ARRIVED', 'COMPLETED', 'CANCELLED', 'REJECTED'];
|
||||
if (blocked.includes(booking.status)) {
|
||||
throw new BadRequestException(`Cannot expedite booking in status ${booking.status}`);
|
||||
}
|
||||
@@ -1848,12 +1878,22 @@ export class BookingsService {
|
||||
holdStartedAt: null,
|
||||
holdExpiresAt: null,
|
||||
});
|
||||
await this.bookingContractService.generateContractForGovernment(id);
|
||||
await this.bookingsRepository.createReviewNote(
|
||||
id,
|
||||
`Government booking expedited to PAID by staff (${staffUserId})`,
|
||||
'STAFF_NOTE',
|
||||
staffUserId,
|
||||
);
|
||||
// Priority placement: run the day-level fill now instead of waiting for a
|
||||
// batch tick — the pool sorts government first and preempts if needed.
|
||||
if (booking.scheduledDate) {
|
||||
this.bookingBatchService.enqueueRouteDayProcessing(
|
||||
booking.originYardId,
|
||||
booking.destinationYardId,
|
||||
eatDay(booking.scheduledDate),
|
||||
);
|
||||
}
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ export class SavedSignatureViewDto {
|
||||
|
||||
@ApiPropertyOptional()
|
||||
signatureImageUrl?: string | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
stampImageUrl?: string | null;
|
||||
}
|
||||
|
||||
export class ContractViewDto {
|
||||
|
||||
Reference in New Issue
Block a user