add goverment booking

This commit is contained in:
Marshal
2026-07-31 00:41:51 +00:00
parent 34e4a7d13f
commit 65f18b4796
35 changed files with 913 additions and 88 deletions

View File

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

View File

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

View File

@@ -20,6 +20,9 @@ export class SavedSignatureViewDto {
@ApiPropertyOptional()
signatureImageUrl?: string | null;
@ApiPropertyOptional()
stampImageUrl?: string | null;
}
export class ContractViewDto {