feat(freight): surface scheduling clock and gate pay during drain

This commit is contained in:
marshalyordanos
2026-08-10 21:47:28 +03:00
parent d3096939e7
commit 40672e9c41
14 changed files with 524 additions and 25 deletions

View File

@@ -34,6 +34,7 @@ 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 { paymentDrainEndsAtIso } from '../train-scheduling/booking-batch.constants';
import { BookingContractService } from './booking-contract.service';
import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from './consolidation.service';
@@ -57,6 +58,24 @@ import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto'
import { PdfRenderService } from '../billing/documents/pdf-render.service';
import { buildTabularFallbackPdf } from '../billing/documents/styled-pdf.util';
/**
* The allocated train as the backoffice booking detail page needs it: which
* train, its window phase, and both the planned and actual clock. Attached by
* `findById` only when the booking is on a schedule.
*/
export interface TrainScheduleSummary {
id: string;
reference: string | null;
trainNumber: string | null;
status: string | null;
scheduledDepartureDate: string | null;
scheduledArrivalDate: string | null;
actualDepartureAt: string | null;
actualArrivalAt: string | null;
windowPhase: string | null;
paymentPhaseEndsAt: string | null;
}
/** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */
export interface PaginatedBookings {
items: Booking[];
@@ -1738,6 +1757,20 @@ export class BookingsService {
(b as Booking & { handoverAwaitingSignature?: boolean }).handoverAwaitingSignature =
pending.has(b.id);
}
this.attachPaymentDrainEnds(bookings);
}
/**
* Derived, no query: end of the settlement drain tail after `paymentDeadline`.
* The portal hides "Pay now" between the deadline and this instant — a payment
* started just before the buzzer is still settling, so offering to pay again
* would invite a double payment.
*/
private attachPaymentDrainEnds(bookings: Booking[]): void {
for (const b of bookings) {
(b as Booking & { paymentDrainEndsAt?: string | null }).paymentDrainEndsAt =
paymentDrainEndsAtIso(b.paymentDeadline);
}
}
async findAll(
@@ -2105,8 +2138,33 @@ export class BookingsService {
.findOne({ where: { id: booking.trainScheduleId } });
(booking as Booking & { trainScheduleStatus?: string | null }).trainScheduleStatus =
schedule?.status ?? null;
// Backoffice staff view: the allocated train's identity and clock, so the
// detail page can state which train the booking rides and when it runs
// without a second round-trip to the schedules API.
(
booking as Booking & { trainScheduleSummary?: TrainScheduleSummary | null }
).trainScheduleSummary = schedule
? {
id: schedule.id,
reference: schedule.reference ?? null,
trainNumber: schedule.trainNumber ?? null,
status: schedule.status ?? null,
scheduledDepartureDate: schedule.scheduledDepartureDate?.toISOString() ?? null,
scheduledArrivalDate: schedule.scheduledArrivalDate?.toISOString() ?? null,
actualDepartureAt: schedule.actualDepartureAt?.toISOString() ?? null,
actualArrivalAt: schedule.actualArrivalAt?.toISOString() ?? null,
windowPhase: schedule.windowPhase ?? null,
paymentPhaseEndsAt: schedule.paymentPhaseEndsAt?.toISOString() ?? null,
}
: null;
}
// End of this booking's own pay window including the settlement drain tail —
// the deadline staff should quote, since a payment landing inside the drain
// still counts (see paymentDrainEndsAtIso).
(booking as Booking & { paymentDrainEndsAt?: string | null }).paymentDrainEndsAt =
paymentDrainEndsAtIso(booking.paymentDeadline);
// A generated-but-unsigned handover means the customer must approve delivery
// from the portal. Self-haul: booking-based, one per booking. EDR last-mile:
// per delivering truck (generated on truck exit), signed one by one.

View File

@@ -3680,23 +3680,24 @@ export class BookingBatchService implements OnModuleInit {
// (provider query errored / payment still in flight) means we could not
// confirm "not paid" — never expire on unknown; the next settle tick
// asks again.
if (reason === "payment" && (fresh?.paymentDeadline ?? booking.paymentDeadline)) {
const reconcile = await this.billing.reconcilePayable(booking.id);
if (reconcile.paid) {
this.logger.log(
`[BATCH] expire skipped for ${booking.reference} — gateway ` +
`reconcile found a settled payment; payment.succeeded will allocate it`,
);
return;
}
if (reconcile.unverifiable) {
this.logger.warn(
`[BATCH] expire deferred for ${booking.reference} — settlement ` +
`unverifiable at the gateway; retrying next settle tick`,
);
return;
}
}
// TODO: CBE has no reconcile endpoint yet — re-enable once available.
// if (reason === "payment" && (fresh?.paymentDeadline ?? booking.paymentDeadline)) {
// const reconcile = await this.billing.reconcilePayable(booking.id);
// if (reconcile.paid) {
// this.logger.log(
// `[BATCH] expire skipped for ${booking.reference} — gateway ` +
// `reconcile found a settled payment; payment.succeeded will allocate it`,
// );
// return;
// }
// if (reconcile.unverifiable) {
// this.logger.warn(
// `[BATCH] expire deferred for ${booking.reference} — settlement ` +
// `unverifiable at the gateway; retrying next settle tick`,
// );
// return;
// }
// }
}
const freedScheduleId = booking.trainScheduleId;
await this.bookingsRepository.update(booking.id, {